fix: separate console pending groups and align row typography

This commit is contained in:
2026-10-02 01:55:54 +09:00
parent 17b7bdc769
commit 4ac448d66c
7 changed files with 403 additions and 28 deletions
+9
View File
@@ -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”.
@@ -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],
},
});
}
@@ -0,0 +1,179 @@
/// <reference lib="dom" />
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<HTMLElement>(".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;
}
});
+1 -1
View File
@@ -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,
+137
View File
@@ -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"
}
]
}
@@ -1892,10 +1892,10 @@
/>
{#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0}
<section class="pending-submissions" aria-label="Pending activations">
<div class="pending-column pending-queue" role="group" aria-label="Queued inputs">
<h2 class="pending-submissions-label">{pendingSubmissionItems.length} Queued</h2>
{#if pendingSubmissionItems.length > 0}
<section class="pending-submissions" class:has-both={pendingSubmissionItems.length > 0 && pendingSubmissions.notification_count > 0} aria-label="Pending activations">
{#if pendingSubmissionItems.length > 0}
<div class="pending-column pending-queue" role="group" aria-label="Queued inputs">
<h2 class="pending-submissions-label">{pendingSubmissionItems.length} Queued</h2>
<ol aria-label="Queued inputs">
{#each pendingSubmissionItems as submission, index (submission.submission_id)}
<li>
@@ -1923,11 +1923,11 @@
</li>
{/each}
</ol>
{/if}
</div>
<div class="pending-column pending-notifications" role="group" aria-label="Notifications">
<h2 class="pending-submissions-label">Notifications</h2>
{#if pendingSubmissions.notification_count > 0}
</div>
{/if}
{#if pendingSubmissions.notification_count > 0}
<div class="pending-column pending-notifications" role="group" aria-label="Notifications">
<h2 class="pending-submissions-label">Notifications</h2>
<ol aria-label="Pending notifications">
{#each pendingSubmissions.notification_previews ?? [] as preview}
<li>
@@ -1941,8 +1941,8 @@
</li>
{/each}
</ol>
{/if}
</div>
</div>
{/if}
</section>
{/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;
@@ -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());
});