diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 9b7cef87d..058f424a3 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -208,6 +208,15 @@ Build `web/workspace` before capture. No real keys, accounts, or deletion reques keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`. +## Console pending input fixture + +`scenarios/console-pending.json` uses the owned Console history fixture with `?pending=both`, +`queue`, `notifications`, `legacy`, or `empty`. Each state includes a task for typography comparison. +It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test.ts` additionally checks +320px and both themes, independent group visibility, full-width single groups, uniform 16px rows, +monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced +cancellation and state transitions are covered by Console component tests. No real Worker is used. + ## Adding a scenario 1. Name the concrete user task and expected data state; do not write “looks correct”. diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts index 2a4e56b22..627def943 100644 --- a/tools/web-ux/browser-tests/console_history_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -174,17 +174,30 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session` ) { + const pendingMode = new URL(request.headers.get("referer") ?? request.url).searchParams.get("pending"); + const hasQueue = pendingMode === "both" || pendingMode === "queue"; + const hasNotifications = pendingMode === "both" || pendingMode === "notifications" || pendingMode === "legacy"; + const submissions = hasQueue ? [ + { submission_id: "queued-1", preview: "First queued input", accepted_at_ms: 1, segment_count: 1, byte_len: 18 }, + { submission_id: "queued-2", preview: "Long queued input — " + "preview text ".repeat(30), accepted_at_ms: 2, segment_count: 1, byte_len: 500 }, + ] : []; + const taskEntries = pendingMode ? [{ + kind: "tool_call", entry_id: "fixture-task", timestamp: 13, provenance: "model_output", + call_id: "fixture-task", name: "TaskCreate", + arguments: JSON.stringify({ subject: "Inspect pending input layout", description: "Synthetic visual fixture" }), + }] : []; return json({ availability: "retained_snapshot", identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 }, snapshot: { pending_submissions: { revision: 0, - notification_count: 0, - head_id: null, - submissions: [], + notification_count: hasNotifications ? 2 : 0, + ...(hasNotifications && pendingMode !== "legacy" ? { notification_previews: ["First notification", "Long notification — " + "notification text ".repeat(30)] } : {}), + head_id: submissions[0]?.submission_id ?? null, + submissions, }, - entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), + entries: [...[7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), ...taskEntries], }, }); } diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts new file mode 100644 index 000000000..7be6c4046 --- /dev/null +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -0,0 +1,179 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium } from "playwright"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +Deno.test("Console pending scopes are independent and match Tasks row typography", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: join(root, "web/workspace"), + stdout: "piped", + stderr: "piped", + }).output(); + assert(build.success, new TextDecoder().decode(build.stderr)); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/console_history_fixture_server.ts"), + String(port), + join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + try { + let started = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await fetch(`${url}/health`); + await response.body?.cancel(); + if (response.ok) { + started = true; + break; + } + } catch { /* owned fixture is starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(started); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + for (const mode of ["both", "queue", "notifications", "legacy", "empty"]) { + await page.goto( + `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, + ); + await page.locator(".task-mini-row").waitFor(); + const queue = mode === "both" || mode === "queue"; + const notifications = ["both", "notifications", "legacy"].includes(mode); + assertEquals( + await page.getByRole("group", { name: "Queued inputs", exact: true }).count(), + queue ? 1 : 0, + ); + assertEquals( + await page.getByRole("group", { name: "Notifications", exact: true }).count(), + notifications ? 1 : 0, + ); + assertEquals(await page.getByRole("heading", { name: "0 Queued" }).count(), 0); + const region = page.getByRole("region", { name: "Pending activations" }); + assertEquals(await region.count(), mode === "empty" ? 0 : 1); + if (mode === "empty") continue; + const geometry = await region.evaluate((region) => { + const task = document.querySelector(".task-mini-row")!; + const taskStyle = getComputedStyle(task); + const metrics = (node: Element) => { + const style = getComputedStyle(node); + return { + height: node.getBoundingClientRect().height, + family: style.fontFamily, + size: style.fontSize, + line: style.lineHeight, + }; + }; + return { + task: metrics(task), + rows: [...region.querySelectorAll("h2, li")].map(metrics), + lists: [...region.querySelectorAll("ol")].map((list) => ({ + margin: getComputedStyle(list).margin, + padding: getComputedStyle(list).padding, + gap: getComputedStyle(list).rowGap, + })), + columns: [...region.querySelectorAll(".pending-column")].map((column) => + column.getBoundingClientRect().toJSON() + ), + width: region.getBoundingClientRect().width, + pageOverflow: document.documentElement.scrollWidth > innerWidth, + mainOverflow: document.querySelector("main")!.scrollWidth > + document.querySelector("main")!.clientWidth, + previews: [...region.querySelectorAll(".pending-submission-preview")].map(( + preview, + ) => ({ + title: preview.getAttribute("title"), + whitespace: getComputedStyle(preview).whiteSpace, + overflow: getComputedStyle(preview).textOverflow, + })), + expectedGap: getComputedStyle(task.closest(".task-mini")!).rowGap, + taskMono: taskStyle.fontFamily.includes("mono"), + }; + }); + assertEquals(geometry.task.height, 16); + assertEquals(geometry.taskMono, true); + for (const row of geometry.rows) { + assertEquals( + row, + geometry.task, + `${width}px/${mode}: heading and preview rows must match Tasks`, + ); + } + for (const list of geometry.lists) { + assertEquals(list, { margin: "0px", padding: "0px", gap: geometry.expectedGap }); + } + if (mode === "both") assertEquals(geometry.columns[0].y, geometry.columns[1].y); + else {assertEquals( + geometry.columns[0].width, + geometry.width, + "a lone scope must not reserve an empty column", + );} + assertEquals(geometry.pageOverflow, false); + assertEquals(geometry.mainOverflow, false); + for (const preview of geometry.previews) { + assert(preview.title); + assertEquals(preview.whitespace, "nowrap"); + assertEquals(preview.overflow, "ellipsis"); + } + if (queue) { + const cancel = page.getByRole("button", { name: "Cancel queued input 1" }); + assertEquals((await cancel.boundingBox())!.height, 16); + assertEquals( + await cancel.isDisabled(), + true, + "retained sessions cannot mutate the queue", + ); + await page.locator(".pending-queue li").first().hover(); + assertEquals( + await cancel.evaluate((button) => getComputedStyle(button).opacity), + "1", + ); + } + if (mode === "legacy") { + await page.getByText("2 pending · Preview unavailable", { exact: true }).waitFor(); + } + } + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + } finally { + await browser.close(); + } + } finally { + try { + server.kill("SIGTERM"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await server.status; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index c76d7545a..ab994d533 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/console-pending.json b/tools/web-ux/scenarios/console-pending.json new file mode 100644 index 000000000..c499b6f6f --- /dev/null +++ b/tools/web-ux/scenarios/console-pending.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 1, + "id": "console-pending", + "title": "Console pending inputs and notifications", + "baseUrl": "http://127.0.0.1:15283", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Retained Console fixture", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "both", + "label": "both", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=both", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: both; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "queue", + "label": "queue", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=queue", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: queue; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "notifications", + "label": "notifications", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=notifications", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: notifications; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "legacy", + "label": "legacy", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=legacy", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: legacy; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "empty", + "label": "empty", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=empty", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: empty; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + } + ], + "processes": [ + { + "id": "console-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/console_history_fixture_server.ts", + "15283", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15283/health" + } + ] +} diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 4ae4f17d5..110c50821 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1892,10 +1892,10 @@ /> {#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0} -
-
-

{pendingSubmissionItems.length} Queued

- {#if pendingSubmissionItems.length > 0} +
0 && pendingSubmissions.notification_count > 0} aria-label="Pending activations"> + {#if pendingSubmissionItems.length > 0} +
+

{pendingSubmissionItems.length} Queued

    {#each pendingSubmissionItems as submission, index (submission.submission_id)}
  1. @@ -1923,11 +1923,11 @@
  2. {/each}
- {/if} -
-
-

Notifications

- {#if pendingSubmissions.notification_count > 0} +
+ {/if} + {#if pendingSubmissions.notification_count > 0} +
+

Notifications

    {#each pendingSubmissions.notification_previews ?? [] as preview}
  1. @@ -1941,8 +1941,8 @@
  2. {/each}
- {/if} -
+
+ {/if}
{/if} @@ -2309,24 +2309,34 @@ .pending-submissions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: var(--space-3); flex: 0 0 auto; min-width: 0; margin: 0 var(--space-3); color: var(--text-muted); + font-family: var(--font-mono); font-size: var(--font-size-compact); line-height: var(--line-height-compact); } + .pending-submissions.has-both { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .pending-column { + display: grid; + align-content: start; + gap: var(--space-1); min-width: 0; } .pending-submissions li { display: grid; grid-template-columns: minmax(0, 1fr); - min-height: var(--space-6); + height: var(--line-height-compact); + margin: 0; + padding: 0; align-items: center; } @@ -2340,16 +2350,16 @@ } .pending-submissions-label { - margin: 0 0 var(--space-1); - font-size: var(--font-size-compact); - line-height: var(--line-height-compact); - font-weight: 500; + margin: 0; + padding: 0; + font: inherit; + color: inherit; } .pending-submissions ol { display: grid; gap: var(--space-1); - max-height: calc(var(--space-6) * 4); + max-height: calc(var(--line-height-compact) * 4 + var(--space-1) * 3); overflow-y: auto; margin: 0; padding: 0; @@ -2371,8 +2381,8 @@ align-items: center; justify-content: center; width: var(--space-6); - height: var(--space-6); - padding: var(--space-2); + height: var(--line-height-compact); + padding: 0; border: 0; border-radius: var(--radius-soft); background: transparent; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index 3a313e00b..069aa88a0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -369,7 +369,8 @@ test("notification-only state displays previews without actions and supports leg session.pending_submissions.head_id = "notification-head"; latestListener().onFrame(subscribedFrame(session)); const region = await screen.findByRole("region", { name: "Pending activations" }); - expect(region.textContent).toContain("0 Queued"); + expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "0 Queued" })).toBeNull(); expect(region.textContent).toContain("一件目の通知"); expect(region.textContent).toContain("二件目の通知"); expect(region.querySelectorAll("li")).toHaveLength(2); @@ -382,7 +383,7 @@ test("notification-only state displays previews without actions and supports leg await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); }); -test("queue-only state keeps the notification column empty and disables cancellation when disconnected", async () => { +test("queue-only state hides notifications and disables cancellation when disconnected", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); render(ConsolePage, { data: pageData() }); await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); @@ -390,7 +391,8 @@ test("queue-only state keeps the notification column empty and disables cancella session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "送信内容", accepted_at_ms: 1, segment_count: 1, byte_len: 12 }]; latestListener().onFrame(subscribedFrame(session)); await screen.findByText("送信内容"); - expect(screen.getByRole("group", { name: "Notifications" }).querySelector("ol")).toBeNull(); + expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Notifications" })).toBeNull(); latestListener().onStatus?.("closed", "connection lost"); await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true)); }); @@ -1137,3 +1139,28 @@ function expectHistoryTurn(text: string) { expect(screen.getAllByText(text)).toHaveLength(1); expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull(); } + +test("pending groups independently follow authoritative changes, including zero count with stale previews", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + const session = emptySession(); + session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "Queued", accepted_at_ms: 1, segment_count: 1, byte_len: 6 }]; + session.pending_submissions.notification_count = 1; + session.pending_submissions.notification_previews = ["Notice"]; + latestListener().onFrame(subscribedFrame(session)); + await screen.findByRole("group", { name: "Queued inputs" }); + expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy(); + session.pending_submissions.notification_count = 0; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull()); + expect(screen.getByRole("group", { name: "Queued inputs" })).toBeTruthy(); + session.pending_submissions.submissions = []; + session.pending_submissions.notification_count = 1; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull()); + expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy(); + session.pending_submissions.notification_count = 0; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); +});