Skip to content

Commit fcd4bbc

Browse files
committed
Tighten mobile viewer layout
1 parent f4bfeca commit fcd4bbc

6 files changed

Lines changed: 734 additions & 37 deletions

File tree

e2e/fixtures.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test as base } from "@playwright/test";
1+
import { expect, test as base, type Locator, type Page } from "@playwright/test";
22
import { type ChildProcess, spawn } from "node:child_process";
33
import { mkdtempSync } from "node:fs";
44
import { tmpdir } from "node:os";
@@ -67,7 +67,7 @@ export const publicReadTest = base.extend<{ publicReadServer: PublicReadServer }
6767
},
6868
});
6969

70-
export { expect } from "@playwright/test";
70+
export { expect };
7171

7272
export async function publish(
7373
serverUrl: string,
@@ -127,3 +127,45 @@ export async function publishParts(
127127
if (!res.ok) throw new Error(`publishParts failed: ${res.status}`);
128128
return res.json() as Promise<{ id: string; sessionId: string; version: number }>;
129129
}
130+
131+
export async function expectNoHorizontalOverflow(page: Page, selector: string) {
132+
await expect.poll(() => page.locator(selector).count()).toBeGreaterThan(0);
133+
await expect
134+
.poll(() =>
135+
page
136+
.locator(selector)
137+
.evaluateAll((elements) =>
138+
Math.max(0, ...elements.map((el) => Math.ceil(el.scrollWidth - el.clientWidth))),
139+
),
140+
)
141+
.toBeLessThanOrEqual(1);
142+
}
143+
144+
export async function expectIframesNoHorizontalOverflow(page: Page, container: Locator) {
145+
const frameUrls = await container
146+
.locator("iframe")
147+
.evaluateAll((frames) => frames.map((frame) => (frame as HTMLIFrameElement).src));
148+
expect(frameUrls.length).toBeGreaterThan(0);
149+
150+
await expect
151+
.poll(async () => {
152+
const childFrames = frameUrls
153+
.map((url) => page.frames().find((frame) => frame.url() === url))
154+
.filter((frame) => frame !== undefined);
155+
if (childFrames.length < frameUrls.length) return Number.POSITIVE_INFINITY;
156+
const overflows = await Promise.all(
157+
childFrames.map((frame) =>
158+
frame.evaluate(() => {
159+
if (document.readyState === "loading") return Number.POSITIVE_INFINITY;
160+
const doc = document.documentElement;
161+
const body = document.body;
162+
const scrollWidth = Math.max(doc.scrollWidth, body?.scrollWidth ?? 0);
163+
const clientWidth = Math.max(doc.clientWidth, body?.clientWidth ?? 0);
164+
return Math.ceil(scrollWidth - clientWidth);
165+
}),
166+
),
167+
);
168+
return Math.max(0, ...overflows);
169+
})
170+
.toBeLessThanOrEqual(1);
171+
}

e2e/uploads.spec.ts

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { expect, publish, publishParts, test, TINY_PNG_B64, upload } from "./fixtures.ts";
1+
import {
2+
expect,
3+
expectIframesNoHorizontalOverflow,
4+
expectNoHorizontalOverflow,
5+
publish,
6+
publishParts,
7+
test,
8+
TINY_PNG_B64,
9+
upload,
10+
} from "./fixtures.ts";
211

312
test("an image part renders an <img> served from /a/:id", async ({ page, server }) => {
413
const asset = await upload(server.url, {
@@ -42,7 +51,7 @@ test("a trace part renders a step timeline with expandable detail", async ({ pag
4251
});
4352

4453
await page.goto(server.url);
45-
const card = page.locator(".card");
54+
const card = page.locator(".card:not(#whatsNew)");
4655
await expect(card.locator(".trace-title")).toHaveText("What I did");
4756
await expect(card.locator(".trace-step")).toHaveCount(2);
4857
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
7281
});
7382

7483
await page.goto(server.url);
75-
const card = page.locator(".card");
84+
const card = page.locator(".card:not(#whatsNew)");
7685
await expect(card.locator(".trace-dl")).toHaveAttribute("href", `/a/${asset.id}`);
7786
// steps are fetched from the asset and rendered
7887
await expect(card.locator(".trace-step")).toHaveCount(2);
7988
await expect(card.locator(".trace-label").first()).toHaveText("step one");
8089
});
8190

91+
test("a trace part stays readable on an iPhone-sized viewport", async ({ page, server }) => {
92+
await publishParts(server.url, {
93+
title: "Trace on mobile",
94+
agent: "e2e",
95+
parts: [
96+
{
97+
kind: "trace",
98+
title: "Long trace heading that should truncate cleanly on mobile",
99+
steps: [
100+
{
101+
label:
102+
"ran a very long shell command with flags --workspace=/tmp/sideshow-mobile --include-traces --verify-sidebar",
103+
kind: "shell",
104+
ts: "2026-06-25T12:00:00Z",
105+
detail:
106+
"stdout: a-long-token-that-should-wrap-instead-of-forcing-horizontal-scroll ".repeat(
107+
6,
108+
),
109+
},
110+
{
111+
label: "noted how the sidebar drawer and trace card read together on a phone",
112+
kind: "say",
113+
ts: "2026-06-25T12:00:03Z",
114+
},
115+
],
116+
},
117+
],
118+
});
119+
120+
await page.setViewportSize({ width: 393, height: 852 });
121+
await page.goto(server.url);
122+
123+
const card = page.locator(".card:not(#whatsNew)");
124+
await expect(card.locator(".trace-title")).toBeVisible();
125+
await expect(card.locator(".trace-step")).toHaveCount(2);
126+
await card.locator(".trace-row.clickable").first().click();
127+
await expect(card.locator(".trace-detail")).toBeVisible();
128+
129+
await expectNoHorizontalOverflow(page, "main");
130+
await expectNoHorizontalOverflow(page, ".card");
131+
await expectNoHorizontalOverflow(page, ".tracepart");
132+
});
133+
134+
test("all native surface primitives fit the iPhone 14 Pro viewer", async ({ page, server }) => {
135+
const asset = await upload(server.url, {
136+
data: TINY_PNG_B64,
137+
contentType: "image/png",
138+
filename: "mobile-primitive.png",
139+
kind: "image",
140+
});
141+
await publishParts(server.url, {
142+
title: "Every primitive on mobile",
143+
agent: "e2e",
144+
session: asset.sessionId,
145+
parts: [
146+
{ kind: "html", html: "<section><h2>HTML</h2><p>Interactive surface shell.</p></section>" },
147+
{ kind: "markdown", markdown: "## Markdown\n\n- readable prose\n- list item" },
148+
{
149+
kind: "diff",
150+
patch:
151+
"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",
152+
},
153+
{
154+
kind: "terminal",
155+
text: "\u001b[32mPASS\u001b[0m mobile primitive check\n$ sideshow trace-sync --all",
156+
title: "terminal",
157+
},
158+
{ kind: "image", assetId: asset.id, caption: "uploaded image primitive" },
159+
{ kind: "mermaid", mermaid: "flowchart LR\n A[Agent] --> B[sideshow]\n B --> C[Phone]" },
160+
{
161+
kind: "json",
162+
data: {
163+
status: "ok",
164+
primitives: [
165+
"html",
166+
"markdown",
167+
"diff",
168+
"terminal",
169+
"image",
170+
"mermaid",
171+
"json",
172+
"code",
173+
"trace",
174+
],
175+
},
176+
},
177+
{
178+
kind: "code",
179+
language: "ts",
180+
title: "mobile.ts",
181+
code: "export const mobilePrimitive = (kind: string) => `${kind}: ok`;\n",
182+
},
183+
{
184+
kind: "trace",
185+
title: "Trace primitive",
186+
steps: [
187+
{
188+
kind: "tool",
189+
label: "verified every native primitive on iPhone 14 Pro",
190+
detail: "html markdown diff terminal image mermaid json code trace",
191+
},
192+
],
193+
},
194+
],
195+
});
196+
197+
await page.setViewportSize({ width: 393, height: 852 });
198+
await page.goto(server.url);
199+
200+
const card = page.locator(".card:not(#whatsNew)");
201+
await expect(card).toBeVisible();
202+
await expect(card.locator("iframe").first()).toBeVisible();
203+
await expectIframesNoHorizontalOverflow(page, card);
204+
await expect(card.locator(".asset-img")).toBeVisible();
205+
await expect(card.locator(".jsonpart")).toContainText("primitives");
206+
await expect(card.locator(".trace-step")).toHaveCount(1);
207+
await card.locator(".trace-row.clickable").click();
208+
await expect(card.locator(".trace-detail")).toBeVisible();
209+
210+
await expectNoHorizontalOverflow(page, "main");
211+
await expectNoHorizontalOverflow(page, ".card");
212+
await expectNoHorizontalOverflow(page, ".tracepart");
213+
});
214+
82215
test("an uploaded image embeds by URL inside an html part under the CSP", async ({
83216
page,
84217
server,

e2e/viewer.spec.ts

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { expect, publish, test, update } from "./fixtures.ts";
1+
import {
2+
expect,
3+
expectNoHorizontalOverflow,
4+
publish,
5+
publishParts,
6+
test,
7+
update,
8+
} from "./fixtures.ts";
29

310
test("the sidebar groups sessions by recency and sinks empty ones to the bottom", async ({
411
page,
@@ -105,7 +112,7 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv
105112
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });
106113

107114
await page.goto(server.url);
108-
const card = page.locator(".card");
115+
const card = page.locator(".card:not(#whatsNew)");
109116
await card.locator(".act.comment").click();
110117
const input = card.locator(".composer input");
111118
await input.fill("ship it");
@@ -138,7 +145,7 @@ test("a comment's copy button puts an agent-ready paste block on the clipboard",
138145
}
139146

140147
await page.goto(server.url);
141-
const card = page.locator(".card");
148+
const card = page.locator(".card:not(#whatsNew)");
142149
await card.locator(".act.comment").click();
143150
const input = card.locator(".composer input");
144151
await input.fill("tighten the spacing");
@@ -165,7 +172,7 @@ test("a failed comment send restores the input instead of losing the message", a
165172
await publish(server.url, { html: "<p>x</p>", title: "Doc", agent: "e2e" });
166173

167174
await page.goto(server.url);
168-
const card = page.locator(".card");
175+
const card = page.locator(".card:not(#whatsNew)");
169176
await page.route("**/api/comments", (route) =>
170177
route.request().method() === "POST" ? route.abort() : route.fallback(),
171178
);
@@ -192,7 +199,7 @@ test("a comment echoes immediately, before the SSE round-trip confirms it", asyn
192199
await publish(server.url, { html: "<p>x</p>", title: "Doc", agent: "e2e" });
193200

194201
await page.goto(server.url);
195-
const card = page.locator(".card");
202+
const card = page.locator(".card:not(#whatsNew)");
196203
// hold the POST open so only the optimistic echo can render
197204
await page.route("**/api/comments", async (route) => {
198205
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"
220227
await publish(server.url, { html: "<p>x</p>", title: "Doc", agent: "e2e" });
221228

222229
await page.goto(server.url);
223-
const card = page.locator(".card");
230+
const card = page.locator(".card:not(#whatsNew)");
224231
await card.locator(".act.comment").click();
225232
const input = card.locator(".composer input");
226233
await input.fill("<img src=x onerror=alert(1)> hi");
@@ -306,12 +313,34 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
306313
server,
307314
}) => {
308315
await publish(server.url, { html: "<p>m</p>", title: "Mobile", agent: "e2e" });
316+
const longSession = (await (
317+
await fetch(`${server.url}/api/sessions`, {
318+
method: "POST",
319+
headers: { "content-type": "application/json" },
320+
body: JSON.stringify({
321+
agent: "e2e",
322+
title: "A deliberately long mobile sidebar session title that should not shove controls",
323+
}),
324+
})
325+
).json()) as { id: string };
326+
await publish(server.url, {
327+
html: "<p>long session row</p>",
328+
title: "Long title mobile",
329+
agent: "e2e",
330+
session: longSession.id,
331+
});
309332

310-
await page.setViewportSize({ width: 375, height: 667 });
311-
await page.goto(server.url);
333+
await fetch(`${server.url}/api/sessions`, {
334+
method: "POST",
335+
headers: { "content-type": "application/json" },
336+
body: JSON.stringify({ agent: "idle", title: "Empty session still shown in drawer" }),
337+
});
338+
339+
await page.setViewportSize({ width: 393, height: 852 });
340+
await page.goto(`${server.url}/session/${longSession.id}`);
312341

313342
// the sidebar is off-canvas and the stream gets the full width
314-
const card = page.locator(".card");
343+
const card = page.locator(".card:not(#whatsNew)");
315344
await expect(card).toBeVisible();
316345
await expect(page.locator("aside")).not.toBeInViewport();
317346
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
322351
// the menu button opens the drawer; picking a session closes it again
323352
await page.locator("#menuBtn").click();
324353
await expect(page.locator("aside")).toBeInViewport();
325-
await page.locator(".sess").click();
354+
await expectNoHorizontalOverflow(page, "main");
355+
await expectNoHorizontalOverflow(page, "aside");
356+
const longSessionTitle = page.getByText("A deliberately long mobile sidebar", { exact: false });
357+
const longSessionRow = page.locator('[role="button"]').filter({ has: longSessionTitle });
358+
await expect(longSessionRow).toBeVisible();
359+
const deleteLongSession = longSessionRow.getByRole("button", { name: /^Delete session/ });
360+
await expect(deleteLongSession).toBeVisible();
361+
await deleteLongSession.click({ trial: true });
362+
await longSessionRow.click();
326363
await expect(page.locator("aside")).not.toBeInViewport();
327364
});
328365

366+
test("timeline traces wrap cleanly at iPhone 14 Pro width", async ({ page, server }) => {
367+
const surface = await publishParts(server.url, {
368+
title: "Timeline anchor",
369+
agent: "e2e",
370+
parts: [
371+
{ kind: "markdown", markdown: "## Timeline card\n\nThe trace wraps around this card." },
372+
],
373+
});
374+
await fetch(`${server.url}/api/sessions/${surface.sessionId}/trace`, {
375+
method: "POST",
376+
headers: { "content-type": "application/json" },
377+
body: JSON.stringify({
378+
steps: [
379+
{
380+
kind: "prompt",
381+
label: "prompt-" + "unbrokenprompttoken".repeat(8),
382+
detail:
383+
"A longer prompt detail that should expand without creating horizontal document scroll.",
384+
},
385+
{
386+
kind: "say",
387+
label: "response-" + "unbrokenresponsetoken".repeat(8),
388+
},
389+
{
390+
kind: "shell",
391+
label:
392+
"npm run trace-check -- --device=iPhone14Pro --case=long-command-label-without-spaces",
393+
detail:
394+
"command output: " +
395+
"unbroken-token-for-overflow-regression-".repeat(8) +
396+
"\nsecond line with normal words",
397+
},
398+
{ kind: "say", label: "The timeline remains readable on a phone." },
399+
],
400+
reset: true,
401+
}),
402+
});
403+
404+
await page.setViewportSize({ width: 393, height: 852 });
405+
await page.goto(`${server.url}/session/${surface.sessionId}`);
406+
await page.locator(".view-toggle button", { hasText: "Timeline" }).click();
407+
408+
await expect(page.locator(".timeline")).toBeVisible();
409+
await expect(page.getByText("prompt-unbrokenprompttoken", { exact: false })).toBeVisible();
410+
await page.getByRole("button", { name: "Show 1 work step" }).click();
411+
await expect(page.getByRole("button", { name: "Hide 1 work step" })).toBeVisible();
412+
await page.getByText("npm run trace-check", { exact: false }).click();
413+
await expect(
414+
page.getByText("unbroken-token-for-overflow-regression", { exact: false }),
415+
).toBeVisible();
416+
await expectNoHorizontalOverflow(page, "main");
417+
await expectNoHorizontalOverflow(page, ".timeline");
418+
});
419+
329420
test("the Connect Claude Code modal shows the plugin install commands", async ({
330421
page,
331422
server,

0 commit comments

Comments
 (0)