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/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 6194e31f5..10acf6efa 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 +330,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 +569,7 @@ } @media (max-width: 900px) { - .subject-facts { + .subject-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -472,10 +586,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..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 @@ -73,6 +73,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")) @@ -214,7 +217,10 @@ 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, @@ -267,9 +273,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(); @@ -280,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", @@ -303,8 +343,25 @@ 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(/current empty surface exists/)).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 +374,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 +391,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 +400,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",