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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mobile-viewer-ui.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 44 additions & 2 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -67,7 +67,7 @@ export const publicReadTest = base.extend<{ publicReadServer: PublicReadServer }
},
});

export { expect } from "@playwright/test";
export { expect };

export async function publish(
serverUrl: string,
Expand Down Expand Up @@ -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);
}
139 changes: 136 additions & 3 deletions e2e/uploads.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <img> served from /a/:id", async ({ page, server }) => {
const asset = await upload(server.url, {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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: "<section><h2>HTML</h2><p>Interactive surface shell.</p></section>" },
{ 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,
Expand Down
111 changes: 101 additions & 10 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: "<p>v1</p>", 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");
Expand Down Expand Up @@ -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");
Expand All @@ -165,7 +172,7 @@ test("a failed comment send restores the input instead of losing the message", a
await publish(server.url, { html: "<p>x</p>", 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(),
);
Expand All @@ -192,7 +199,7 @@ test("a comment echoes immediately, before the SSE round-trip confirms it", asyn
await publish(server.url, { html: "<p>x</p>", 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();
Expand Down Expand Up @@ -220,7 +227,7 @@ test("a comment containing raw HTML is sandboxed and escaped, never a live node"
await publish(server.url, { html: "<p>x</p>", 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("<img src=x onerror=alert(1)> hi");
Expand Down Expand Up @@ -306,12 +313,34 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
server,
}) => {
await publish(server.url, { html: "<p>m</p>", 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: "<p>long session row</p>",
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);
Expand All @@ -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,
Expand Down
Loading
Loading