From b0d49737176fb7e511a0baa6ff8f1c410e7bc8cc Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 4 Oct 2026 21:03:16 +0900 Subject: [PATCH 01/11] fix: align console metadata and details controls --- .../console_pending_shell_test.ts | 56 ++++++++++++++++ .../WorkerContextStatus.browser.spec.ts | 23 ++++++- .../console/WorkerContextStatus.svelte | 65 +++++++++++++++---- .../lib/workspace/console/run-status.test.ts | 6 +- .../console/worker-console.ui.test.ts | 20 ++++++ .../lib/workspace/console/worker-metadata.ts | 6 +- .../workers/[workerRef]/console/+page.svelte | 22 +++---- 7 files changed, 162 insertions(+), 36 deletions(-) diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts index 7be6c4046..a419e985f 100644 --- a/tools/web-ux/browser-tests/console_pending_shell_test.ts +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -64,6 +64,62 @@ Deno.test("Console pending scopes are independent and match Tasks row typography `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, ); await page.locator(".task-mini-row").waitFor(); + const metadata = page.getByLabel("Worker model and context", { exact: true }); + const statusGeometry = await metadata.evaluate((status) => { + const task = document.querySelector(".task-mini-row")!; + const composer = document.querySelector(".console-composer")!; + const metrics = (node: Element) => { + const style = getComputedStyle(node); + return [style.fontFamily, style.fontSize, style.lineHeight]; + }; + const rect = status.getBoundingClientRect(); + return { + typography: metrics(status), + taskTypography: metrics(task), + height: rect.height, + belowComposer: rect.top >= composer.getBoundingClientRect().bottom, + alignedWithTasks: rect.left + parseFloat(getComputedStyle(status).paddingLeft) === task.getBoundingClientRect().left, + visible: rect.bottom <= innerHeight, + wrap: getComputedStyle(status).flexWrap, + pageOverflow: document.documentElement.scrollWidth > innerWidth, + }; + }); + assertEquals(statusGeometry.typography, statusGeometry.taskTypography); + assertEquals(statusGeometry.height, 16); + assertEquals(statusGeometry.belowComposer, true); + assertEquals(statusGeometry.alignedWithTasks, true); + assertEquals(statusGeometry.visible, true); + assertEquals(statusGeometry.wrap, "nowrap"); + assertEquals(statusGeometry.pageOverflow, false); + const details = metadata.getByRole("button", { name: "Details", exact: true }); + assertEquals(await page.locator(".console-header").getByRole("button", { name: "Details" }).count(), 0); + const detailsGeometry = await details.evaluate((button) => { + const row = button.closest(".worker-context-status")!; + const rect = button.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + return { + rightAligned: Math.abs(rect.right - (rowRect.right - parseFloat(getComputedStyle(row).paddingRight))) < 1, + centered: Math.abs(rect.top + rect.height / 2 - (rowRect.top + rowRect.height / 2)) < 1, + width: rect.width, + }; + }); + assertEquals(detailsGeometry.rightAligned, true); + assertEquals(detailsGeometry.centered, true); + assert(detailsGeometry.width > 0); + await details.focus(); + await page.keyboard.press("Enter"); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).waitFor(); + await page.keyboard.press("Space"); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + await details.click(); + assertEquals(await details.getAttribute("aria-expanded"), "true"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + assertEquals(await details.getAttribute("aria-expanded"), "false"); + if (mode === "both") { + await Deno.mkdir(join(root, "target/web-ux/composer-metadata"), { recursive: true }); + await page.screenshot({ path: join(root, `target/web-ux/composer-metadata/${colorScheme}-${width}.png`) }); + } const queue = mode === "both" || mode === "queue"; const notifications = ["both", "notifications", "legacy"].includes(mode); assertEquals( diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts index f45776f62..fd37c8bcf 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.browser.spec.ts @@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte"; afterEach(cleanup); +test("the three-dot Details control invokes the existing toggle and reflects its state", async () => { + let toggles = 0; + const view = render(WorkerContextStatus, { + metadata: null, + detailsOpen: false, + onToggleDetails: () => { toggles++; }, + }); + const button = view.getByRole("button", { name: "Details" }); + expect(button.querySelectorAll("circle")).toHaveLength(3); + expect(button.textContent?.trim()).toBe(""); + expect(button.getAttribute("aria-expanded")).toBe("false"); + button.click(); + expect(toggles).toBe(1); + await view.rerender({ detailsOpen: true }); + expect(button.getAttribute("aria-expanded")).toBe("true"); +}); + test("renders resolved model, reasoning, and measured context accessibly", () => { const view = render(WorkerContextStatus, { metadata: { @@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () => const status = view.getByLabelText("Worker model and context"); expect(status.textContent).toContain("gpt-6-astra · high"); - expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)"); + expect(status.textContent).toContain("142.0k / 272.0k (52%)"); }); test("renders honest unavailable state instead of a fabricated zero percent", async () => { const view = render(WorkerContextStatus, { metadata: null }); expect(view.getByText("Model unavailable")).not.toBeNull(); - expect(view.getByText("Context unavailable")).not.toBeNull(); + expect(view.getByText("unavailable")).not.toBeNull(); expect(view.container.textContent).not.toContain("0%"); await view.rerender({ @@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as }, }); expect(view.container.textContent).toContain("claude · 8.2k token budget"); - expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)"); + expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)"); await view.rerender({ metadata: { diff --git a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte index 248cf5025..c2fdb3e65 100644 --- a/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte +++ b/web/workspace/src/lib/workspace/console/WorkerContextStatus.svelte @@ -7,9 +7,11 @@ type Props = { metadata: ConsoleWorkerMetadata | null; + detailsOpen?: boolean; + onToggleDetails?: () => void; }; - let { metadata }: Props = $props(); + let { metadata, detailsOpen = false, onToggleDetails }: Props = $props(); const modelSummary = $derived(formatModelSummary(metadata)); const contextSummary = $derived(formatContextSummary(metadata)); @@ -18,12 +20,32 @@ {modelSummary} {contextSummary} + {#if onToggleDetails} + + {/if} diff --git a/web/workspace/src/lib/workspace/console/run-status.test.ts b/web/workspace/src/lib/workspace/console/run-status.test.ts index 29de10947..bc8a9f989 100644 --- a/web/workspace/src/lib/workspace/console/run-status.test.ts +++ b/web/workspace/src/lib/workspace/console/run-status.test.ts @@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 142_000, contextSource: "estimated", }), - "Context ~142.0k / 272.0k (52%)", + "~142.0k / 272.0k (52%)", ); assertEquals(formatModelSummary(null), "Model unavailable"); assertEquals( @@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }), "reasoning unavailable", ); - assertEquals(formatContextSummary(null), "Context unavailable"); + assertEquals(formatContextSummary(null), "unavailable"); assertEquals( formatContextSummary({ model: null, @@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely" contextTokens: 0, contextSource: null, }), - "Context unavailable", + "unavailable", ); }); diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index ba0543d4d..9160e9583 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -738,6 +738,26 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps" ); }); +Deno.test("Model information stays below Composer in one compact monospace row", async () => { + const page = await Deno.readTextFile(new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", + import.meta.url, + )); + const composer = page.indexOf('
", composer); + const status = page.indexOf("= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer"); + assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata"); + assert(page.indexOf(" { const files = [ "ComposerInput.svelte", diff --git a/web/workspace/src/lib/workspace/console/worker-metadata.ts b/web/workspace/src/lib/workspace/console/worker-metadata.ts index 03f9618e3..d9217cb61 100644 --- a/web/workspace/src/lib/workspace/console/worker-metadata.ts +++ b/web/workspace/src/lib/workspace/console/worker-metadata.ts @@ -36,11 +36,11 @@ export function formatContextSummary( used === null || used === undefined || limit === null || limit === undefined || !Number.isSafeInteger(used) || !Number.isSafeInteger(limit) || used < 0 || limit <= 0 - ) return "Context unavailable"; + ) return "unavailable"; const percent = Math.round((used / limit) * 100); - if (!Number.isFinite(percent)) return "Context unavailable"; + if (!Number.isFinite(percent)) return "unavailable"; const estimate = metadata?.contextSource === "estimated" ? "~" : ""; - return `Context ${estimate}${formatRunTokens(used)} / ${ + return `${estimate}${formatRunTokens(used)} / ${ formatRunTokens(limit) } (${percent}%)`; } 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 773e1996d..1a00e244e 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 @@ -1652,7 +1652,6 @@
-
-
@@ -2094,6 +2082,14 @@
+ { + workerDetailsOpen = !workerDetailsOpen; + if (workerDetailsOpen) taskPaneOpen = false; + }} + /> 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 fc951de67..125f09ff5 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 @@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history await screen.findByText("Start of conversation"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1); - await fireEvent.click(screen.getByRole("button", { name: "Normal" })); + const footer = screen.getByLabelText("Worker model and context"); + const overview = screen.getByRole("switch", { name: "Overview" }); + expect(screen.queryByRole("button", { name: "Normal" })).toBeNull(); + const details = screen.getByRole("button", { name: "Details" }); + expect(view.container.querySelector(".console-header")).toBeNull(); + expect(footer.contains(overview)).toBe(true); + expect(overview.nextElementSibling).toBe(details); + expect(overview.closest("form")).toBeNull(); + expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer); + expect(overview.getAttribute("aria-checked")).toBe("true"); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("false"); + expect(overview.textContent?.trim()).toBe("Overview"); const transcript = screen.getByRole("article", { name: "main transcript" }); const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id")); expect(ids).toHaveLength(3); expect(new Set(ids).size).toBe(ids.length); - await fireEvent.click(screen.getByRole("button", { name: "Overview" })); + await fireEvent.click(overview); + expect(overview.getAttribute("aria-checked")).toBe("true"); + expect(overview.textContent?.trim()).toBe("Overview"); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1); + expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled(); }); test.each([ @@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button", latestListener().onFrame(subscribedFrame(session)); const summary = await screen.findByRole("button", { name: /1 task — pending/ }); expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull(); - expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks"); + expect(view.container.querySelector(".console-header")).toBeNull(); await fireEvent.click(screen.getByRole("button", { name: "Details" })); const details = await screen.findByRole("complementary", { name: "Worker detail" }); expect(details.textContent).not.toContain("Capabilities"); From fbb09e6597f7815c7e66ac6256adfdeb0651578c Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 13:56:33 +0900 Subject: [PATCH 07/11] feat(web): clarify Subject Memory status --- .../memory_document_fixture_server.ts | 45 +++- .../memory_document_shell_test.ts | 63 ++++-- .../scenarios/memory-subjects-dark.json | 40 +++- .../scenarios/memory-subjects-light.json | 40 +++- .../console/worker-console.ui.test.ts | 9 +- .../w/[workspaceId]/memory/+page.svelte | 30 ++- .../memory/[subjectId]/+page.svelte | 207 ++++++++++++++---- .../[subjectId]/[memoryId]/+page.svelte | 29 ++- .../memory/memory-page.browser.spec.ts | 58 +++-- web/workspace/tests/memory-api.test.ts | 59 ++++- 10 files changed, 460 insertions(+), 120 deletions(-) diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 3cd9cc001..b370ffe81 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -66,7 +66,13 @@ ${"detail-wide-code-".repeat(18)} `; const subjects = [ - subject(representativeSubjectId, "Release coordination", "active", 42), + subject( + representativeSubjectId, + "Release coordination", + "active", + 42, + connectedWorker("Release coordination Worker with a deliberately long display name"), + ), subject(emptySubjectId, "Empty ready subject", "active", 0), subject(staleSubjectId, "Stale surface subject", "active", 19), subject(failedSubjectId, "Failed surface subject", "active", 8), @@ -75,7 +81,37 @@ const subjects = [ ]; const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); -function subject(id: string, role: string, state: "active" | "retired", storeRevision: number) { +function connectedWorker(displayName: string) { + return { + runtime_id: "embedded-worker-runtime", + worker_id: "release-coordination-worker", + host_id: "fixture-host", + display_name: displayName, + label: displayName, + profile: "builtin:companion", + singleton_key: `subjektiv:${representativeSubjectId}`, + tags: [], + workspace: { + visibility: "workspace", + identity: workspaceId, + workspace_id: workspaceId, + }, + state: "idle", + last_seen_at: "2026-01-02T03:04:05Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Fixture Worker" }, + diagnostics: [], + }; +} + +function subject( + id: string, + role: string, + state: "active" | "retired", + storeRevision: number, + currentWorker?: ReturnType, +) { return { id, role, @@ -83,6 +119,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev store_revision: storeRevision, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", + ...(currentWorker ? { current_worker: currentWorker } : {}), }; } @@ -125,7 +162,7 @@ function memoriesFor(subjectId: string) { 1, "working_assumption", "retracted", - "T-672 UNIQUE END MARKER", + "Memory list end marker for scroll validation", ), ]; } @@ -235,7 +272,7 @@ function memoryDetail(memoryId: string, requestedRevision: number | null) { evidence_id: "evidence-message-0001", origin: { kind: "human_input", account_id: "account-fixture" }, evidence_kind: "message", - label: "T-672 product direction", + label: "Memory information design source", summary: "Bounded source reference retained with the committed revision.", }], source_refs_total: 1, diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index c0913082a..53b167168 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -16,7 +16,7 @@ const memoryId = const subjectPageCursor = "fixture-subject-page-2"; const createInteractionArtifacts = join( repositoryRoot, - "target/web-ux/t-689-browser-interactions", + "target/web-ux/t-692-browser-interactions", ); async function freePort(): Promise { @@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise { const document = (globalThis as any).document; const main = document.querySelector("main"); const marker = [...document.querySelectorAll("h3")].find((heading) => - heading.textContent === "T-672 UNIQUE END MARKER" + heading.textContent === "Memory list end marker for scroll validation" ); if (!main || !marker) return false; const mainRect = main.getBoundingClientRect(); @@ -262,7 +262,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); assertEquals( - await page.getByRole("article", { name: "Resident surface" }).locator("img, script") + await page.getByRole("article", { name: "Resident context" }).locator("img, script") .count(), 0, ); @@ -282,7 +282,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await page.locator("main").hover(); await page.mouse.wheel(0, 100_000); } else if (scenario.label === "keyboard") { - await page.getByText("Surface provenance", { exact: true }).focus(); + await page.getByText("Surface sources and diagnostics", { exact: true }).focus(); await page.keyboard.press("End"); } else { await swipeMainToEnd(page); @@ -313,17 +313,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco level: 1, }).waitFor(); assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); - assertEquals(await detailPage.getByText("T-672 product direction").count(), 1); + assertEquals(await detailPage.getByText("Memory information design source").count(), 1); assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1); assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).count(), 1); - assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); - assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0); + assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1); + assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0); await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( "link", { name: "Next page →" }, ).click(); - await detailPage.getByText("Revision 1", { exact: true }).waitFor(); + await detailPage.getByText("Memory revision 1", { exact: true }).waitFor(); assert(new URL(detailPage.url()).searchParams.has("revision_cursor")); assertEquals( await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( @@ -352,6 +352,15 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const paginationPage = await paginationContext.newPage(); await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`); await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor(); + assertEquals( + await paginationPage.getByText( + "Release coordination Worker with a deliberately long display name", + { exact: true }, + ).count(), + 1, + ); + assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0); + assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( "link", @@ -451,10 +460,12 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, ); - const currentWorker = createPage.locator(".subject-facts div").filter({ - hasText: "Current worker", - }); - assert((await currentWorker.textContent())?.includes("None")); + const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator( + "..", + ); + assert((await currentWorker.textContent())?.includes("Not connected")); + await createPage.getByText("Resident context has not been generated.").waitFor(); + await createPage.getByText("No committed Memories yet.").waitFor(); assertEquals(mutationPaths, [ `/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`, @@ -472,21 +483,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const stateCases = [ { id: "empty-subject", - text: "Resident surface is ready and empty.", + text: "Resident context is current but empty.", + role: "status" as const, + }, + { + id: "stale-subject", + text: "Resident context needs to be refreshed.", role: "status" as const, }, - { id: "stale-subject", text: "Resident surface is stale.", role: "status" as const }, { id: "failed-subject", - text: "Resident surface generation failed.", + text: "Resident context generation failed.", role: "alert" as const, }, { id: "ungenerated-subject", - text: "Resident surface has not been generated.", + text: "Resident context has not been generated.", role: "status" as const, }, - { id: "error-subject", text: "Resident surface unavailable.", role: "alert" as const }, + { + id: "error-subject", + text: "Resident context status unavailable.", + role: "alert" as const, + }, ]; for (const stateCase of stateCases) { const page = await stateContext.newPage(); @@ -494,8 +513,14 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text }); await state.waitFor(); assert((await state.textContent())?.includes(stateCase.text)); + if (stateCase.id === "stale-subject") { + assertEquals(await page.getByText("A resolved stale-surface lesson").count(), 1); + } + if (stateCase.id === "failed-subject") { + assertEquals(await page.getByText("A retracted failed-surface constraint").count(), 1); + } if (stateCase.id !== "empty-subject") { - assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0); + assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0); } await assertNoPageWideOverflow(page); await page.close(); diff --git a/tools/web-ux/scenarios/memory-subjects-dark.json b/tools/web-ux/scenarios/memory-subjects-dark.json index 7fbdebfb0..bfb3de69b 100644 --- a/tools/web-ux/scenarios/memory-subjects-dark.json +++ b/tools/web-ux/scenarios/memory-subjects-dark.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", diff --git a/tools/web-ux/scenarios/memory-subjects-light.json b/tools/web-ux/scenarios/memory-subjects-light.json index 9ddf6f803..905f15372 100644 --- a/tools/web-ux/scenarios/memory-subjects-light.json +++ b/tools/web-ux/scenarios/memory-subjects-light.json @@ -122,9 +122,33 @@ "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "ready": { "kind": "selector", - "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident surface']" + "selector": "[data-memory-view='subject'][data-memory-state='ready'] article[aria-label='Resident context']" }, - "capturePoints": [{ "id": "initial", "label": "Ready subject" }] + "capturePoints": [ + { "id": "initial", "label": "Ready subject" }, + { + "id": "subject-diagnostics", + "label": "Subject diagnostics", + "interaction": [ + { "action": "click", "selector": "details.subject-technical-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.subject-technical-details[open]" + } + }, + { + "id": "surface-diagnostics", + "label": "Surface sources and diagnostics", + "interaction": [ + { "action": "click", "selector": "details.surface-details summary" } + ], + "ready": { + "kind": "selector", + "selector": "details.surface-details[open]" + } + } + ] }, { "id": "detail", @@ -150,6 +174,18 @@ }, "capturePoints": [{ "id": "initial", "label": "Ready empty" }] }, + { + "id": "ungenerated", + "label": "Ungenerated subject context", + "path": "/w/memory-review/memory/ungenerated-subject", + "goal": "Distinguish a Subject with no generated resident context from lost committed Memory.", + "dataState": "Ungenerated resident context and a successful empty committed Memory list.", + "ready": { + "kind": "selector", + "selector": "[data-memory-view='subject'][data-memory-state='ungenerated']" + }, + "capturePoints": [{ "id": "initial", "label": "Not generated" }] + }, { "id": "stale", "label": "Stale subject surface", diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index ba0543d4d..55b0d091f 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectIndexLoad.includes('"/subjektiv/subjects"') && subjectIndexLoad.includes("parseSubjektivSubjectListResponse") && subjectIndexPage.includes(">Subjects") && - subjectIndexPage.includes("subject.store_revision") && + subjectIndexPage.includes("Worker connection") && + subjectIndexPage.includes("subject.current_worker") && + !subjectIndexPage.includes("subject.store_revision") && subjectIndexPage.includes('data-memory-view="subjects"'), "Memory product entry should list explicit typed subjects", ); @@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async () subjectPageLoad.includes("`${subjectPath}/surface`") && subjectPageLoad.includes("`${subjectPath}/memories`") && subjectPage.includes("DocumentMarkdown from") && - subjectPage.includes("Resident surface") && + subjectPage.includes("Resident context") && + subjectPage.includes("Subject store revision") && subjectPage.includes("Current Memories") && subjectPage.includes("surface?.availability === 'ready'") && + subjectPage.includes("surface?.availability === 'ungenerated'") && subjectPage.includes("surface?.availability === 'stale'") && + subjectPage.includes("surface?.availability === 'failed'") && subjectPage.includes("data-surface-ready-empty") && !subjectPage.includes("overflow-y"), "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller", diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index 9483ad3fc..2ba3dbdca 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -31,6 +31,10 @@ return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path; } + function subjectStateLabel(state: SubjektivSubjectResponse['state']): string { + return state === 'active' ? 'Available' : 'Retired'; + } + async function openCreateForm(): Promise { createExpanded = true; createError = null; @@ -109,7 +113,7 @@
{#if data.subjects.data} - {subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'} + {subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''} {/if}
-
Memory id
{memory.id}
-
Revision
{memory.revision}
-
Updated
+
Memory ID
{memory.id}
+
Memory revision
{memory.revision}
+
Last changed
{/each} @@ -270,25 +323,79 @@ font-size: var(--font-size-compact); } - .subject-facts { - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-3); + .subject-overview { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); + padding: var(--space-4); + background: var(--bg-raised); + border-radius: var(--radius-soft); } - .subject-facts > div { - display: block; + .subject-overview > div { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; } - .subject-facts dt { - white-space: normal; + .subject-overview span, + .subject-overview p, + .subject-technical-details summary, + .subject-technical-details small, + .surface-details small { + color: var(--text-muted); + font-size: var(--font-size-compact); } - .subject-facts dd { - margin-top: var(--space-1); - color: var(--text); + .subject-overview strong { + color: var(--text-strong); overflow-wrap: anywhere; } + .subject-overview p { + margin: 0; + overflow-wrap: anywhere; + } + + .subject-technical-details { + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--line); + } + + .subject-technical-details summary { + width: fit-content; + cursor: pointer; + font-weight: 700; + } + + .subject-technical-details dl, + .surface-details dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); + margin-top: var(--space-3); + } + + .subject-technical-details dl > div, + .surface-details dl > div { + display: block; + min-width: 0; + } + + .subject-technical-details dd, + .surface-details dd { + margin-top: var(--space-1); + overflow-wrap: anywhere; + } + + .subject-technical-details small, + .surface-details small { + display: block; + margin-top: var(--space-1); + line-height: 1.4; + } + .subject-state, .availability, .memory-pill, @@ -455,7 +562,7 @@ } @media (max-width: 900px) { - .subject-facts { + .subject-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -472,10 +579,16 @@ gap: var(--space-2); } - .subject-facts { + .subject-overview, + .subject-technical-details dl, + .surface-details dl { grid-template-columns: minmax(0, 1fr); } + .subject-overview { + padding: var(--space-3); + } + .memory-row { padding-inline: 0; } diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte index 07a1108fe..71c038627 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/[memoryId]/+page.svelte @@ -63,6 +63,11 @@ return value.replaceAll('_', ' '); } + function titleLabel(value: string): string { + const normalized = label(value); + return normalized[0].toUpperCase() + normalized.slice(1); + } + function rangeLabel(range: [number, number] | null | undefined): string { return range ? `${range[0]}–${range[1]}` : 'None'; } @@ -98,8 +103,8 @@
- {memory.state} - {label(memory.kind)} + {titleLabel(memory.state)} + {titleLabel(memory.kind)} {#if memory.revision !== memory.current_revision}Historical revision{/if}

{memory.claim}

@@ -109,11 +114,11 @@
-
Revision
{memory.revision} of {memory.current_revision}
-
State
{memory.state}
-
Kind
{label(memory.kind)}
+
Memory revision
{memory.revision} of {memory.current_revision}
+
Status
{titleLabel(memory.state)}
+
Type
{titleLabel(memory.kind)}
Created
-
Updated
+
Last changed
@@ -140,7 +145,7 @@

Audit context

Candidate provenance

- {memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'} + {memory.source_candidates.length} shown
{#if memory.source_candidates.length === 0} @@ -226,7 +231,11 @@
-

Immutable record

Revision history

{#if data.revisions.data}{revisions.length}{data.revisions.data.has_more ? '+' : ''}{/if}
+
+

Immutable record

Revision history

+ {#if data.revisions.data}{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}{/if} +
+

These are versions of this Memory record, separate from the Subject’s internal update number.

{#if data.revisions.data} {#if revisions.length === 0}
@@ -238,7 +247,7 @@ {#each revisions as revision (revision.revision)}
  • -
    Revision {revision.revision}{revision.state}{label(revision.kind)}
    +
    Memory revision {revision.revision}{titleLabel(revision.state)}{titleLabel(revision.kind)}

    {revision.claim}

    {revision.change_reason} ·
    @@ -386,6 +395,7 @@ } .memory-facts { + display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } @@ -414,6 +424,7 @@ .why-useful, .staleness, .change-reason, + .section-intro, .empty-copy, .bounded-note { margin: 0; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index cc767fa43..9ed075458 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -1,12 +1,6 @@ // @vitest-environment happy-dom -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/svelte"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; const { gotoMock } = vi.hoisted(() => ({ @@ -73,6 +67,9 @@ test("renders the subject index as the Memory product entry", () => { expect(screen.getByRole("link", { name: /Release coordinator/ })).not .toBeNull(); expect(screen.getByText("subject-1")).not.toBeNull(); + expect(screen.getByText("Not connected")).not.toBeNull(); + expect(screen.queryByText("Store revision", { exact: true })).toBeNull(); + expect(screen.getByText("1 shown · more available")).not.toBeNull(); expect(screen.getByRole("link", { name: "First page" }).getAttribute("href")) .toBe("/w/workspace-1/memory"); expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) @@ -184,9 +181,7 @@ test("preserves invalid and rejected role drafts with accessible errors", async }); test("warns about an unknown create outcome without replaying the POST", async () => { - const fetchMock = vi.fn(() => - Promise.reject(new TypeError("connection reset")) - ); + const fetchMock = vi.fn(() => Promise.reject(new TypeError("connection reset"))); vi.stubGlobal("fetch", fetchMock); render(SubjectIndexPage, { data: subjectIndexData() } as never); @@ -214,14 +209,16 @@ test("renders a ready resident surface and committed Memory lifecycle states", a workspaceId: "workspace-1", subjectId: "subject-1", cursor: "memory-current", - subject: result(subject()), + subject: result({ + ...subject(), + current_worker: { display_name: "Release Worker" }, + }), surface: result({ subject_id: "subject-1", availability: "ready" as const, snapshot: { snapshot_id: "snapshot-1", - body_md: - "# Resident context\n\nUse the **current** committed record.", + body_md: "# Resident context\n\nUse the **current** committed record.", memory_refs: [{ memory_id: "memory-1", revision: 3 }], built_from_store_revision: 9, created_at: "2026-01-02T03:04:05Z", @@ -267,9 +264,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a expect(screen.getByRole("heading", { name: "Resident context", level: 1 })) .not.toBeNull(); }); - expect(screen.getByRole("article", { name: "Resident surface" })).not + expect(screen.getByRole("article", { name: "Resident context" })).not .toBeNull(); - expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan( + expect(screen.getByText("Connected", { exact: true })).not.toBeNull(); + expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull(); + expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull(); + expect(screen.getByText("Subject store revision", { exact: true })).not + .toBeNull(); + expect(screen.getByText(/not a Memory count/)).not.toBeNull(); + expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan( 0, ); expect(screen.getByText("Resolved", { exact: true })).not.toBeNull(); @@ -303,8 +306,24 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }), }, } as never); - expect(screen.getByText("Resident surface is ready and empty.")).not + expect(screen.getByText("Resident context is current but empty.")).not .toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + + await view.rerender( + { + data: { + ...base, + surface: result({ + subject_id: "subject-1", + availability: "ungenerated" as const, + }), + }, + } as never, + ); + expect(screen.getByText("Resident context has not been generated.")).not + .toBeNull(); + expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); await view.rerender( { @@ -317,7 +336,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s }, } as never, ); - expect(screen.getByText("Resident surface is stale.")).not.toBeNull(); + expect(screen.getByText("Resident context needs to be refreshed.")).not + .toBeNull(); await view.rerender( { @@ -333,7 +353,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s expect(screen.getByRole("alert").textContent).toContain("generation failed"); await view.rerender({ data: { ...base, surface: result(null) } } as never); - expect(screen.getByText("Resident surface data is unavailable.")).not + expect(screen.getByText("Resident context status is unavailable.")).not .toBeNull(); await view.rerender( @@ -342,7 +362,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s } as never, ); expect(screen.getByRole("alert").textContent).toContain( - "Resident surface unavailable", + "Resident context status unavailable", ); }); diff --git a/web/workspace/tests/memory-api.test.ts b/web/workspace/tests/memory-api.test.ts index 97f036e1e..52f7c017e 100644 --- a/web/workspace/tests/memory-api.test.ts +++ b/web/workspace/tests/memory-api.test.ts @@ -61,9 +61,7 @@ async function assertSubjectCreateRejects( assertEquals(error.status, expectedStatus); if (!error.message.includes(expectedMessage)) { throw new Error( - `expected ${JSON.stringify(error.message)} to include ${ - JSON.stringify(expectedMessage) - }`, + `expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`, ); } return; @@ -82,6 +80,30 @@ function subject(id = "subject-1") { }; } +function currentWorker() { + return { + runtime_id: "runtime-1", + worker_id: "worker-1", + host_id: "host-1", + display_name: "Release Worker", + label: "Release Worker", + profile: "builtin:companion", + singleton_key: "subjektiv:subject-1", + tags: [], + workspace: { + visibility: "workspace", + identity: "workspace-1", + workspace_id: "workspace-1", + }, + state: "idle", + last_seen_at: "2026-09-02T00:00:00Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "embedded", display_hint: "Embedded" }, + diagnostics: [], + }; +} + function queryItem(id = "memory-1") { return { id, @@ -187,14 +209,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo () => parseSubjektivSubjectListResponse({ limit: 100, - items: Array.from({ length: 101 }, (_, index) => - subject(`subject-${index}`)), + items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)), has_more: false, }), "bounded array", ); }); +Deno.test("Subject parser preserves live Worker connection state", () => { + assertEquals( + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: currentWorker(), + }), + { ...subject(), current_worker: currentWorker() }, + ); + assertEquals( + parseSubjektivSubjectResponse({ ...subject(), current_worker: null }), + { ...subject(), current_worker: null }, + ); + assertThrows( + () => + parseSubjektivSubjectResponse({ + ...subject(), + current_worker: { display_name: "Incomplete Worker" }, + }), + "current worker.runtime_id", + ); +}); + Deno.test("Subject list parser preserves the exact bounded response", () => { const response = { limit: 100, items: [subject()], has_more: false }; assertEquals(parseSubjektivSubjectListResponse(response), response); @@ -328,8 +371,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit throw new TypeError("connection reset"); }) as typeof fetch; await assertSubjectCreateRejects( - () => - createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), + () => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }), "unknown_outcome", "may have been created", ); @@ -433,8 +475,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers, assertThrows( () => parseSubjektivMemoryQueryResponse({ - items: Array.from({ length: 101 }, (_, index) => - queryItem(`memory-${index}`)), + items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)), has_more: false, }), "bounded array", From 4cf6c130cde19197d6b657783b1ac5218984a2d1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 13:59:39 +0900 Subject: [PATCH 08/11] fix(web): distinguish empty resident context --- .../memory/[subjectId]/+page.svelte | 9 ++++++++- .../memory/memory-page.browser.spec.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte index 86292ffa1..def40f255 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/+page.svelte @@ -47,6 +47,13 @@ return 'Generation failed'; } + function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string { + if (availability !== 'ready') return 'No current generated context is available to use.'; + return snapshot?.body_md.trim() + ? 'Current generated context is available.' + : 'A current empty surface exists; there is no resident context to show.'; + } + function subjectStateLabel(state: 'active' | 'retired'): string { return state === 'active' ? 'Available' : 'Retired'; } @@ -95,7 +102,7 @@ Resident context {#if surface} {surfaceLabel(surface.availability)} -

    {surface.availability === 'ready' ? 'Current generated context is available.' : 'No current generated context is available to use.'}

    +

    {surfaceSummary(surface.availability)}

    {:else} Unavailable

    The resident context status could not be read.

    diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index 9ed075458..f1b9bb906 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -1,6 +1,12 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; const { gotoMock } = vi.hoisted(() => ({ @@ -181,7 +187,9 @@ test("preserves invalid and rejected role drafts with accessible errors", async }); test("warns about an unknown create outcome without replaying the POST", async () => { - const fetchMock = vi.fn(() => Promise.reject(new TypeError("connection reset"))); + const fetchMock = vi.fn(() => + Promise.reject(new TypeError("connection reset")) + ); vi.stubGlobal("fetch", fetchMock); render(SubjectIndexPage, { data: subjectIndexData() } as never); @@ -218,7 +226,8 @@ test("renders a ready resident surface and committed Memory lifecycle states", a availability: "ready" as const, snapshot: { snapshot_id: "snapshot-1", - body_md: "# Resident context\n\nUse the **current** committed record.", + body_md: + "# Resident context\n\nUse the **current** committed record.", memory_refs: [{ memory_id: "memory-1", revision: 3 }], built_from_store_revision: 9, created_at: "2026-01-02T03:04:05Z", @@ -308,6 +317,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s } as never); expect(screen.getByText("Resident context is current but empty.")).not .toBeNull(); + expect(screen.getByText(/current empty surface exists/)).not.toBeNull(); expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); await view.rerender( From b489a59057f7a7ac489da2a18ded0c6dfcb0dd09 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 14:05:45 +0900 Subject: [PATCH 09/11] fix: handle configuration schema bodies and editor load failures --- .../ConfigSourceEditor.browser.spec.ts | 231 ++++++++++++++++++ .../config-source/ConfigSourceEditor.svelte | 51 +++- .../src/lib/workspace/config-source/api.ts | 7 +- web/workspace/test/config-source/api.test.ts | 91 ++++++- 4 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts new file mode 100644 index 000000000..6355eaa4d --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; + +const toolchain = vi.hoisted(() => ({ + construct: vi.fn(), + setSnapshot: vi.fn(), + analyze: vi.fn(), + complete: vi.fn(), + close: vi.fn(), +})); +vi.mock("./toolchain.ts", () => ({ + ConfigSourceToolchain: class { + constructor() { + toolchain.construct(); + } + setSnapshot = toolchain.setSnapshot; + analyze = toolchain.analyze; + complete = toolchain.complete; + close = toolchain.close; + }, +})); + +const schemaSource = "schema body\n".repeat(6000); +const tree = { + snapshot: { + revision: 7, + digest: "sha256:tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: "WorkspaceConfigSchema({})\n", + content_digest: "sha256:main", + }, + }, + }, + contract: { + contract_version: 1, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: { + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: schemaSource, + source_digest: "sha256:contribution", + }], + source: schemaSource, + fingerprint: "sha256:schema", + }, + fingerprint: "sha256:toolchain", + }, + projection_digest: "sha256:projection", +}; +const fetcher = vi.fn(); +const response = () => new Response(JSON.stringify(tree)); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("fetch", fetcher); + fetcher.mockImplementation(async () => response()); + toolchain.setSnapshot.mockResolvedValue(undefined); + toolchain.analyze.mockResolvedValue([]); + toolchain.complete.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderEditor(workspaceId = "w") { + return render(ConfigSourceEditor, { workspaceId }); +} + +function expectNoEditor(view: ReturnType) { + expect(view.queryByText("Select or create a source")).toBeNull(); + expect(view.queryByRole("button", { name: "Create local source" })) + .toBeNull(); + expect(view.queryByRole("button", { name: "Commit" })).toBeNull(); + expect(view.container.querySelector(".cm-editor")).toBeNull(); +} + +async function expectReady(view: ReturnType) { + await waitFor(() => + expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not + .toBeNull() + ); + expect(view.container.querySelector('.cm-content[contenteditable="true"]')) + .not.toBeNull(); + expect(view.container.textContent).toContain("WorkspaceConfigSchema({})"); + expect(view.queryByRole("alert")).toBeNull(); +} + +test("loading is not an empty source tree; editing waits for schema installation", async () => { + let finishFetch!: (response: Response) => void; + let finishSetup!: () => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + finishSetup = resolve; + }), + ); + const view = render(ConfigSourceEditor, { workspaceId: "w/one" }); + expect(view.getByRole("status").textContent).toContain("Loading source tree"); + expectNoEditor(view); + finishFetch(response()); + await waitFor(() => + expect(toolchain.setSnapshot).toHaveBeenCalledWith( + tree.snapshot, + tree.contract.schema_bundle, + ) + ); + expectNoEditor(view); + finishSetup(); + await expectReady(view); + expect(fetcher).toHaveBeenCalledWith( + "/api/w/w%2Fone/config/source-tree", + expect.anything(), + ); +}); + +test.each(["http", "invalid"])( + "%s failure has a manual reload, not creation controls or automatic retries", + async (failure) => { + fetcher.mockResolvedValueOnce( + failure === "http" + ? new Response("Backend unavailable", { status: 503 }) + : new Response(JSON.stringify({ ...tree, unexpected: true })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Unable to load workspace configuration", + ) + ); + expect(view.getByRole("alert").textContent).toContain( + failure === "http" ? "Backend unavailable" : "invalid response", + ); + expectNoEditor(view); + expect(toolchain.setSnapshot).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(fetcher).toHaveBeenCalledOnce(); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); + +test.each(["construct", "setSnapshot"] as const)( + "toolchain %s failure is caught and manual reload initializes a fresh toolchain", + async (phase) => { + if (phase === "construct") { + toolchain.construct.mockImplementationOnce(() => { + throw new Error("Worker unavailable"); + }); + } else {toolchain.setSnapshot.mockRejectedValueOnce( + new Error("Schema setup failed"), + );} + const view = renderEditor(); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + phase === "construct" ? "Worker unavailable" : "Schema setup failed", + ) + ); + expectNoEditor(view); + await fireEvent.click( + view.getByRole("button", { name: "Reload source tree" }), + ); + await expectReady(view); + expect(toolchain.construct).toHaveBeenCalledTimes(2); + }, +); + +test("a successfully loaded empty tree still offers source creation", async () => { + fetcher.mockResolvedValueOnce( + new Response(JSON.stringify({ + ...tree, + snapshot: { ...tree.snapshot, entries: {} }, + })), + ); + const view = renderEditor(); + await waitFor(() => + expect(view.getByText("Select or create a source")).not.toBeNull() + ); + expect(view.getByRole("button", { name: "Create local source" })).not + .toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("unmount during schema setup closes the toolchain", async () => { + let failSetup!: (error: Error) => void; + toolchain.setSnapshot.mockReturnValueOnce( + new Promise((_, reject) => { + failSetup = reject; + }), + ); + toolchain.close.mockImplementationOnce(() => + failSetup(new Error("toolchain closed")) + ); + const view = renderEditor(); + await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce()); + view.unmount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.close).toHaveBeenCalledOnce(); +}); + +test("unmount during fetch does not start a toolchain", async () => { + let finishFetch!: (response: Response) => void; + fetcher.mockReturnValueOnce( + new Promise((resolve) => { + finishFetch = resolve; + }), + ); + const view = renderEditor(); + view.unmount(); + finishFetch(response()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(toolchain.construct).not.toHaveBeenCalled(); +}); diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 0cf4471d4..78f9edb44 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -21,6 +21,8 @@ let newPath = $state("module.dcdl"); let diagnostics = $state([]); let status = $state("Loading source tree…"); + let loadState = $state<"loading" | "error" | "ready">("loading"); + let mounted = false; let busy = $state(false); let workingChanges = $state([]); let baseRevision = $state(0); @@ -42,9 +44,12 @@ const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); onMount(() => { - toolchain = new ConfigSourceToolchain(); + mounted = true; void reload(); - return () => toolchain?.close(); + return () => { + mounted = false; + toolchain?.close(); + }; }); $effect(() => { @@ -70,9 +75,20 @@ }); async function reload() { + loadState = "loading"; + status = "Loading source tree…"; analysisReady = false; + analysisGeneration += 1; try { - treeState = await fetchConfigTree(workspaceId); + const remote = await fetchConfigTree(workspaceId); + if (!mounted) return; + toolchain?.close(); + toolchain = null; + toolchain = new ConfigSourceToolchain(); + await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); + if (!mounted) return; + // Publish the new baseline only after both fetch and toolchain setup succeed. + treeState = remote; if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } @@ -80,15 +96,19 @@ baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; - await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); analysisReady = true; workingChanges = []; renamePath = selectedPath; diagnostics = []; conflict = false; status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; + loadState = "ready"; } catch (error) { + if (!mounted) return; + toolchain?.close(); + toolchain = null; status = String(error); + loadState = "error"; } } @@ -213,8 +233,6 @@ } async function discardAndReload() { - workingChanges = []; - source = ""; await reload(); } @@ -287,7 +305,18 @@ } -
    +
    + {#if loadState === "loading"} +
    Loading source tree…
    + {:else if loadState === "error"} +
    +
    + Unable to load workspace configuration +

    {status}

    +
    + +
    + {:else}
    + + diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 479ba0b9b..c277ccc5d 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -11,6 +11,9 @@ import type { } from "./types.ts"; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +// Schema bodies are documents, not short metadata. Keep them within the +// response budget; readJson also bounds the combined bundle and contributions. +const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES; const MAX_ERROR_BYTES = 4 * 1024; const MAX_ENTRY_COUNT = 256; const MAX_ENTRY_BYTES = 256 * 1024; @@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution { provider_id: boundedString(item.provider_id), namespace: boundedString(item.namespace), version: boundedString(item.version), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), ...(projection === undefined ? {} : { projection_validator: projection }), source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), }; @@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle { const item = record(value, ["contributions", "source", "fingerprint"]); return { contributions: boundedArray(item.contributions).map(parseContribution), - source: boundedString(item.source), + source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES), }; } diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 6c5771099..d0269c215 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn entrypoints: [], }, fetcher); - assertEquals(calls.map((call) => call.url), [ + assertEquals(calls.map((call) => call.url), [ "/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/source-tree/revisions/7", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/commit", ]); - assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[3].init?.method, "POST"); assert( String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), ); }); +function withSchema(source: unknown, contributionSource: unknown = "builtin") { + return { + ...tree, + contract: { + ...tree.contract, + schema_bundle: { + ...tree.contract.schema_bundle, + source, + contributions: [{ + provider_id: "builtin", + namespace: "workspace", + version: "1", + source: contributionSource, + source_digest: "sha256:contribution", + }], + }, + }, + }; +} + +for (const field of ["bundle", "contribution"] as const) { + Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => { + const source = "schema body\n".repeat(6000); + const body = field === "bundle" + ? withSchema(source) + : withSchema("builtin", source); + const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; + assertEquals(await fetchConfigTree("w", fetcher), body); + assertEquals( + await commitConfigTree("w", { + base_revision: 7, + base_digest: snapshot.digest, + changes: [], + entrypoints: ["main.dcdl"], + }, fetcher), + body, + ); + }); + + Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => { + const body = (source: unknown) => + field === "bundle" ? withSchema(source) : withSchema("builtin", source); + const limit = 8 * 1024 * 1024; + const atLimit = "é".repeat(limit / 2); + assertEquals( + parseWorkspaceConfigTreeResponse(body(atLimit)), + body(atLimit), + ); + for (const invalid of [atLimit + "x", null, 42, {}]) { + assertThrows( + () => parseWorkspaceConfigTreeResponse(body(invalid)), + ConfigSourceApiError, + ); + } + }); +} + +Deno.test("config source API retains metadata and total response limits", async () => { + const invalid = withSchema("builtin"); + invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat( + 4097, + ); + assertThrows( + () => parseWorkspaceConfigTreeResponse(invalid), + ConfigSourceApiError, + ); + for ( + const headers of [ + new Headers(), + new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }), + ] + ) { + const fetcher = (() => + Promise.resolve( + new Response( + JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), + { headers }, + ), + )) as typeof fetch; + await assertRejects( + () => fetchConfigTree("w", fetcher), + ConfigSourceApiError, + "too large", + ); + } +}); + Deno.test("config source API rejects unknown response fields", async () => { const fetcher = (() => From 90c74df87979bfe449bfcc767706a541dca577b1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 14:05:45 +0900 Subject: [PATCH 10/11] fix: enable indentation keys in Decodal editor --- .../DecodalSourceEditor.browser.spec.ts | 153 ++++++++++++++++++ .../settings/DecodalSourceEditor.svelte | 7 + 2 files changed, 160 insertions(+) create mode 100644 web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts new file mode 100644 index 000000000..baf7c6a9f --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.browser.spec.ts @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom + +import { + type CompletionResult, + completionStatus, + startCompletion, +} from "@codemirror/autocomplete"; +import { EditorView } from "@codemirror/view"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import DecodalSourceEditor from "./DecodalSourceEditor.svelte"; + +afterEach(cleanup); + +function setup( + value: string, + props: { + readonly?: boolean; + fixedSchemaWrapper?: boolean; + onComplete?: ( + source: string, + offset: number, + explicit: boolean, + ) => Promise; + } = {}, +) { + const onChange = vi.fn(); + const result = render(DecodalSourceEditor, { value, onChange, ...props }); + const content = result.container.querySelector(".cm-content")!; + const editor = EditorView.findFromDOM(content)!; + editor.focus(); + return { ...result, content, editor, onChange }; +} + +test.each([ + { name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" }, + { + name: "object body", + value: "{\n name = 1", + anchor: 12, + expected: "{\n name = 1\n ", + }, + { name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" }, + { + name: "nested object", + value: "{\n nested = {}\n}", + anchor: 14, + expected: "{\n nested = {\n \n }\n}", + }, +])( + "Enter inserts an indented newline in $name", + async ({ value, anchor, expected }) => { + const { content, editor, onChange } = setup(value); + editor.dispatch({ selection: { anchor } }); + expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" })) + .toBe(false); + expect(editor.state.doc.toString()).toBe(expected); + expect(onChange).toHaveBeenLastCalledWith(expected); + }, +); + +test("Enter indents inside the fixed wrapper without altering its assertion", async () => { + const { content, editor } = setup("{} as WorkspaceConfigSchema\n", { + fixedSchemaWrapper: true, + }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe( + "{\n \n} as WorkspaceConfigSchema\n", + ); + expect(editor.state.selection.main.head).toBe(4); +}); + +test("Enter does not mutate readonly sources", async () => { + const value = "{\n name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + editor.dispatch({ selection: { anchor: value.length } }); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Enter adds an indented newline instead of accepting a visible completion", async () => { + const value = "{\n na"; + const { content, editor } = setup(value, { + onComplete: async () => ({ + from: 4, + options: [{ label: "name", type: "property" }], + }), + }); + editor.dispatch({ selection: { anchor: value.length } }); + startCompletion(editor); + await waitFor(() => expect(completionStatus(editor.state)).toBe("active")); + // Wait past CodeMirror's completion interaction delay, so accepting a + // suggestion would be observable rather than ignored as an early keypress. + await new Promise((resolve) => setTimeout(resolve, 100)); + await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }); + expect(editor.state.doc.toString()).toBe("{\n na\n "); + await waitFor(() => expect(completionStatus(editor.state)).toBeNull()); +}); + +test("Tab indents the current line and Shift+Tab removes indentation", async () => { + const { content, editor, onChange } = setup("name = 1"); + editor.dispatch({ selection: { anchor: 4 } }); + expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe( + false, + ); + expect(editor.state.doc.toString()).toBe(" name = 1"); + expect(onChange).toHaveBeenLastCalledWith(" name = 1"); + expect(editor.hasFocus).toBe(true); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe("name = 1"); + expect(onChange).toHaveBeenLastCalledWith("name = 1"); +}); + +test("Tab and Shift+Tab indent every selected line", async () => { + const value = "first = 1\nsecond = 2"; + const { content, editor } = setup(value); + editor.dispatch({ selection: { anchor: 0, head: value.length } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2"); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); +}); + +test("Tab inserts indentation on a blank line", async () => { + const { content, editor } = setup(""); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(" "); +}); + +test("Tab and Shift+Tab do not mutate readonly sources", async () => { + const value = " name = 1"; + const { content, editor, onChange } = setup(value, { readonly: true }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + expect(onChange).not.toHaveBeenCalled(); +}); + +test("Tab indents the schema body without modifying the fixed wrapper", async () => { + const value = "{\n name = 1\n} as WorkspaceConfigSchema\n"; + const { content, editor } = setup(value, { fixedSchemaWrapper: true }); + editor.dispatch({ selection: { anchor: value.indexOf("name") } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe( + "{\n name = 1\n} as WorkspaceConfigSchema\n", + ); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true }); + expect(editor.state.doc.toString()).toBe(value); + editor.dispatch({ selection: { anchor: 0 } }); + await fireEvent.keyDown(content, { key: "Tab", code: "Tab" }); + expect(editor.state.doc.toString()).toBe(value); +}); diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 5fd974cec..7bb39e679 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -9,6 +9,7 @@ type CompletionContext, type CompletionResult, } from '@codemirror/autocomplete'; + import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view'; @@ -129,6 +130,8 @@ decodal({ highlight: false }), syntaxHighlighting(syntaxTheme), autocompletion({ + // Use the explicit keymap below so Enter never accepts a suggestion. + defaultKeymap: false, activateOnTyping: false, override: [ async (context: CompletionContext) => { @@ -139,6 +142,10 @@ ], }), keymap.of(completionKeymapWithoutEnter), + keymap.of([ + { key: 'Enter', run: insertNewlineAndIndent }, + indentWithTab, + ]), fixedSchemaWrapperCompartment.of( initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], ), From b981e60231a1e389bb589f40bc5b9ddb6befe3d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 5 Oct 2026 16:58:35 +0900 Subject: [PATCH 11/11] test(web): document Subject Memory information decisions --- .../ui-ux/subject-memory-information.md | 133 ++++++++++++++++++ .../memory/memory-page.browser.spec.ts | 28 ++++ 2 files changed, 161 insertions(+) create mode 100644 docs/development/ui-ux/subject-memory-information.md diff --git a/docs/development/ui-ux/subject-memory-information.md b/docs/development/ui-ux/subject-memory-information.md new file mode 100644 index 000000000..2a2ff687c --- /dev/null +++ b/docs/development/ui-ux/subject-memory-information.md @@ -0,0 +1,133 @@ +# Subject / Memory information audit (T-692) + +## Decision and scope + +The previous Subjects index exposed `Store revision` alongside identity, state and +update time; Subject detail led with technical metadata and raw surface states. +These numbers require implementation knowledge before they help a user choose a +Subject or understand what is available. The revised hierarchy starts with +identity, connection, committed records and generated-context availability. + +This is a presentation change, not a change to storage, freshness comparisons, +permissions, generation, or Worker history. T-688's ordinary Worker launch and +T-689's independent Subject creation remain the actual operation paths. T-693 +owns editable user-managed behavior: that content must remain distinct from this +page's context generated from committed Memories. A successful read or a current +surface does not attest what is already in any running Worker's history. + +## Field classification and before/after priorities + +| Information | Classification and reason | Presentation decision | +| --- | --- | --- | +| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. | +| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. | +| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. | +| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. | +| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. | +| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. | +| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. | +| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. | +| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. | +| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. | +| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. | +| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. | +| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. | +| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. | +| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. | + +No diagnostic datum was removed from Subject detail, surface detail or Memory +provenance/history. The index no longer shows a store revision, but the exact +value remains one click away in Subject detail. No invented regeneration, +editing or recovery action was added; `Needs refresh` does not mean an update job +is running. Creation still creates only a Subject, and selection still uses the +existing ordinary launch contract. + +## Candidate-only verification + +Candidates are not committed records and are not an input to this page. The +Subject loader performs just three bounded reads: Subject, resident surface and +`/memories?limit=100[&cursor=...]`. It does not read or count staging records. + +The existing real-store regression +`subjektiv::tests::candidate_decisions_reject_conflicts_and_roll_back_partial_writes` +stages a candidate and attempts an invalid apply. It asserts the candidate has no +resolution and the committed Memory list remains empty. The component regression +`candidate-only Subjects show no committed Memories, not an inferred candidate count` +uses that read projection (store revision 0, ungenerated surface, empty successful +committed list) and checks `None`, `No committed Memories yet.`, `Not connected` +and `Not generated`, with no resident body. Thus a staged candidate does not turn +into a committed-Memory count or a claim that context has been generated. + +The store test proves the persistence boundary; the component test proves the +rendering of its public projection. These are separate boundary tests, **not** a +live browser-to-Backend candidate-creation E2E. The existing `ungenerated` capture +represents the same visible projection, but does not itself stage a candidate. + +## Visual review and validation evidence + +The restored implementation is in `fbb09e65` and `4cf6c130`; this report/test +supplement does not change rendered markup, styling, copy or data contracts. + +- Scenarios: `tools/web-ux/scenarios/memory-subjects-{light,dark}.json`. +- Before: `target/web-ux/t-692-before-{light,dark}` at `cc979874`. +- After: `target/web-ux/t-692-final2-{light,dark}` at clean `4cf6c130`. +- Comparisons: `target/web-ux/t-692-final2-{light,dark}-comparison`. +- Browser interactions: `target/web-ux/t-692-browser-interactions`. +- Persona: synthetic Workspace fixture; permitted creation and intentional + permission-rejection responses. No real account or private store data used. +- Viewports: 1440, 768, 390 and 320px; light and dark themes. +- States: connected/unconnected, active/retired, successful empty lists, ready + long Markdown, ready-empty, ungenerated, stale, failed, unavailable HTTP503, + create form and create permission error; expanded technical disclosures. +- The takeover implementer personally inspected individual before/after images + for index at 768px, representative detail at desktop/mobile and Memory detail, + then ready-empty mobile, ungenerated narrow, stale desktop, failed mobile, + endpoint error at 320px, mobile surface sources, compact Subject diagnostics + and mobile permission error. Oversized contact sheets were rejected by the + image provider; individual viewport PNGs were inspected instead. +- Findings/result: primary state groups replace counters without losing IDs or + document access; long IDs wrap, status is textual rather than color-only, + mobile groups stack, diagnostics remain readable, and error state regions stay + separate. No new visual defect identified. **Pass for the audited scope.** +- Final bundles contain 64 captures each. `completed-with-errors` is expected: + intentional creation HTTP403, endpoint HTTP503/associated aborted requests, + and the deliberately failed surface's alert are retained as evidence. Healthy + capture states have empty error arrays; this is not a blanket claim that all + captures were error-free. Review-context diagnostics are empty. +- The focused production-shell browser test proves main-owned vertical scrolling + with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow, + local table/code scroll, Markdown safety, creation/launch separation, bounded + pagination and Memory/source/revision reachability. + +Independent review at source `4cf6c130` ran root `cargo check`, +`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12 +Memory API tests, 452 full Web unit tests, 134 component tests (8 Memory tests), +production build, the focused production-shell browser test (1), and Web-UX unit +tests (16), all passing. + +The takeover implementer then validated this report/test supplement with: + +- `cargo test -p yoi-workspace-server --lib candidate_decisions_reject_conflicts_and_roll_back_partial_writes`: 1 passed. +- Root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`: passed. +- Targeted Memory component tests: 9 passed, including the added candidate-only projection test. +- Web `deno task check`: 0 errors/warnings; `deno task test`: 452 passed; + `deno task test:component`: 135 passed. +- Scoped `deno fmt --check` on the changed TypeScript test: passed. +- JSON audit of both final2 bundles: 64 captures each, exact clean source + `4cf6c130`, and no errors outside the deliberate permission/error/failed routes. + +The supplement changes only a document and a test, so it does not require new +rendered-UI captures. Rereview records and the final approved Ticket handoff link +this report to the new exact source. + +Known non-blocking validation diagnostics: component runs emitted incidental +localhost:3000 connection-refused messages while passing; the production build +warned about a large chunk. A broader Web-UX typecheck fails in unchanged +`tools/web-ux/src/processes.ts:44` on a `TextDecoderStream` generic incompatibility; +this report does not claim that broad check passed. + +No live-Backend browser interactions, real Worker model-request propagation, +regeneration, or server/runtime process E2E were performed. No Rust production +crate changed in T-692; the supplemental store test exercises an existing +contract without changing that contract. Backend counters, comparison logic, +storage schema and feature behavior remain intact. diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts index f1b9bb906..c8188d9c8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -292,6 +292,34 @@ test("renders a ready resident surface and committed Memory lifecycle states", a .toContain("cursor=memory-next"); }); +test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => { + // The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes + // stages a candidate without committing a Memory. Its public read projection is + // the same empty committed list as a new Subject; candidates are not a page input. + render(SubjectPage, { + data: { + workspaceId: "workspace-1", + subjectId: "subject-1", + cursor: null, + subject: result({ ...subject(), store_revision: 0 }), + surface: result({ subject_id: "subject-1", availability: "ungenerated" }), + memories: result({ limit: 100, items: [], has_more: false }), + }, + } as never); + + expect(screen.getByText("None", { exact: true })).not.toBeNull(); + expect(screen.getByText("No committed Memories yet.")).not.toBeNull(); + expect(screen.getByText("Not connected", { exact: true })).not.toBeNull(); + expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2); + expect(screen.queryByRole("article", { name: "Resident context" })) + .toBeNull(); + expect( + screen.queryByText( + /candidate count|all Memories lost|automatically updating/i, + ), + ).toBeNull(); +}); + test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => { const base = { workspaceId: "workspace-1",