Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wide-columns-stretch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add an opt-in wide layout: a toggle beside Stream/Timeline lets the feed column grow past 860px (up to 1600px) on wide windows so diffs, code, diagrams and html use the space, while markdown prose stays capped at its normal-column width (code blocks and tables inside markdown go full width), and long lines in markdown code blocks, code, terminal and diff surfaces soft-wrap instead of scrolling sideways. The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default.
138 changes: 138 additions & 0 deletions e2e/width.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { expect, publishParts, test } from "./fixtures.ts";
import type { Page } from "@playwright/test";
import { isSoftWrapSurfaceKind, SURFACE_FRAME_CLASSES, SURFACE_KINDS } from "../server/types.ts";

const SOFT_WRAP_KINDS = SURFACE_KINDS.filter(isSoftWrapSurfaceKind);

// Wide mode (issue #223): a workspace setting that lets the feed column grow past
// its 860px cap on a wide window, with markdown kept at a readable measure.
const LONG = `const value = compute(${"x".repeat(130)});`; // ~150 chars
const PARTS = [
{
kind: "markdown",
markdown: `${"Some prose that should keep a readable measure. ".repeat(12)}\n\n\`\`\`text\n${LONG}\n\`\`\``,
},
{ kind: "diff", patch: `--- a/x\n+++ b/x\n@@ -1 +1 @@\n-${LONG}\n+${LONG}y` },
];

const streamWidth = (page: Page) =>
page.locator("#stream").evaluate((el) => el.getBoundingClientRect().width);
// Rendered widths of the markdown prose paragraph and its code block.
const markdownWidths = (page: Page) =>
page
.frameLocator(".card:not(#whatsNew) iframe.mdframe")
.locator("body")
.evaluate(() => ({
prose: document.querySelector("p")!.getBoundingClientRect().width,
code: document.querySelector("pre")!.getBoundingClientRect().width,
}));
// Widest sideways scroll inside a surface frame (@pierre/diffs renders in shadow roots).
const frameOverflow = (page: Page, frame: string) =>
page
.frameLocator(`.card:not(#whatsNew) iframe.${frame}`)
.locator("body")
.evaluate(() => {
let max = 0;
const walk = (root: Document | ShadowRoot) => {
for (const el of root.querySelectorAll("*")) {
if (/(auto|scroll)/.test(getComputedStyle(el).overflowX)) {
max = Math.max(max, el.scrollWidth - el.clientWidth);
}
if (el.shadowRoot) walk(el.shadowRoot);
}
};
walk(document);
return max;
})
// a width switch reloads the frame mid-poll; NaN fails both bounds, so poll again
.catch(() => Number.NaN);
const diffOverflow = (page: Page) => frameOverflow(page, "diffframe");

test.use({ viewport: { width: 1930, height: 1000 } });

test("the wide toggle widens the column, persists, and toggles back to normal", async ({
page,
server,
}) => {
await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS });
await page.goto(server.url);
const toggle = page.locator("#widthToggle");

// normal (the default): the classic 860px column; the long diff line scrolls
await expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(await streamWidth(page)).toBe(860);
await expect.poll(() => diffOverflow(page)).toBeGreaterThan(1);
const normalMarkdown = await markdownWidths(page);
expect(normalMarkdown.prose).toBe(normalMarkdown.code);

await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => streamWidth(page)).toBe(1600);
// the diff now fits; markdown prose keeps its readable measure while its code
// block takes the full width
await expect.poll(() => diffOverflow(page)).toBeLessThanOrEqual(1);
await expect.poll(async () => (await markdownWidths(page)).code).toBeGreaterThan(1200);
expect((await markdownWidths(page)).prose).toBeLessThanOrEqual(780);

// PUT /api/width persisted it for the workspace
await page.reload();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => streamWidth(page)).toBe(1600);

await toggle.click();
await expect.poll(() => streamWidth(page)).toBe(860);
await expect.poll(() => markdownWidths(page)).toEqual(normalMarkdown);
});

test("another open tab follows a width switch live", async ({ page, context, server }) => {
await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS });
const other = await context.newPage();
await page.goto(server.url);
await other.goto(server.url);
await expect(other.locator(".card:not(#whatsNew)")).toBeVisible();
expect(await streamWidth(other)).toBe(860);

await page.locator("#widthToggle").click();
await expect.poll(() => streamWidth(other)).toBe(1600);
});

test("phone layout is unchanged in wide mode and hides the toggle", async ({ page, server }) => {
await publishParts(server.url, { title: "Wide", agent: "e2e", parts: PARTS });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(server.url);
await expect(page.locator(".card:not(#whatsNew)")).toBeVisible();
const normal = await streamWidth(page);

const res = await page.request.put(`${server.url}/api/width`, { data: { id: "wide" } });
expect(res.ok()).toBe(true);
await page.reload();
await expect(page.locator(".card:not(#whatsNew)")).toBeVisible();
await expect(page.locator("#widthToggle")).toBeHidden();
expect(await streamWidth(page)).toBe(normal);
});

test("wide mode soft-wraps lines longer than the column instead of scrolling", async ({
page,
server,
}) => {
const huge = `${"word ".repeat(400)}end`; // ~2000 chars, wider than any column
await publishParts(server.url, {
title: "Wrap",
agent: "e2e",
parts: [
{ kind: "markdown", markdown: `\`\`\`text\n${huge}\n\`\`\`` },
{ kind: "code", code: `const s = "${huge}";`, language: "ts" },
{ kind: "terminal", text: huge },
{ kind: "diff", patch: `--- a/x\n+++ b/x\n@@ -1 +1 @@\n-${huge}\n+${huge}!` },
],
});
await page.goto(server.url);
const frames = SOFT_WRAP_KINDS.map((kind) => SURFACE_FRAME_CLASSES[kind]!);

// normal: the long lines scroll sideways, as before
for (const f of frames) await expect.poll(() => frameOverflow(page, f)).toBeGreaterThan(1);

await page.locator("#widthToggle").click();
await expect.poll(() => streamWidth(page)).toBe(1600);
for (const f of frames) await expect.poll(() => frameOverflow(page, f)).toBeLessThanOrEqual(1);
});
34 changes: 29 additions & 5 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ import {
type CodeSurface,
type Comment,
type CommentAnchor,
DEFAULT_WIDTH,
type DiffSurface,
htmlSurface,
isSandboxedSurfaceKind,
isSoftWrapSurfaceKind,
reservedAgent,
LAYOUT_WIDTHS,
type MarkdownSurface,
MAX_ASSET_BYTES,
surfacesByteLength,
Expand Down Expand Up @@ -264,6 +267,7 @@ function isPublicReadAllowed(path: string, mode: PublicReadMode): boolean {
if (path === "/api/comments") return true;
if (path === "/api/events") return true;
if (path === "/api/theme") return true;
if (path === "/api/width") return true;
if (path === "/api/version") return true;
if (path === "/api/kits") return true;
return false;
Expand Down Expand Up @@ -1038,6 +1042,22 @@ export function createApp({
return c.json({ id });
});

// --- column width (one workspace-level setting, same shape as theme) ---

app.get("/api/width", async (c) => {
const id = (await store.getSetting("width")) ?? DEFAULT_WIDTH;
return c.json({ id });
});

app.put("/api/width", async (c) => {
const body = await c.req.json().catch(() => null);
const id = body?.id;
if (!LAYOUT_WIDTHS.includes(id)) return c.json({ error: "unknown width id" }, 400);
await store.setSetting("width", id);
bus.broadcast({ type: "width-changed", id });
return c.json({ id });
});

// --- sessions ---

app.get("/api/sessions", async (c) => {
Expand Down Expand Up @@ -1612,12 +1632,15 @@ export function createApp({
const modeParam = c.req.query("mode");
const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
const origin = new URL(c.req.url).origin;
// Wrap: the viewer's wide mode asks text surfaces to soft-wrap long lines.
// Ignored for other kinds so a stray ?wrap=1 can't duplicate a cache entry.
const wrap = c.req.query("wrap") === "1" && isSoftWrapSurfaceKind(surface.kind);

// Cache the finished document. The key pins everything the output depends
// on; the resolved `version` makes it immutable, so a hit is always correct.
// Versioned + themed requests (what the viewer always sends) are immutable,
// so allow long-lived shared caching; an unpinned direct load is not.
const cacheKey = `${post.id}:${idx}:${version}:${themeId}:${mode ?? "os"}`;
const cacheKey = `${post.id}:${idx}:${version}:${themeId}:${mode ?? "os"}${wrap ? ":wrap" : ""}`;
const immutable = c.req.query("ver") != null && c.req.query("theme") != null;
if (immutable) c.header("Cache-Control", "public, max-age=31536000, immutable");
else c.header("Cache-Control", "private, no-cache");
Expand Down Expand Up @@ -1651,14 +1674,15 @@ export function createApp({
// way this optimization could break in production and nowhere else.
const { renderCode, renderDiff, renderMarkdown, renderTerminal } =
await import("./richRender.ts");
const opts = { theme: themeId, mode, wrap };
const rendered =
surface.kind === "markdown"
? await renderMarkdown(surface as MarkdownSurface, { theme: themeId, mode })
? await renderMarkdown(surface as MarkdownSurface, opts)
: surface.kind === "code"
? await renderCode(surface as CodeSurface, { theme: themeId, mode })
? await renderCode(surface as CodeSurface, opts)
: surface.kind === "terminal"
? renderTerminal(surface as TerminalSurface)
: await renderDiff(surface as DiffSurface, { theme: themeId, mode }).catch((e) => ({
? renderTerminal(surface as TerminalSurface, opts)
: await renderDiff(surface as DiffSurface, opts).catch((e) => ({
body: `<div class="rich-error">Couldn’t render diff — ${escapeHtml(
e instanceof Error ? e.message : "render error",
)}</div>`,
Expand Down
4 changes: 4 additions & 0 deletions server/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LayoutWidth } from "./types.ts";

export type FeedEvent =
| { type: "session-created" | "session-updated" | "session-deleted"; id: string }
| { type: "post-created" | "post-updated"; id: string; sessionId: string; version: number }
Expand All @@ -12,6 +14,8 @@ export type FeedEvent =
| { type: "comment-deleted"; id: string; sessionId: string }
// Workspace theme changed; `id` is the new theme id. Other open tabs re-theme.
| { type: "theme-changed"; id: string }
// Workspace column width changed; `id` is the new width. Other tabs re-layout.
| { type: "width-changed"; id: LayoutWidth }
// Session-scoped agent trace gained steps (synced in a batch). Carries only
// the new total so the viewer refetches once per batch, not once per step.
| { type: "trace-updated"; sessionId: string; count: number };
Expand Down
29 changes: 24 additions & 5 deletions server/richRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import { type Mode, THEMES, themeById } from "./themes.ts";
import type { CodeSurface, DiffSurface, MarkdownSurface, TerminalSurface } from "./types.ts";

export type RenderedSurface = { body: string; css: string };
export type RenderOpts = { theme?: string; mode?: Mode };
// `wrap` (the viewer's wide mode) soft-wraps long lines instead of scrolling
// sideways; each renderer adds its own wrap rules only when it is set.
export type RenderOpts = { theme?: string; mode?: Mode; wrap?: boolean };
const SOFT_WRAP = "white-space: pre-wrap; overflow-wrap: anywhere;";

// ---------------------------------------------------------------------------
// shiki: one shared highlighter on the JS regex engine (no oniguruma WASM —
Expand Down Expand Up @@ -117,6 +120,9 @@ function shikiThemeOptions(theme: string | undefined, mode: Mode | undefined): S
// Markdown
// ---------------------------------------------------------------------------

// Prose is capped just above its width in the normal 860px column, so normal
// mode is unchanged while a wide frame keeps a readable measure; code blocks
// and tables are not capped and use the full width.
const MD_CSS = `
body {
margin: 0;
Expand Down Expand Up @@ -162,6 +168,7 @@ th, td { border: 0.5px solid var(--border); padding: 4px 8px; text-align: left;
th { background: var(--hover); }
img { max-width: 100%; height: auto; border-radius: 6px; }
hr { border: none; border-top: 0.5px solid var(--border); margin: 1em 0; }
p, h1, h2, h3, h4, h5, h6, ul, ol, dl, blockquote { max-width: 780px; }
`;

// The languages named on fenced code blocks (```ts, ~~~python).
Expand Down Expand Up @@ -196,7 +203,8 @@ export async function renderMarkdown(
return renderLinkOpen(tokens, idx, options, env, self);
};

return { body: md.render(src), css: MD_CSS + shikiSchemeCss(opts.mode) };
const wrap = opts.wrap ? `pre { ${SOFT_WRAP} }` : "";
return { body: md.render(src), css: MD_CSS + wrap + shikiSchemeCss(opts.mode) };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -235,7 +243,7 @@ function resolveCarriageReturns(text: string): string {
.join("\n");
}

export function renderTerminal(part: TerminalSurface): RenderedSurface {
export function renderTerminal(part: TerminalSurface, opts: RenderOpts = {}): RenderedSurface {
const au = new AnsiUp();
au.use_classes = false;
const ansi = au.ansi_to_html(resolveCarriageReturns(part.text ?? ""));
Expand All @@ -246,7 +254,8 @@ export function renderTerminal(part: TerminalSurface): RenderedSurface {
`<span></span><span></span><span></span></span>` +
`<span class="term-title">${title}</span></div>` +
`<pre class="term-body"${width}>${ansi}</pre>`;
return { body, css: TERM_CSS };
const wrap = opts.wrap ? `.term-body { ${SOFT_WRAP} }` : "";
return { body, css: TERM_CSS + wrap };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -300,6 +309,15 @@ pre.shiki code, pre.plain code { background: none; padding: 0; }
pre.plain { color: var(--text); }
`;

// Wrapped lines hang under the code, not the line number: the indent pulls the
// number back to the gutter the padding reserves.
const CODE_WRAP_CSS = `
.line {
${SOFT_WRAP}
padding-left: calc(2.5em + 12px); text-indent: calc(-2.5em - 12px);
}
`;

function plainHtml(code: string): string {
const lines = code.split("\n");
return `<pre class="plain"><code>${lines
Expand Down Expand Up @@ -346,7 +364,7 @@ export async function renderCode(
const head = hasHead ? `<div class="code-head">${filename}${langBadge}${copyBtn}</div>` : copyBtn;
const codeJs = JSON.stringify(code).replace(/</g, "\\u003c");
const body = `<div class="${wrapClass}">${head}${preWithStart}<script>(function(){var c=${codeJs};window.__codeCopy=function(b){copyToClipboard(c);b.textContent="Copied!";b.classList.add("copied");setTimeout(function(){b.textContent="Copy";b.classList.remove("copied")},1500)}})();</script></div>`;
return { body, css: CODE_CSS + shikiSchemeCss(opts.mode) };
return { body, css: CODE_CSS + (opts.wrap ? CODE_WRAP_CSS : "") + shikiSchemeCss(opts.mode) };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -408,6 +426,7 @@ export async function renderDiff(
theme: { dark: shiki.dark, light: shiki.light },
themeType: opts.mode ?? "system",
preferredHighlighter: "shiki-js",
...(opts.wrap ? { overflow: "wrap" as const } : {}),
} as const;
const rendered = await Promise.all(
diffs.map((fileDiff) => preloadFileDiff({ fileDiff, options })),
Expand Down
21 changes: 17 additions & 4 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,21 @@ export interface SurfaceKindMetadata {
sandboxed: boolean;
// Stable iframe selector hook for sandboxed kinds that need kind-specific CSS.
frameClass?: string;
// Server-rendered text kinds that soft-wrap long lines on request (?wrap=1,
// the viewer's wide mode) instead of scrolling sideways.
softWrap?: boolean;
}

export const SURFACE_KIND_METADATA = {
html: { contentField: "html", sandboxed: true },
diff: { contentField: "patch", sandboxed: true, frameClass: "diffframe" },
diff: { contentField: "patch", sandboxed: true, frameClass: "diffframe", softWrap: true },
image: { sandboxed: false },
trace: { sandboxed: false },
markdown: { contentField: "markdown", sandboxed: true, frameClass: "mdframe" },
terminal: { contentField: "text", sandboxed: true, frameClass: "termframe" },
markdown: { contentField: "markdown", sandboxed: true, frameClass: "mdframe", softWrap: true },
terminal: { contentField: "text", sandboxed: true, frameClass: "termframe", softWrap: true },
mermaid: { contentField: "mermaid", sandboxed: true, frameClass: "mermaidframe" },
json: { contentField: "data", sandboxed: false },
code: { contentField: "code", sandboxed: true, frameClass: "codeframe" },
code: { contentField: "code", sandboxed: true, frameClass: "codeframe", softWrap: true },
} as const satisfies Record<SurfaceKind, SurfaceKindMetadata>;

export const SURFACE_KIND_LIST = SURFACE_KINDS.join(", ");
Expand Down Expand Up @@ -96,10 +99,20 @@ export function isSurfaceKind(kind: unknown): kind is SurfaceKind {
return typeof kind === "string" && Object.hasOwn(SURFACE_KIND_METADATA, kind);
}

export function isSoftWrapSurfaceKind(kind: unknown): kind is SurfaceKind {
return isSurfaceKind(kind) && "softWrap" in SURFACE_KIND_METADATA[kind];
}

export function isSandboxedSurfaceKind(kind: unknown): kind is SurfaceKind {
return isSurfaceKind(kind) && SURFACE_KIND_METADATA[kind].sandboxed;
}

// Workspace column width (Store setting "width"): "normal" is the classic 860px
// feed column, "wide" lets it grow with the window (viewer styles.css).
export const LAYOUT_WIDTHS = ["normal", "wide"] as const;
export type LayoutWidth = (typeof LAYOUT_WIDTHS)[number];
export const DEFAULT_WIDTH: LayoutWidth = "normal";

export interface HtmlSurface {
kind: "html";
html: string;
Expand Down
Loading