From 5c6bef0c66bc4f203490a6d47405d68e4880d16d Mon Sep 17 00:00:00 2001 From: banozz <263121691+banozz0@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:43:58 +0200 Subject: [PATCH 1/4] feat(viewer): opt-in wide layout for wide monitors (#223) The feed column is capped at 860px, so on a wide window most of the screen is empty and diffs scroll sideways. Add a per-workspace width setting, persisted and broadcast like the theme: - GET/PUT /api/width ({"id":"normal"|"wide"}), stored via Store.setSetting("width") (no schema change), width-changed SSE so other tabs follow, public-read GET like /api/theme. - A toggle beside Stream/Timeline (hidden on phones) puts a `wide` class on the engine root; #stream and .standalone-main grow to 1600px. Mobile rules and normal mode are untouched. - Markdown keeps a readable ~80ch measure via right padding on its iframe, so no server render change, cache key or frame reload. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/wide-columns-stretch.md | 5 ++ e2e/width.spec.ts | 96 +++++++++++++++++++++++++++ server/app.ts | 19 ++++++ server/events.ts | 4 ++ server/types.ts | 6 ++ test/api.test.ts | 22 ++++++ test/workerIntegration.integration.ts | 6 ++ viewer/src/App.tsx | 29 +++++++- viewer/src/icons.tsx | 11 +++ viewer/src/state.ts | 3 + viewer/src/styles.css | 36 +++++++++- viewer/src/width.ts | 23 +++++++ 12 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 .changeset/wide-columns-stretch.md create mode 100644 e2e/width.spec.ts create mode 100644 viewer/src/width.ts diff --git a/.changeset/wide-columns-stretch.md b/.changeset/wide-columns-stretch.md new file mode 100644 index 00000000..edbf0d52 --- /dev/null +++ b/.changeset/wide-columns-stretch.md @@ -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 and html use the space, while markdown prose keeps a readable ~80-character measure. The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default. diff --git a/e2e/width.spec.ts b/e2e/width.spec.ts new file mode 100644 index 00000000..94b015e7 --- /dev/null +++ b/e2e/width.spec.ts @@ -0,0 +1,96 @@ +import { expect, publishParts, test } from "./fixtures.ts"; +import type { Page } from "@playwright/test"; + +// 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." }, + { 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); +const markdownViewport = (page: Page) => + page + .frameLocator(".card:not(#whatsNew) iframe.mdframe") + .locator("body") + .evaluate(() => innerWidth); +// Widest sideways scroll inside the diff frame (@pierre/diffs renders in shadow roots). +const diffOverflow = (page: Page) => + page + .frameLocator(".card:not(#whatsNew) iframe.diffframe") + .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; + }); + +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 markdownViewport(page); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => streamWidth(page)).toBe(1600); + // the diff now fits; markdown keeps its readable measure (no wider than normal) + await expect.poll(() => diffOverflow(page)).toBeLessThanOrEqual(1); + await expect.poll(() => markdownViewport(page)).toBeLessThanOrEqual(normalMarkdown); + + // 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(() => markdownViewport(page)).toBe(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); +}); diff --git a/server/app.ts b/server/app.ts index 23478b4c..047ddc1e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -31,10 +31,12 @@ import { type CodeSurface, type Comment, type CommentAnchor, + DEFAULT_WIDTH, type DiffSurface, htmlSurface, isSandboxedSurfaceKind, reservedAgent, + LAYOUT_WIDTHS, type MarkdownSurface, MAX_ASSET_BYTES, surfacesByteLength, @@ -264,6 +266,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; @@ -1038,6 +1041,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) => { diff --git a/server/events.ts b/server/events.ts index 9e85008b..c493c0e6 100644 --- a/server/events.ts +++ b/server/events.ts @@ -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 } @@ -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 }; diff --git a/server/types.ts b/server/types.ts index 5ef3ceba..977b7ba7 100644 --- a/server/types.ts +++ b/server/types.ts @@ -100,6 +100,12 @@ 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; diff --git a/test/api.test.ts b/test/api.test.ts index 1cefc4da..bb5e3e26 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -498,6 +498,22 @@ test("post preview image URL changes with the workspace theme", async () => { assert.doesNotMatch(after, /theme=github/); }); +test("workspace width defaults to normal, persists, and broadcasts", async () => { + const events: unknown[] = []; + const app = makeApp(undefined, { onEvent: (e) => events.push(e) }); + + assert.deepEqual(await (await app.request("/api/width")).json(), { id: "normal" }); + const put = await app.request("/api/width", { ...json({ id: "wide" }), method: "PUT" }); + assert.equal(put.status, 200); + assert.deepEqual(await put.json(), { id: "wide" }); + assert.deepEqual(await (await app.request("/api/width")).json(), { id: "wide" }); + assert.deepEqual(events, [{ type: "width-changed", id: "wide" }]); + + const bad = await app.request("/api/width", { ...json({ id: "huge" }), method: "PUT" }); + assert.equal(bad.status, 400); + assert.deepEqual(await (await app.request("/api/width")).json(), { id: "wide" }); +}); + test("/s served versioned + themed is cacheable; an unpinned load is not", async () => { const app = makeApp(); const res = await app.request( @@ -1335,6 +1351,7 @@ test("public read full mode allows unauthenticated GETs but not writes", async ( assert.equal((await app.request("/session/anything")).status, 200); assert.equal((await app.request("/api/sessions")).status, 200); assert.equal((await app.request("/api/theme")).status, 200); + assert.equal((await app.request("/api/width")).status, 200); assert.equal((await app.request("/api/version")).status, 200); const created = (await ( @@ -1345,6 +1362,10 @@ test("public read full mode allows unauthenticated GETs but not writes", async ( assert.equal((await app.request("/api/snippets", json({ html: "

x

" }))).status, 401); assert.equal((await app.request("/api/comments", json({ text: "hi" }))).status, 401); + assert.equal( + (await app.request("/api/width", { ...json({ id: "wide" }), method: "PUT" })).status, + 401, + ); }); test("public read session mode allows scoped reads and denies root/session list", async () => { @@ -1353,6 +1374,7 @@ test("public read session mode allows scoped reads and denies root/session list" assert.equal((await app.request("/")).status, 401); assert.equal((await app.request("/api/sessions")).status, 401); assert.equal((await app.request("/api/theme")).status, 200); + assert.equal((await app.request("/api/width")).status, 200); assert.equal((await app.request("/api/version")).status, 200); const created = (await ( diff --git a/test/workerIntegration.integration.ts b/test/workerIntegration.integration.ts index 2766f239..d1efcd58 100644 --- a/test/workerIntegration.integration.ts +++ b/test/workerIntegration.integration.ts @@ -260,6 +260,7 @@ test( assert.deepEqual(new Uint8Array(await servedAsset.arrayBuffer()), bytes); await expectJson(await worker.fetch("/api/theme", json({ id: "gruvbox" }, "PUT")), 200); + await expectJson(await worker.fetch("/api/width", json({ id: "wide" }, "PUT")), 200); const pendingFeedback = worker.fetch( `/api/comments?session=${post.sessionId}&author=user&wait=2`, @@ -362,6 +363,11 @@ test( 200, ); assert.equal(persistedTheme.id, "gruvbox"); + const persistedWidth = await expectJson<{ id: string }>( + await worker.fetch("/api/width", { headers: AUTH }), + 200, + ); + assert.equal(persistedWidth.id, "wide"); const persistedAsset = await worker.fetch(`/a/${asset.id}`, { headers: AUTH }); assert.equal(persistedAsset.status, 200); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index f20f7df2..3e22c1b8 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -26,6 +26,7 @@ import { PlugIcon, SunIcon, SystemIcon, + WidthIcon, } from "./icons.tsx"; import { activeTheme, @@ -36,6 +37,7 @@ import { setTheme, themeOptions, } from "./theme.ts"; +import { initWidth, isWide, setWidth } from "./width.ts"; import { applyRoute, bootstrap, @@ -167,6 +169,7 @@ export default function App() { }); checkVersion(); void initTheme(); + void initWidth(); const timer = setInterval(() => { if (sessions.length > 0) refreshSessionsQuiet(); }, 45_000); @@ -241,7 +244,7 @@ export default function App() { keyed fallback={ <> -
+
+
+ ); +} + function SessionTitle(props: { current: SessionRow | undefined }) { let el!: HTMLSpanElement; // contenteditable owns its text while focused; sync from state otherwise diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx index 1e51e1f3..3b9949a7 100644 --- a/viewer/src/icons.tsx +++ b/viewer/src/icons.tsx @@ -105,6 +105,17 @@ export function MaximizeIcon() { ); } +// lucide: move-horizontal — wide column layout. +export function WidthIcon() { + return ( + + + + + + ); +} + // lucide: panel-left-close — collapse the desktop session sidebar. export function PanelLeftCloseIcon() { return ( diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 8cac4431..550f5981 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -16,6 +16,7 @@ import { } from "./api.ts"; import { host, root, type Route } from "./host.ts"; import { applyTheme } from "./theme.ts"; +import { applyWidth } from "./width.ts"; import { compactViewerPost, viewerPostFromDetail } from "./viewerPost.ts"; // --- URL routing --- @@ -608,6 +609,8 @@ async function handleFeedData(data: string) { const away = e.sessionId != null && (e.sessionId !== selected() || document.hidden); if (e.type === "theme-changed") { applyTheme(e.id); + } else if (e.type === "width-changed") { + applyWidth(e.id); } else if (e.type.startsWith("session-")) { scheduleHomeRefresh(); await refreshSessions(); diff --git a/viewer/src/styles.css b/viewer/src/styles.css index 17358be8..e73f6b35 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -544,6 +544,18 @@ main { .standalone-main .card { margin-bottom: 0; } +/* Wide mode (width.ts): the column grows with the window so diffs and wide html + stop scrolling sideways. Only max-width changes, so phone rules still apply. + Markdown keeps a readable ~80ch measure: right padding shrinks the frame's + viewport, while its full-width border-top still separates the surfaces. */ +.wide #stream, +.wide .standalone-main { + max-width: 1600px; +} +.wide #stream iframe.mdframe, +.wide .standalone-main iframe.mdframe { + padding-right: max(0px, calc(100% - 740px)); +} .standalone-foot { margin-top: 18px; text-align: center; @@ -2520,14 +2532,16 @@ iframe { .session-head .head-sp { flex: 1; } -.view-toggle { +.view-toggle, +.width-toggle { display: inline-flex; align-self: center; border: 0.5px solid var(--border); border-radius: 999px; overflow: hidden; } -.view-toggle button { +.view-toggle button, +.width-toggle button { font-family: inherit; font-size: 12px; color: var(--muted); @@ -2536,10 +2550,26 @@ iframe { padding: 4px 13px; cursor: pointer; } -.view-toggle button.on { +.view-toggle button.on, +.width-toggle button.on { background: var(--accent-bg); color: var(--accent); } +.width-toggle button { + display: block; + padding: 4px 10px; +} +.width-toggle svg { + width: 14px; + height: 14px; + display: block; +} +/* phone: the column is already narrower than normal, so wide can't apply */ +@media (max-width: 700px) { + .width-toggle { + display: none; + } +} @media (prefers-reduced-motion: reduce) { .card-head .update-dot { animation: none; diff --git a/viewer/src/width.ts b/viewer/src/width.ts new file mode 100644 index 00000000..e631ab60 --- /dev/null +++ b/viewer/src/width.ts @@ -0,0 +1,23 @@ +// Workspace column width, persisted server-side (PUT /api/width) like the theme; +// other open tabs follow via the width-changed SSE event (state.ts). +import { createSignal } from "solid-js"; +import { api } from "./api.ts"; +import type { LayoutWidth } from "../../server/types.ts"; + +const [wide, setWide] = createSignal(false); +export const isWide = wide; + +// Apply locally, no round-trip (initial load + SSE). Unknown values mean normal. +export function applyWidth(value: string | undefined) { + setWide(value === "wide"); +} + +export async function initWidth() { + const res = await api<{ id: string }>("/api/width").catch(() => null); + applyWidth(res?.id); +} + +export async function setWidth(value: LayoutWidth) { + applyWidth(value); + await api("/api/width", { method: "PUT", body: JSON.stringify({ id: value }) }).catch(() => null); +} From d30b0f3a4473279a5f081da779cfc85b5fb6ae64 Mon Sep 17 00:00:00 2001 From: banozz <263121691+banozz0@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:06:46 +0200 Subject: [PATCH 2/4] fix(render): cap markdown prose, not code blocks, in wide mode Padding the markdown iframe capped the whole document at 740px, so fenced code blocks and tables still scrolled sideways inside a half-empty wide card. Cap prose elements in MD_CSS at just above their normal-column width instead: normal mode is unchanged, wide frames keep a readable measure, and pre/table use the full width. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VZX7jtqqerDjNe5Q813hbK --- e2e/width.spec.ts | 24 +++++++++++++++++------- server/richRender.ts | 4 ++++ viewer/src/styles.css | 7 +------ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/e2e/width.spec.ts b/e2e/width.spec.ts index 94b015e7..dc5a5279 100644 --- a/e2e/width.spec.ts +++ b/e2e/width.spec.ts @@ -5,17 +5,24 @@ import type { Page } from "@playwright/test"; // 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." }, + { + 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); -const markdownViewport = (page: Page) => +// 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(() => innerWidth); + .evaluate(() => ({ + prose: document.querySelector("p")!.getBoundingClientRect().width, + code: document.querySelector("pre")!.getBoundingClientRect().width, + })); // Widest sideways scroll inside the diff frame (@pierre/diffs renders in shadow roots). const diffOverflow = (page: Page) => page @@ -49,14 +56,17 @@ test("the wide toggle widens the column, persists, and toggles back to normal", await expect(toggle).toHaveAttribute("aria-pressed", "false"); expect(await streamWidth(page)).toBe(860); await expect.poll(() => diffOverflow(page)).toBeGreaterThan(1); - const normalMarkdown = await markdownViewport(page); + 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 keeps its readable measure (no wider than normal) + // 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(() => markdownViewport(page)).toBeLessThanOrEqual(normalMarkdown); + 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(); @@ -65,7 +75,7 @@ test("the wide toggle widens the column, persists, and toggles back to normal", await toggle.click(); await expect.poll(() => streamWidth(page)).toBe(860); - await expect.poll(() => markdownViewport(page)).toBe(normalMarkdown); + await expect.poll(() => markdownWidths(page)).toEqual(normalMarkdown); }); test("another open tab follows a width switch live", async ({ page, context, server }) => { diff --git a/server/richRender.ts b/server/richRender.ts index 4780e711..82e90c28 100644 --- a/server/richRender.ts +++ b/server/richRender.ts @@ -117,6 +117,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; @@ -162,6 +165,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). diff --git a/viewer/src/styles.css b/viewer/src/styles.css index e73f6b35..abc92122 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -546,16 +546,11 @@ main { } /* Wide mode (width.ts): the column grows with the window so diffs and wide html stop scrolling sideways. Only max-width changes, so phone rules still apply. - Markdown keeps a readable ~80ch measure: right padding shrinks the frame's - viewport, while its full-width border-top still separates the surfaces. */ + Markdown prose keeps a readable measure inside its own frame (richRender.ts). */ .wide #stream, .wide .standalone-main { max-width: 1600px; } -.wide #stream iframe.mdframe, -.wide .standalone-main iframe.mdframe { - padding-right: max(0px, calc(100% - 740px)); -} .standalone-foot { margin-top: 18px; text-align: center; From a07d04fba7de4270211eec21f2f3e18f450079ac Mon Sep 17 00:00:00 2001 From: banozz <263121691+banozz0@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:08:28 +0200 Subject: [PATCH 3/4] docs(changeset): describe the markdown prose cap accurately Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VZX7jtqqerDjNe5Q813hbK --- .changeset/wide-columns-stretch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wide-columns-stretch.md b/.changeset/wide-columns-stretch.md index edbf0d52..ca905935 100644 --- a/.changeset/wide-columns-stretch.md +++ b/.changeset/wide-columns-stretch.md @@ -2,4 +2,4 @@ "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 and html use the space, while markdown prose keeps a readable ~80-character measure. The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default. +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). The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default. From 66cc73d000998f6d999161d6f0a548d0ef402895 Mon Sep 17 00:00:00 2001 From: banozz <263121691+banozz0@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:26:06 +0200 Subject: [PATCH 4/4] feat(render): soft-wrap long lines in wide mode Even a 1600px column can't fit a 4000px line, so text surfaces still scrolled sideways. In wide mode the viewer now asks the server-rendered text kinds (markdown, code, terminal, diff; `softWrap` in the surface metadata) for `?wrap=1`: pre-wrap for markdown fences, code and terminal, with code lines hanging under the code rather than the line number, and @pierre/diffs' native `overflow: "wrap"` for diffs. The flag is part of the render cache key and ignored for other kinds. Normal mode output is byte-identical. The frame URL is now built in one place (surfaceSrc). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VZX7jtqqerDjNe5Q813hbK --- .changeset/wide-columns-stretch.md | 2 +- e2e/width.spec.ts | 40 +++++++++++++++++++++++++++--- server/app.ts | 15 +++++++---- server/richRender.ts | 25 +++++++++++++++---- server/types.ts | 15 ++++++++--- test/richRender.test.ts | 20 +++++++++++++++ viewer/src/Card.tsx | 27 ++++++++++++++------ 7 files changed, 117 insertions(+), 27 deletions(-) diff --git a/.changeset/wide-columns-stretch.md b/.changeset/wide-columns-stretch.md index ca905935..786ba1b8 100644 --- a/.changeset/wide-columns-stretch.md +++ b/.changeset/wide-columns-stretch.md @@ -2,4 +2,4 @@ "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). The choice is saved per workspace (`PUT /api/width` with `{"id":"wide"}`), and normal stays the default. +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. diff --git a/e2e/width.spec.ts b/e2e/width.spec.ts index dc5a5279..038790c3 100644 --- a/e2e/width.spec.ts +++ b/e2e/width.spec.ts @@ -1,5 +1,8 @@ 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. @@ -23,10 +26,10 @@ const markdownWidths = (page: Page) => prose: document.querySelector("p")!.getBoundingClientRect().width, code: document.querySelector("pre")!.getBoundingClientRect().width, })); -// Widest sideways scroll inside the diff frame (@pierre/diffs renders in shadow roots). -const diffOverflow = (page: Page) => +// 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.diffframe") + .frameLocator(`.card:not(#whatsNew) iframe.${frame}`) .locator("body") .evaluate(() => { let max = 0; @@ -40,7 +43,10 @@ const diffOverflow = (page: Page) => }; 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 } }); @@ -104,3 +110,29 @@ test("phone layout is unchanged in wide mode and hides the toggle", async ({ pag 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); +}); diff --git a/server/app.ts b/server/app.ts index 047ddc1e..fa85ab45 100644 --- a/server/app.ts +++ b/server/app.ts @@ -35,6 +35,7 @@ import { type DiffSurface, htmlSurface, isSandboxedSurfaceKind, + isSoftWrapSurfaceKind, reservedAgent, LAYOUT_WIDTHS, type MarkdownSurface, @@ -1631,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"); @@ -1670,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: `
Couldn’t render diff — ${escapeHtml( e instanceof Error ? e.message : "render error", )}
`, diff --git a/server/richRender.ts b/server/richRender.ts index 82e90c28..692e0688 100644 --- a/server/richRender.ts +++ b/server/richRender.ts @@ -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 — @@ -200,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) }; } // --------------------------------------------------------------------------- @@ -239,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 ?? "")); @@ -250,7 +254,8 @@ export function renderTerminal(part: TerminalSurface): RenderedSurface { `` + `${title}
` + `
${ansi}
`; - return { body, css: TERM_CSS }; + const wrap = opts.wrap ? `.term-body { ${SOFT_WRAP} }` : ""; + return { body, css: TERM_CSS + wrap }; } // --------------------------------------------------------------------------- @@ -304,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 `
${lines
@@ -350,7 +364,7 @@ export async function renderCode(
   const head = hasHead ? `
${filename}${langBadge}${copyBtn}
` : copyBtn; const codeJs = JSON.stringify(code).replace(/${head}${preWithStart}`; - return { body, css: CODE_CSS + shikiSchemeCss(opts.mode) }; + return { body, css: CODE_CSS + (opts.wrap ? CODE_WRAP_CSS : "") + shikiSchemeCss(opts.mode) }; } // --------------------------------------------------------------------------- @@ -412,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 })), diff --git a/server/types.ts b/server/types.ts index 977b7ba7..6f34812b 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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; export const SURFACE_KIND_LIST = SURFACE_KINDS.join(", "); @@ -96,6 +99,10 @@ 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; } diff --git a/test/richRender.test.ts b/test/richRender.test.ts index 749a5850..e51dbfa1 100644 --- a/test/richRender.test.ts +++ b/test/richRender.test.ts @@ -157,3 +157,23 @@ test("renderTerminal: a title-less terminal defaults the bar title to 'terminal' assert.match(body, /terminal<\/span>/); assert.match(body, /
plain output<\/pre>/);
 });
+
+test("wrap: off leaves every renderer's output unchanged; on adds soft-wrap rules", async () => {
+  const md: MarkdownSurface = { kind: "markdown", markdown: "```text\nlong line\n```" };
+  const code: CodeSurface = { kind: "code", code: "const x = 1;", language: "ts" };
+  const term: TerminalSurface = { kind: "terminal", text: "output" };
+  const diff: DiffSurface = {
+    kind: "diff",
+    files: [{ filename: "f.ts", before: "const x = 1", after: "const x = 2" }],
+  };
+  assert.deepEqual(await renderMarkdown(md, { wrap: false }), await renderMarkdown(md));
+  assert.deepEqual(await renderCode(code, { wrap: false }), await renderCode(code));
+  assert.deepEqual(renderTerminal(term, { wrap: false }), renderTerminal(term));
+  assert.deepEqual(await renderDiff(diff, { wrap: false }), await renderDiff(diff));
+
+  assert.match((await renderMarkdown(md, { wrap: true })).css, /pre \{ white-space: pre-wrap;/);
+  assert.match((await renderCode(code, { wrap: true })).css, /\.line \{\s*white-space: pre-wrap;/);
+  assert.match(renderTerminal(term, { wrap: true }).css, /\.term-body \{ white-space: pre-wrap;/);
+  assert.match((await renderDiff(diff, { wrap: true })).body, /data-overflow="wrap"/);
+  assert.doesNotMatch((await renderDiff(diff)).body, /data-overflow="wrap"/);
+});
diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx
index 43de4ced..beb82072 100644
--- a/viewer/src/Card.tsx
+++ b/viewer/src/Card.tsx
@@ -22,13 +22,18 @@ import {
   type TraceSurface as TraceSurfaceData,
   type ViewerPost,
 } from "./api.ts";
-import { isSandboxedSurfaceKind, SURFACE_FRAME_CLASSES } from "../../server/types.ts";
+import {
+  isSandboxedSurfaceKind,
+  isSoftWrapSurfaceKind,
+  SURFACE_FRAME_CLASSES,
+} from "../../server/types.ts";
 import { CommentIcon, MaximizeIcon, PinIcon, TrashIcon } from "./icons.tsx";
 import { ShareMenu } from "./ShareMenu.tsx";
 import { root } from "./host.ts";
 import { ImageSurface } from "./ImageSurface.tsx";
 import { JsonSurface } from "./JsonSurface.tsx";
 import { activeTheme, resolvedMode } from "./theme.ts";
+import { isWide } from "./width.ts";
 import { TraceSurface } from "./TraceSurface.tsx";
 import {
   comments,
@@ -209,10 +214,19 @@ export function Card(props: { post: Post | ViewerPost; standalone?: boolean }) {
       ? `${props.post.title} (surface ${surfaceIndex + 1})`
       : props.post.title;
 
-  const surfaceSrc = (surfaceIndex: number) =>
-    appPath(
-      `/s/${props.post.id}?part=${surfaceIndex}&ver=${props.post.version}&cb=${props.post.version}&theme=${activeTheme()}&mode=${resolvedMode()}`,
+  // `?part=` is the legacy wire query key for a surface index. Wide mode asks
+  // the server-rendered text kinds to soft-wrap long lines.
+  const surfaceSrc = (
+    surfaceIndex: number,
+    ver: number | string = props.post.version,
+    cb: number | string = ver,
+  ) => {
+    const wrap =
+      isWide() && isSoftWrapSurfaceKind(props.post.surfaces[surfaceIndex]?.kind) ? "&wrap=1" : "";
+    return appPath(
+      `/s/${props.post.id}?part=${surfaceIndex}&ver=${ver}&cb=${cb}&theme=${activeTheme()}&mode=${resolvedMode()}${wrap}`,
     );
+  };
 
   const anchoredComments = (surfaceIndex: number) =>
     comments().filter((c) => c.postId === props.post.id && c.anchor?.surfaceIndex === surfaceIndex);
@@ -383,10 +397,7 @@ export function Card(props: { post: Post | ViewerPost; standalone?: boolean }) {
                     const ver = e.currentTarget.value;
                     const cb = Date.now();
                     for (const [surface, frame] of surfaceFrames) {
-                      // `?part=` is the legacy wire query key for a surface index.
-                      frame.src = appPath(
-                        `/s/${props.post.id}?part=${surface}&ver=${ver}&cb=${cb}&theme=${activeTheme()}&mode=${resolvedMode()}`,
-                      );
+                      frame.src = surfaceSrc(surface, ver, cb);
                     }
                   }}
                 >