diff --git a/.changeset/wide-columns-stretch.md b/.changeset/wide-columns-stretch.md new file mode 100644 index 0000000..786ba1b --- /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, 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 new file mode 100644 index 0000000..038790c --- /dev/null +++ b/e2e/width.spec.ts @@ -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); +}); diff --git a/server/app.ts b/server/app.ts index 23478b4..fa85ab4 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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, @@ -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; @@ -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) => { @@ -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"); @@ -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: `
Couldn’t render diff — ${escapeHtml( e instanceof Error ? e.message : "render error", )}
`, diff --git a/server/events.ts b/server/events.ts index 9e85008..c493c0e 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/richRender.ts b/server/richRender.ts index 4780e71..692e068 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 — @@ -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; @@ -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). @@ -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) }; } // --------------------------------------------------------------------------- @@ -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 ?? "")); @@ -246,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 }; } // --------------------------------------------------------------------------- @@ -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 `
${lines
@@ -346,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) }; } // --------------------------------------------------------------------------- @@ -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 })), diff --git a/server/types.ts b/server/types.ts index 5ef3ceb..6f34812 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,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; diff --git a/test/api.test.ts b/test/api.test.ts index 1cefc4d..bb5e3e2 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/richRender.test.ts b/test/richRender.test.ts index 749a585..e51dbfa 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/test/workerIntegration.integration.ts b/test/workerIntegration.integration.ts
index 2766f23..d1efcd5 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 f20f7df..3e22c1b 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/Card.tsx b/viewer/src/Card.tsx index 43de4ce..beb8207 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); } }} > diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx index 1e51e1f..3b9949a 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 8cac443..550f598 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 17358be..abc9212 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -544,6 +544,13 @@ 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 prose keeps a readable measure inside its own frame (richRender.ts). */ +.wide #stream, +.wide .standalone-main { + max-width: 1600px; +} .standalone-foot { margin-top: 18px; text-align: center; @@ -2520,14 +2527,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 +2545,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 0000000..e631ab6 --- /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); +}