diff --git a/.changeset/mobile-viewer-ui.md b/.changeset/mobile-viewer-ui.md
new file mode 100644
index 0000000..a1d0b9d
--- /dev/null
+++ b/.changeset/mobile-viewer-ui.md
@@ -0,0 +1,5 @@
+---
+"sideshow": patch
+---
+
+Improve the mobile viewer layout for phone-sized screens, including sidebar ergonomics, native surface primitives, and timeline trace readability.
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
index 7f168ff..260cde3 100644
--- a/e2e/fixtures.ts
+++ b/e2e/fixtures.ts
@@ -1,4 +1,4 @@
-import { test as base } from "@playwright/test";
+import { expect, test as base, type Locator, type Page } from "@playwright/test";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -67,7 +67,7 @@ export const publicReadTest = base.extend<{ publicReadServer: PublicReadServer }
},
});
-export { expect } from "@playwright/test";
+export { expect };
export async function publish(
serverUrl: string,
@@ -127,3 +127,45 @@ export async function publishParts(
if (!res.ok) throw new Error(`publishParts failed: ${res.status}`);
return res.json() as Promise<{ id: string; sessionId: string; version: number }>;
}
+
+export async function expectNoHorizontalOverflow(page: Page, selector: string) {
+ await expect.poll(() => page.locator(selector).count()).toBeGreaterThan(0);
+ await expect
+ .poll(() =>
+ page
+ .locator(selector)
+ .evaluateAll((elements) =>
+ Math.max(0, ...elements.map((el) => Math.ceil(el.scrollWidth - el.clientWidth))),
+ ),
+ )
+ .toBeLessThanOrEqual(1);
+}
+
+export async function expectIframesNoHorizontalOverflow(page: Page, container: Locator) {
+ const frameUrls = await container
+ .locator("iframe")
+ .evaluateAll((frames) => frames.map((frame) => (frame as HTMLIFrameElement).src));
+ expect(frameUrls.length).toBeGreaterThan(0);
+
+ await expect
+ .poll(async () => {
+ const childFrames = frameUrls
+ .map((url) => page.frames().find((frame) => frame.url() === url))
+ .filter((frame) => frame !== undefined);
+ if (childFrames.length < frameUrls.length) return Number.POSITIVE_INFINITY;
+ const overflows = await Promise.all(
+ childFrames.map((frame) =>
+ frame.evaluate(() => {
+ if (document.readyState === "loading") return Number.POSITIVE_INFINITY;
+ const doc = document.documentElement;
+ const body = document.body;
+ const scrollWidth = Math.max(doc.scrollWidth, body?.scrollWidth ?? 0);
+ const clientWidth = Math.max(doc.clientWidth, body?.clientWidth ?? 0);
+ return Math.ceil(scrollWidth - clientWidth);
+ }),
+ ),
+ );
+ return Math.max(0, ...overflows);
+ })
+ .toBeLessThanOrEqual(1);
+}
diff --git a/e2e/uploads.spec.ts b/e2e/uploads.spec.ts
index 4c0be9f..0ea158f 100644
--- a/e2e/uploads.spec.ts
+++ b/e2e/uploads.spec.ts
@@ -1,4 +1,13 @@
-import { expect, publish, publishParts, test, TINY_PNG_B64, upload } from "./fixtures.ts";
+import {
+ expect,
+ expectIframesNoHorizontalOverflow,
+ expectNoHorizontalOverflow,
+ publish,
+ publishParts,
+ test,
+ TINY_PNG_B64,
+ upload,
+} from "./fixtures.ts";
test("an image part renders an served from /a/:id", async ({ page, server }) => {
const asset = await upload(server.url, {
@@ -42,7 +51,7 @@ test("a trace part renders a step timeline with expandable detail", async ({ pag
});
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await expect(card.locator(".trace-title")).toHaveText("What I did");
await expect(card.locator(".trace-step")).toHaveCount(2);
await expect(card.locator(".trace-kind").first()).toHaveText("tool");
@@ -72,13 +81,137 @@ test("a trace part backed by an uploaded file offers a download and renders step
});
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await expect(card.locator(".trace-dl")).toHaveAttribute("href", `/a/${asset.id}`);
// steps are fetched from the asset and rendered
await expect(card.locator(".trace-step")).toHaveCount(2);
await expect(card.locator(".trace-label").first()).toHaveText("step one");
});
+test("a trace part stays readable on an iPhone-sized viewport", async ({ page, server }) => {
+ await publishParts(server.url, {
+ title: "Trace on mobile",
+ agent: "e2e",
+ parts: [
+ {
+ kind: "trace",
+ title: "Long trace heading that should truncate cleanly on mobile",
+ steps: [
+ {
+ label:
+ "ran a very long shell command with flags --workspace=/tmp/sideshow-mobile --include-traces --verify-sidebar",
+ kind: "shell",
+ ts: "2026-06-25T12:00:00Z",
+ detail:
+ "stdout: a-long-token-that-should-wrap-instead-of-forcing-horizontal-scroll ".repeat(
+ 6,
+ ),
+ },
+ {
+ label: "noted how the sidebar drawer and trace card read together on a phone",
+ kind: "say",
+ ts: "2026-06-25T12:00:03Z",
+ },
+ ],
+ },
+ ],
+ });
+
+ await page.setViewportSize({ width: 393, height: 852 });
+ await page.goto(server.url);
+
+ const card = page.locator(".card:not(#whatsNew)");
+ await expect(card.locator(".trace-title")).toBeVisible();
+ await expect(card.locator(".trace-step")).toHaveCount(2);
+ await card.locator(".trace-row.clickable").first().click();
+ await expect(card.locator(".trace-detail")).toBeVisible();
+
+ await expectNoHorizontalOverflow(page, "main");
+ await expectNoHorizontalOverflow(page, ".card");
+ await expectNoHorizontalOverflow(page, ".tracepart");
+});
+
+test("all native surface primitives fit the iPhone 14 Pro viewer", async ({ page, server }) => {
+ const asset = await upload(server.url, {
+ data: TINY_PNG_B64,
+ contentType: "image/png",
+ filename: "mobile-primitive.png",
+ kind: "image",
+ });
+ await publishParts(server.url, {
+ title: "Every primitive on mobile",
+ agent: "e2e",
+ session: asset.sessionId,
+ parts: [
+ { kind: "html", html: "HTML Interactive surface shell.
" },
+ { kind: "markdown", markdown: "## Markdown\n\n- readable prose\n- list item" },
+ {
+ kind: "diff",
+ patch:
+ "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old mobile spacing\n+new mobile spacing\n",
+ },
+ {
+ kind: "terminal",
+ text: "\u001b[32mPASS\u001b[0m mobile primitive check\n$ sideshow trace-sync --all",
+ title: "terminal",
+ },
+ { kind: "image", assetId: asset.id, caption: "uploaded image primitive" },
+ { kind: "mermaid", mermaid: "flowchart LR\n A[Agent] --> B[sideshow]\n B --> C[Phone]" },
+ {
+ kind: "json",
+ data: {
+ status: "ok",
+ primitives: [
+ "html",
+ "markdown",
+ "diff",
+ "terminal",
+ "image",
+ "mermaid",
+ "json",
+ "code",
+ "trace",
+ ],
+ },
+ },
+ {
+ kind: "code",
+ language: "ts",
+ title: "mobile.ts",
+ code: "export const mobilePrimitive = (kind: string) => `${kind}: ok`;\n",
+ },
+ {
+ kind: "trace",
+ title: "Trace primitive",
+ steps: [
+ {
+ kind: "tool",
+ label: "verified every native primitive on iPhone 14 Pro",
+ detail: "html markdown diff terminal image mermaid json code trace",
+ },
+ ],
+ },
+ ],
+ });
+
+ await page.setViewportSize({ width: 393, height: 852 });
+ await page.goto(server.url);
+
+ const card = page.locator(".card:not(#whatsNew)");
+ await expect(card).toBeVisible();
+ await expect(card.locator("iframe").first()).toBeVisible();
+ await expectIframesNoHorizontalOverflow(page, card);
+ await expect(card.locator(".asset-img")).toBeVisible();
+ await expect(card.locator(".jsonpart")).toContainText("primitives");
+ await expect(card.locator(".trace-step")).toHaveCount(1);
+ await card.locator(".trace-row.clickable").click();
+ await expect(card.locator(".trace-detail")).toBeVisible();
+
+ await expectNoHorizontalOverflow(page, "main");
+ await expectNoHorizontalOverflow(page, ".card");
+ await expectNoHorizontalOverflow(page, ".tracepart");
+});
+
test("an uploaded image embeds by URL inside an html part under the CSP", async ({
page,
server,
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index a3664c3..bc91cfc 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -1,4 +1,11 @@
-import { expect, publish, test, update } from "./fixtures.ts";
+import {
+ expect,
+ expectNoHorizontalOverflow,
+ publish,
+ publishParts,
+ test,
+ update,
+} from "./fixtures.ts";
test("the sidebar groups sessions by recency and sinks empty ones to the bottom", async ({
page,
@@ -105,7 +112,7 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv
const snippet = await publish(server.url, { html: "
v1
", title: "Doc", agent: "e2e" });
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await card.locator(".act.comment").click();
const input = card.locator(".composer input");
await input.fill("ship it");
@@ -138,7 +145,7 @@ test("a comment's copy button puts an agent-ready paste block on the clipboard",
}
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await card.locator(".act.comment").click();
const input = card.locator(".composer input");
await input.fill("tighten the spacing");
@@ -165,7 +172,7 @@ test("a failed comment send restores the input instead of losing the message", a
await publish(server.url, { html: "x
", title: "Doc", agent: "e2e" });
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await page.route("**/api/comments", (route) =>
route.request().method() === "POST" ? route.abort() : route.fallback(),
);
@@ -192,7 +199,7 @@ test("a comment echoes immediately, before the SSE round-trip confirms it", asyn
await publish(server.url, { html: "x
", title: "Doc", agent: "e2e" });
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
// hold the POST open so only the optimistic echo can render
await page.route("**/api/comments", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
@@ -220,7 +227,7 @@ test("a comment containing raw HTML is sandboxed and escaped, never a live node"
await publish(server.url, { html: "x
", title: "Doc", agent: "e2e" });
await page.goto(server.url);
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await card.locator(".act.comment").click();
const input = card.locator(".composer input");
await input.fill(" hi");
@@ -306,12 +313,34 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
server,
}) => {
await publish(server.url, { html: "m
", title: "Mobile", agent: "e2e" });
+ const longSession = (await (
+ await fetch(`${server.url}/api/sessions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ agent: "e2e",
+ title: "A deliberately long mobile sidebar session title that should not shove controls",
+ }),
+ })
+ ).json()) as { id: string };
+ await publish(server.url, {
+ html: "long session row
",
+ title: "Long title mobile",
+ agent: "e2e",
+ session: longSession.id,
+ });
- await page.setViewportSize({ width: 375, height: 667 });
- await page.goto(server.url);
+ await fetch(`${server.url}/api/sessions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ agent: "idle", title: "Empty session still shown in drawer" }),
+ });
+
+ await page.setViewportSize({ width: 393, height: 852 });
+ await page.goto(`${server.url}/session/${longSession.id}`);
// the sidebar is off-canvas and the stream gets the full width
- const card = page.locator(".card");
+ const card = page.locator(".card:not(#whatsNew)");
await expect(card).toBeVisible();
await expect(page.locator("aside")).not.toBeInViewport();
expect((await card.boundingBox())!.width).toBeGreaterThan(300);
@@ -322,10 +351,72 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
// the menu button opens the drawer; picking a session closes it again
await page.locator("#menuBtn").click();
await expect(page.locator("aside")).toBeInViewport();
- await page.locator(".sess").click();
+ await expectNoHorizontalOverflow(page, "main");
+ await expectNoHorizontalOverflow(page, "aside");
+ const longSessionTitle = page.getByText("A deliberately long mobile sidebar", { exact: false });
+ const longSessionRow = page.locator('[role="button"]').filter({ has: longSessionTitle });
+ await expect(longSessionRow).toBeVisible();
+ const deleteLongSession = longSessionRow.getByRole("button", { name: /^Delete session/ });
+ await expect(deleteLongSession).toBeVisible();
+ await deleteLongSession.click({ trial: true });
+ await longSessionRow.click();
await expect(page.locator("aside")).not.toBeInViewport();
});
+test("timeline traces wrap cleanly at iPhone 14 Pro width", async ({ page, server }) => {
+ const surface = await publishParts(server.url, {
+ title: "Timeline anchor",
+ agent: "e2e",
+ parts: [
+ { kind: "markdown", markdown: "## Timeline card\n\nThe trace wraps around this card." },
+ ],
+ });
+ await fetch(`${server.url}/api/sessions/${surface.sessionId}/trace`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ steps: [
+ {
+ kind: "prompt",
+ label: "prompt-" + "unbrokenprompttoken".repeat(8),
+ detail:
+ "A longer prompt detail that should expand without creating horizontal document scroll.",
+ },
+ {
+ kind: "say",
+ label: "response-" + "unbrokenresponsetoken".repeat(8),
+ },
+ {
+ kind: "shell",
+ label:
+ "npm run trace-check -- --device=iPhone14Pro --case=long-command-label-without-spaces",
+ detail:
+ "command output: " +
+ "unbroken-token-for-overflow-regression-".repeat(8) +
+ "\nsecond line with normal words",
+ },
+ { kind: "say", label: "The timeline remains readable on a phone." },
+ ],
+ reset: true,
+ }),
+ });
+
+ await page.setViewportSize({ width: 393, height: 852 });
+ await page.goto(`${server.url}/session/${surface.sessionId}`);
+ await page.locator(".view-toggle button", { hasText: "Timeline" }).click();
+
+ await expect(page.locator(".timeline")).toBeVisible();
+ await expect(page.getByText("prompt-unbrokenprompttoken", { exact: false })).toBeVisible();
+ await page.getByRole("button", { name: "Show 1 work step" }).click();
+ await expect(page.getByRole("button", { name: "Hide 1 work step" })).toBeVisible();
+ await page.getByText("npm run trace-check", { exact: false }).click();
+ await expect(
+ page.getByText("unbroken-token-for-overflow-regression", { exact: false }),
+ ).toBeVisible();
+ await expectNoHorizontalOverflow(page, "main");
+ await expectNoHorizontalOverflow(page, ".timeline");
+});
+
test("the Connect Claude Code modal shows the plugin install commands", async ({
page,
server,
diff --git a/viewer/src/SessionTimeline.tsx b/viewer/src/SessionTimeline.tsx
index af8089d..daf8269 100644
--- a/viewer/src/SessionTimeline.tsx
+++ b/viewer/src/SessionTimeline.tsx
@@ -107,7 +107,7 @@ function TurnBlock(props: { turn: Turn }) {
events().filter((_, i) => i !== intentIdx() && i !== outcomeIdx()),
);
return (
- <>
+
{(p) => }
= 0}>
@@ -118,7 +118,7 @@ function TurnBlock(props: { turn: Turn }) {
= 0}>
- >
+
);
}
@@ -128,12 +128,23 @@ function TurnBlock(props: { turn: Turn }) {
function WorkFold(props: { steps: TraceStep[] }) {
const [open, setOpen] = createSignal(false);
const n = () => props.steps.length;
+ const label = () => `${open() ? "Hide" : "Show"} ${n()} work ${n() === 1 ? "step" : "steps"}`;
+ const desktopLabel = () => (open() ? `··· hide ${n()} steps ···` : `··· ${n()} steps ···`);
return (
<>
-
setOpen(!open())}>
- {open() ? `··· hide ${n()} steps ···` : `··· ${n()} steps ···`}
-
+
setOpen(!open())}
+ >
+ {desktopLabel()}
+ {label()}
+
+ {open() ? "-" : "+"}
+
+
@@ -178,7 +189,7 @@ function CommandRow(props: { step: TraceStep }) {
more() && setOpen(!open())}
>
diff --git a/viewer/src/embed.tsx b/viewer/src/embed.tsx
index 2c2336e..223b3e6 100644
--- a/viewer/src/embed.tsx
+++ b/viewer/src/embed.tsx
@@ -39,6 +39,7 @@ const EMBED_BASE_CSS = `
position: relative;
background: var(--bg);
color: var(--text);
+ -webkit-text-size-adjust: 100%;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.ss-engine-root { position: absolute; inset: 0; }
diff --git a/viewer/src/styles.css b/viewer/src/styles.css
index e77ab8a..9a51484 100644
--- a/viewer/src/styles.css
+++ b/viewer/src/styles.css
@@ -13,6 +13,8 @@
--accent-bg: #ddf4ff;
--hover: #eaeef2;
--danger: #cf222e;
+ --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
+ --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Terminal chrome — fallback values for the pre-theme paint. The active theme
overrides these from its DARK palette (see themes.ts termVars), so the
terminal adopts the theme's hue but stays dark in both color schemes: a
@@ -50,6 +52,7 @@ body {
margin: 0;
background: var(--bg);
color: var(--text);
+ -webkit-text-size-adjust: 100%;
font:
14px/1.5 -apple-system,
BlinkMacSystemFont,
@@ -538,12 +541,14 @@ iframe {
margin-bottom: 6px;
}
.trace-title {
+ min-width: 0;
font-weight: 500;
font-size: 12.5px;
color: var(--muted);
}
.trace-dl {
margin-left: auto;
+ flex: none;
font-size: 12px;
color: var(--accent);
text-decoration: none;
@@ -590,6 +595,7 @@ iframe {
padding: 1px 5px;
}
.trace-label {
+ min-width: 0;
color: var(--text);
}
.trace-ts {
@@ -930,7 +936,6 @@ iframe {
.connect-btn:hover {
border-color: var(--muted);
}
-
/* the "Connect Claude Code" integrations modal */
.modal-backdrop {
position: fixed;
@@ -1031,12 +1036,15 @@ iframe {
align-items: center;
gap: 4px;
flex: none;
- padding: 8px 10px;
+ min-height: 49px;
+ padding: calc(6px + env(safe-area-inset-top, 0px)) 10px 6px;
background: var(--panel);
border-bottom: 0.5px solid var(--border);
+ z-index: 20;
}
.topbar .brand {
padding: 0;
+ min-width: 0;
}
.topbar .menu {
position: relative;
@@ -1049,11 +1057,18 @@ iframe {
padding: 6px 9px;
cursor: pointer;
font-family: inherit;
+ transition:
+ background-color 120ms ease,
+ color 120ms ease,
+ transform 120ms var(--ease-out-strong);
}
.topbar .menu:hover {
color: var(--text);
background: var(--hover);
}
+ .topbar .menu:active {
+ transform: scale(0.96);
+ }
.topbar .menu .dot {
position: absolute;
top: 3px;
@@ -1074,10 +1089,48 @@ iframe {
left: 0;
z-index: 30;
width: min(280px, 84vw);
+ padding-top: env(safe-area-inset-top, 0px);
+ padding-bottom: env(safe-area-inset-bottom, 0px);
transform: translateX(-105%);
- transition: transform 0.2s ease;
+ transition: transform 220ms var(--ease-drawer);
+ will-change: transform;
+ }
+ aside > .brand {
+ min-height: 48px;
+ padding-top: 12px;
+ padding-bottom: 10px;
+ }
+ #sessionList {
+ padding: 6px 8px 10px;
+ }
+ .sess {
+ min-height: 52px;
+ padding: 10px 58px 10px 11px;
+ }
+ .sess-title {
+ padding-right: 0;
}
- body.nav-open aside {
+ .sess-meta {
+ min-width: 0;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .sess .x {
+ top: 11px;
+ right: 8px;
+ min-width: 28px;
+ min-height: 28px;
+ }
+ .sess .dot {
+ top: 15px;
+ right: 44px;
+ }
+ .aside-foot {
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
+ }
+ :is(body.nav-open, :host(.nav-open)) aside {
transform: none;
box-shadow: 0 0 32px rgba(0, 0, 0, 0.25);
}
@@ -1089,9 +1142,9 @@ iframe {
background: rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
- transition: opacity 0.2s;
+ transition: opacity 220ms var(--ease-drawer);
}
- body.nav-open #scrim {
+ :is(body.nav-open, :host(.nav-open)) #scrim {
opacity: 1;
pointer-events: auto;
}
@@ -1099,21 +1152,171 @@ iframe {
min-height: 0;
}
.session-head {
- padding: 12px 16px 10px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+ gap: 2px;
+ padding: 8px 12px 9px;
+ box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 70%, transparent);
+ }
+ #sessTitle {
+ display: block;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .session-head .meta {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .session-head .head-sp {
+ display: none;
+ }
+ .view-toggle {
+ width: min(100%, 360px);
+ margin-top: 4px;
+ }
+ .view-toggle button {
+ flex: 1;
+ min-height: 30px;
+ padding: 3px 10px;
}
#stream {
- padding: 16px 14px 120px;
+ padding: 12px 8px calc(112px + env(safe-area-inset-bottom, 0px));
+ }
+ .standalone-main {
+ padding: 12px 8px calc(48px + env(safe-area-inset-bottom, 0px));
}
#onboard {
padding: 40px 18px;
}
+}
+
+/* phone card chrome: keep cards scannable without changing desktop density */
+@media (max-width: 700px) {
+ .card {
+ border-radius: 10px;
+ margin-bottom: 14px;
+ }
+ .card-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 4px 8px;
+ padding: 9px 10px;
+ }
.card-title {
+ grid-column: 1 / -1;
min-width: 0;
- flex: 0 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+ .card-head .sp {
+ display: none;
+ }
+ .vslot {
+ min-width: 0;
+ }
+ .card-meta {
+ justify-self: end;
+ }
+}
+
+/* phone native parts: prevent each built-in renderer from forcing page overflow */
+@media (max-width: 700px) {
+ .imagepart,
+ .tracepart,
+ .jsonpart,
+ .part-unsupported,
+ .cmts {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+ .trace-head {
+ align-items: flex-start;
+ }
+ .trace-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .trace-row {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 4px 7px;
+ }
+ .trace-label {
+ flex: 1 1 160px;
+ overflow-wrap: anywhere;
+ }
+ .trace-ts {
+ width: 100%;
+ margin-left: 0;
+ }
+ .trace-detail {
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ }
+ .json-children {
+ padding-left: 14px;
+ }
+}
+
+/* phone card footer: comments and actions become touch targets */
+@media (max-width: 700px) {
+ .cmt .copy {
+ opacity: 1;
+ }
+ .card-actions {
+ min-height: 48px;
+ padding: 7px 8px;
+ }
+ .actbar {
+ gap: 4px;
+ }
+ .card-actions .act {
+ min-width: 36px;
+ height: 36px;
+ }
+ .card-actions .composer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 6px;
+ }
+ .card-actions .composer input {
+ min-height: 38px;
+ }
+ .card-actions .composer button {
+ min-height: 34px;
+ width: 100%;
+ }
+ #newPill {
+ bottom: calc(22px + env(safe-area-inset-bottom, 0px));
+ }
+}
+@media (max-width: 430px) {
+ #stream,
+ .standalone-main {
+ padding-left: 0;
+ padding-right: 0;
+ }
+ .card {
+ border-left: 0;
+ border-right: 0;
+ border-radius: 0;
+ }
+ .standalone-foot {
+ padding: 0 12px;
+ }
+ .view-toggle {
+ width: 100%;
+ }
}
/* narrow or touch: hover-revealed actions must stay reachable */
@media (max-width: 700px), (hover: none) {
@@ -1123,11 +1326,13 @@ iframe {
.sess .x {
opacity: 1;
}
- .sess-title {
- padding-right: 44px;
- }
.sess .dot {
- right: 32px;
+ right: 44px;
+ }
+}
+@media (hover: none) and (min-width: 701px) {
+ .sess-title {
+ padding-right: 52px;
}
}
@@ -1155,6 +1360,12 @@ iframe {
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
+@media (max-width: 430px) {
+ #toast {
+ width: calc(100vw - 24px);
+ max-width: none;
+ }
+}
/* ---- session view toggle (Stream / Timeline) ---- */
.session-head .head-sp {
@@ -1180,6 +1391,20 @@ iframe {
background: var(--accent-bg);
color: var(--accent);
}
+@media (prefers-reduced-motion: reduce) {
+ .topbar .menu,
+ aside,
+ #scrim,
+ .tl-notes-fold > .body,
+ .tl-cmd-inline {
+ transition: none;
+ }
+ .topbar .menu:active,
+ .tl-notes-fold > .body:active,
+ .tl-cmd-inline:active {
+ transform: none;
+ }
+}
/* ---- treatment E: session timeline ---- */
/* One spine down the middle. Surfaces are opaque cards that cover the line;
@@ -1209,6 +1434,10 @@ iframe {
.tl-rail-pad {
padding-left: 28px;
}
+.tl-turn {
+ position: relative;
+ z-index: 1;
+}
.tl-surface {
position: relative;
z-index: 1;
@@ -1267,15 +1496,40 @@ iframe {
font-size: 12px;
color: var(--faint);
}
+.tl-fold-button {
+ appearance: none;
+ display: inline;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ line-height: inherit;
+ text-align: left;
+}
+.tl-fold-caret {
+ display: none;
+}
+.tl-fold-label-mobile {
+ display: none;
+}
/* an expanded tool call, a faint mono line in the fold */
.tl-cmd-row > .body {
- display: flex;
- align-items: center;
- gap: 8px;
padding-left: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--faint);
+ min-width: 0;
+}
+.tl-cmd-inline {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.tl-cmd-inline > span:last-child {
+ min-width: 0;
}
.tl-cmd-row .knd {
flex: 0 0 auto;
@@ -1298,6 +1552,7 @@ iframe {
border: 0.5px solid var(--border);
border-radius: 8px;
white-space: pre-wrap;
+ overflow-x: auto;
}
.tl-tail {
padding-bottom: 80px;
@@ -1306,3 +1561,167 @@ iframe {
font-size: 12px;
color: var(--faint);
}
+
+@media (max-width: 700px) {
+ .timeline {
+ padding: 0 10px 0;
+ }
+ .timeline::before {
+ display: none;
+ }
+ .tl-turn {
+ margin: 10px 0 12px;
+ padding: 11px 12px 12px;
+ background: color-mix(in srgb, var(--surface) 78%, var(--bg));
+ border: 0.5px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 70%, transparent);
+ }
+ .tl-turn::before {
+ content: "";
+ position: absolute;
+ inset: 12px auto 12px 0;
+ width: 3px;
+ border-radius: 999px;
+ background: var(--accent);
+ }
+ .tl-row {
+ padding: 0;
+ }
+ .tl-marker {
+ display: none;
+ }
+ .tl-marker.prompt {
+ display: none;
+ }
+ .tl-surface {
+ padding-left: 0;
+ margin: 12px 0 14px;
+ }
+ .tl-surface .tl-node {
+ display: none;
+ }
+ .tl-prompt {
+ margin: 0 0 8px;
+ }
+ .tl-prompt > .body {
+ font-size: 14px;
+ line-height: 1.42;
+ font-weight: 650;
+ letter-spacing: 0;
+ overflow-wrap: anywhere;
+ }
+ .tl-prompt > .body::before {
+ content: "Prompt";
+ display: block;
+ margin-bottom: 5px;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ text-transform: uppercase;
+ color: var(--accent);
+ }
+ .tl-response > .body {
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+ }
+ .tl-response + .tl-notes-fold,
+ .tl-notes-fold + .tl-response,
+ .tl-row + .tl-row {
+ margin-top: 9px;
+ }
+ .tl-notes-fold > .body {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ min-height: 36px;
+ padding: 0 10px 0 12px;
+ color: var(--text);
+ background: var(--bg);
+ border: 0.5px solid var(--border);
+ border-radius: 8px;
+ transition:
+ transform 140ms var(--ease-out-strong),
+ border-color 160ms ease,
+ background-color 160ms ease;
+ }
+ .tl-notes-fold > .body:active {
+ transform: scale(0.99);
+ }
+ .tl-fold-caret {
+ display: inline-grid;
+ width: 22px;
+ height: 22px;
+ place-items: center;
+ border-radius: 999px;
+ color: var(--accent);
+ background: var(--accent-bg);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 14px;
+ line-height: 1;
+ }
+ .tl-fold-label-desktop {
+ display: none;
+ }
+ .tl-fold-label-mobile {
+ display: inline;
+ }
+ .tl-cmd-row > .body {
+ padding-left: 0;
+ color: var(--muted);
+ }
+ .tl-cmd-inline {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 4px 8px;
+ padding: 9px 10px;
+ background: var(--bg);
+ border: 0.5px solid var(--border);
+ border-radius: 8px;
+ transition:
+ transform 140ms var(--ease-out-strong),
+ border-color 160ms ease,
+ background-color 160ms ease;
+ }
+ .tl-cmd-inline:active {
+ transform: scale(0.99);
+ }
+ .tl-cmd-row .knd {
+ margin-top: 1px;
+ padding: 1px 5px;
+ background: var(--accent-bg);
+ border-radius: 4px;
+ opacity: 1;
+ }
+ .tl-cmd-inline > span:last-child {
+ color: var(--muted);
+ line-height: 1.45;
+ overflow-wrap: anywhere;
+ }
+ .tl-detail {
+ margin: 6px 0 2px;
+ padding: 9px 10px;
+ font-size: 11.5px;
+ line-height: 1.5;
+ background: var(--bg);
+ border-color: var(--border-2);
+ overflow-wrap: anywhere;
+ }
+ .tl-tail {
+ padding: 8px 10px 72px;
+ }
+ .tl-tail > .body {
+ padding-left: 0;
+ }
+}