chore: integrate Subject Memory information improvements

This commit is contained in:
2026-10-05 17:08:32 +09:00
11 changed files with 626 additions and 108 deletions
@@ -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.
@@ -66,7 +66,13 @@ ${"detail-wide-code-".repeat(18)}
<script>alert("not rendered")</script>`; <script>alert("not rendered")</script>`;
const subjects = [ 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(emptySubjectId, "Empty ready subject", "active", 0),
subject(staleSubjectId, "Stale surface subject", "active", 19), subject(staleSubjectId, "Stale surface subject", "active", 19),
subject(failedSubjectId, "Failed surface subject", "active", 8), subject(failedSubjectId, "Failed surface subject", "active", 8),
@@ -75,7 +81,37 @@ const subjects = [
]; ];
const pagedSubject = subject(pagedSubjectId, "Subject on the next page", "active", 1); 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<typeof connectedWorker>,
) {
return { return {
id, id,
role, role,
@@ -83,6 +119,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev
store_revision: storeRevision, store_revision: storeRevision,
created_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z", updated_at: "2026-01-02T03:04:05Z",
...(currentWorker ? { current_worker: currentWorker } : {}),
}; };
} }
@@ -125,7 +162,7 @@ function memoriesFor(subjectId: string) {
1, 1,
"working_assumption", "working_assumption",
"retracted", "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", evidence_id: "evidence-message-0001",
origin: { kind: "human_input", account_id: "account-fixture" }, origin: { kind: "human_input", account_id: "account-fixture" },
evidence_kind: "message", evidence_kind: "message",
label: "T-672 product direction", label: "Memory information design source",
summary: "Bounded source reference retained with the committed revision.", summary: "Bounded source reference retained with the committed revision.",
}], }],
source_refs_total: 1, source_refs_total: 1,
@@ -16,7 +16,7 @@ const memoryId =
const subjectPageCursor = "fixture-subject-page-2"; const subjectPageCursor = "fixture-subject-page-2";
const createInteractionArtifacts = join( const createInteractionArtifacts = join(
repositoryRoot, repositoryRoot,
"target/web-ux/t-689-browser-interactions", "target/web-ux/t-692-browser-interactions",
); );
async function freePort(): Promise<number> { async function freePort(): Promise<number> {
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
const document = (globalThis as any).document; const document = (globalThis as any).document;
const main = document.querySelector("main"); const main = document.querySelector("main");
const marker = [...document.querySelectorAll("h3")].find((heading) => 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; if (!main || !marker) return false;
const mainRect = main.getBoundingClientRect(); 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="https://example.com"]').count(), 1);
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
assertEquals( assertEquals(
await page.getByRole("article", { name: "Resident surface" }).locator("img, script") await page.getByRole("article", { name: "Resident context" }).locator("img, script")
.count(), .count(),
0, 0,
); );
@@ -282,7 +282,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await page.locator("main").hover(); await page.locator("main").hover();
await page.mouse.wheel(0, 100_000); await page.mouse.wheel(0, 100_000);
} else if (scenario.label === "keyboard") { } 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"); await page.keyboard.press("End");
} else { } else {
await swipeMainToEnd(page); await swipeMainToEnd(page);
@@ -313,17 +313,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
level: 1, level: 1,
}).waitFor(); }).waitFor();
assertEquals(await detailPage.getByText("candidate-release-decision-0001").count(), 1); 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.getByText("memory-derived-source-0002").count(), 1);
assertEquals(await detailPage.getByRole("heading", { name: "Revision history" }).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("Memory revision 3", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1); assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0); assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
"link", "link",
{ name: "Next page →" }, { name: "Next page →" },
).click(); ).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")); assert(new URL(detailPage.url()).searchParams.has("revision_cursor"));
assertEquals( assertEquals(
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole( 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(); const paginationPage = await paginationContext.newPage();
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`); await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
await paginationPage.getByRole("heading", { name: "Subjects", level: 1 }).waitFor(); 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); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
"link", "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(), await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1, 1,
); );
const currentWorker = createPage.locator(".subject-facts div").filter({ const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
hasText: "Current worker", "..",
}); );
assert((await currentWorker.textContent())?.includes("None")); 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, [ assertEquals(mutationPaths, [
`/api/w/${workspaceId}/subjektiv/subjects`, `/api/w/${workspaceId}/subjektiv/subjects`,
`/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 = [ const stateCases = [
{ {
id: "empty-subject", 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, role: "status" as const,
}, },
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
{ {
id: "failed-subject", id: "failed-subject",
text: "Resident surface generation failed.", text: "Resident context generation failed.",
role: "alert" as const, role: "alert" as const,
}, },
{ {
id: "ungenerated-subject", id: "ungenerated-subject",
text: "Resident surface has not been generated.", text: "Resident context has not been generated.",
role: "status" as const, 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) { for (const stateCase of stateCases) {
const page = await stateContext.newPage(); 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 }); const state = page.getByRole(stateCase.role).filter({ hasText: stateCase.text });
await state.waitFor(); await state.waitFor();
assert((await state.textContent())?.includes(stateCase.text)); 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") { 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 assertNoPageWideOverflow(page);
await page.close(); await page.close();
@@ -122,9 +122,33 @@
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
"ready": { "ready": {
"kind": "selector", "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", "id": "detail",
@@ -150,6 +174,18 @@
}, },
"capturePoints": [{ "id": "initial", "label": "Ready empty" }] "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", "id": "stale",
"label": "Stale subject surface", "label": "Stale subject surface",
@@ -122,9 +122,33 @@
"dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.", "dataState": "Ready generated Markdown surface with long content, unsafe Markdown, wide table/code, provenance refs, and three lifecycle states.",
"ready": { "ready": {
"kind": "selector", "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", "id": "detail",
@@ -150,6 +174,18 @@
}, },
"capturePoints": [{ "id": "initial", "label": "Ready empty" }] "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", "id": "stale",
"label": "Stale subject surface", "label": "Stale subject surface",
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
subjectIndexLoad.includes('"/subjektiv/subjects"') && subjectIndexLoad.includes('"/subjektiv/subjects"') &&
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") && subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
subjectIndexPage.includes(">Subjects</h1>") && subjectIndexPage.includes(">Subjects</h1>") &&
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"'), subjectIndexPage.includes('data-memory-view="subjects"'),
"Memory product entry should list explicit typed 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}/surface`") &&
subjectPageLoad.includes("`${subjectPath}/memories`") && subjectPageLoad.includes("`${subjectPath}/memories`") &&
subjectPage.includes("DocumentMarkdown from") && subjectPage.includes("DocumentMarkdown from") &&
subjectPage.includes("Resident surface") && subjectPage.includes("Resident context") &&
subjectPage.includes("Subject store revision") &&
subjectPage.includes("Current Memories") && subjectPage.includes("Current Memories") &&
subjectPage.includes("surface?.availability === 'ready'") && subjectPage.includes("surface?.availability === 'ready'") &&
subjectPage.includes("surface?.availability === 'ungenerated'") &&
subjectPage.includes("surface?.availability === 'stale'") && subjectPage.includes("surface?.availability === 'stale'") &&
subjectPage.includes("surface?.availability === 'failed'") &&
subjectPage.includes("data-surface-ready-empty") && subjectPage.includes("data-surface-ready-empty") &&
!subjectPage.includes("overflow-y"), !subjectPage.includes("overflow-y"),
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller", "Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
@@ -31,6 +31,10 @@
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path; return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
} }
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> { async function openCreateForm(): Promise<void> {
createExpanded = true; createExpanded = true;
createError = null; createError = null;
@@ -109,7 +113,7 @@
</div> </div>
<div class="memory-header-actions"> <div class="memory-header-actions">
{#if data.subjects.data} {#if data.subjects.data}
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span> <span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
{/if} {/if}
<button <button
bind:this={createButton} bind:this={createButton}
@@ -184,14 +188,19 @@
{#each subjects as subject (subject.id)} {#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}> <a class="subject-row" href={subjectHref(subject.id)}>
<div class="subject-copy"> <div class="subject-copy">
<span class="memory-state-pill is-{subject.state}">{subject.state}</span> <span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
<h2>{subject.role}</h2> <h2>{subject.role}</h2>
<code title={subject.id}>{subject.id}</code> <code title={subject.id}>{subject.id}</code>
</div> </div>
<dl class="subject-meta"> <dl class="subject-meta">
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div> <div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div> <dt>Worker connection</dt>
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div> <dd>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
</dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl> </dl>
</a> </a>
{/each} {/each}
@@ -426,8 +435,8 @@
.subject-meta { .subject-meta {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3); gap: var(--space-4);
align-self: center; align-self: center;
} }
@@ -441,12 +450,19 @@
} }
.subject-meta dd { .subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1); margin-top: var(--space-1);
color: var(--text-muted); color: var(--text-muted);
font-size: var(--font-size-compact); font-size: var(--font-size-compact);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state { .memory-state {
padding: var(--space-5) 0; padding: var(--space-5) 0;
color: var(--text-muted); color: var(--text-muted);
@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte'; import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts'; import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts'; import type {
SubjektivMemoryState,
SubjektivResidentSurfaceAvailability,
} from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -36,6 +39,24 @@
function kindLabel(kind: string): string { function kindLabel(kind: string): string {
return kind.replaceAll('_', ' '); return kind.replaceAll('_', ' ');
} }
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
if (availability === 'ungenerated') return 'Not generated';
if (availability === 'stale') return 'Needs refresh';
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';
}
</script> </script>
<svelte:head> <svelte:head>
@@ -56,13 +77,50 @@
</header> </header>
{#if data.subject.data} {#if data.subject.data}
<dl class="subject-facts" aria-label="Subject details"> <section class="subject-overview" aria-label="Subject status">
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div> <div>
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div> <span>Subject status</span>
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div> <strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong>
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div> <p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div> </div>
<div>
<span>Worker connection</span>
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
{#if data.memories.data}
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
{:else}
<strong>Unavailable</strong>
<p>The current Memory list could not be read.</p>
{/if}
</div>
<div>
<span>Resident context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
{:else}
<strong>Unavailable</strong>
<p>The resident context status could not be read.</p>
{/if}
</div>
</section>
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{data.subject.data.id}</code></dd></div>
<div>
<dt>Subject store revision</dt>
<dd>{data.subject.data.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
</dl> </dl>
</details>
{:else if data.subject.error} {:else if data.subject.error}
<div class="memory-state is-error" role="alert"> <div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong> <strong>Subject details unavailable.</strong>
@@ -75,38 +133,40 @@
<section class="surface-section" aria-labelledby="resident-surface-heading"> <section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading"> <header class="section-heading">
<div> <div>
<p class="memory-eyebrow">Resident context</p> <p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident surface</h2> <h2 id="resident-surface-heading">Resident context</h2>
</div> </div>
{#if surface} {#if surface}
<span class="availability is-{surface.availability}">{surface.availability}</span> <span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if} {/if}
</header> </header>
{#if surface?.availability === 'ready' && snapshot} {#if surface?.availability === 'ready' && snapshot}
<div class="surface-meta"> <div class="surface-meta">
<span>Built from store revision {snapshot.built_from_store_revision}</span> <span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
<span aria-hidden="true">·</span> <span aria-hidden="true">·</span>
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time> <span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
</div> </div>
{#if snapshot.body_md.trim().length === 0} {#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty> <div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident surface is ready and empty.</strong> <strong>Resident context is current but empty.</strong>
<p>This subject currently has no resident context to display.</p> <p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div> </div>
{:else} {:else}
<article class="surface-document" aria-label="Resident surface"> <article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} /> <DocumentMarkdown text={snapshot.body_md} />
</article> </article>
{/if} {/if}
<details class="surface-details"> <details class="surface-details">
<summary>Surface provenance</summary> <summary>Surface sources and diagnostics</summary>
<dl> <dl>
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div> <div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
<div> <div>
<dt>Memory refs</dt> <dt>Generated from Subject revision</dt>
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
</div>
<div>
<dt>Referenced Memory revisions</dt>
<dd> <dd>
{#if snapshot.memory_refs.length === 0} {#if snapshot.memory_refs.length === 0}
None None
@@ -123,26 +183,26 @@
</details> </details>
{:else if surface?.availability === 'ungenerated'} {:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status"> <div class="memory-state" role="status">
<strong>Resident surface has not been generated.</strong> <strong>Resident context has not been generated.</strong>
<p>Committed Memories remain available below.</p> <p>Committed Memories, if any, remain available below.</p>
</div> </div>
{:else if surface?.availability === 'stale'} {:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status"> <div class="memory-state is-warning" role="status">
<strong>Resident surface is stale.</strong> <strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a publishable snapshot.</p> <p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div> </div>
{:else if surface?.availability === 'failed'} {:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert"> <div class="memory-state is-error" role="alert">
<strong>Resident surface generation failed.</strong> <strong>Resident context generation failed.</strong>
<p>No failed or partial snapshot is displayed.</p> <p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div> </div>
{:else if data.surface.error} {:else if data.surface.error}
<div class="memory-state is-error" role="alert"> <div class="memory-state is-error" role="alert">
<strong>Resident surface unavailable.</strong> <strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p> <p>{data.surface.error}</p>
</div> </div>
{:else} {:else}
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div> <div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
{/if} {/if}
</section> </section>
@@ -152,14 +212,14 @@
<p class="memory-eyebrow">Committed records</p> <p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2> <h2 id="current-memories-heading">Current Memories</h2>
</div> </div>
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if} {#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
</header> </header>
{#if data.memories.data} {#if data.memories.data}
{#if memories.length === 0} {#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty> <div class="memory-state" role="status" data-memories-empty>
<strong>No committed Memories.</strong> <strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<p>This subject has no current Memory revisions.</p> <p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if} {#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
</div> </div>
{:else} {:else}
@@ -175,9 +235,9 @@
<p>{memory.excerpt || 'No excerpt.'}</p> <p>{memory.excerpt || 'No excerpt.'}</p>
</div> </div>
<dl> <dl>
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div> <div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Revision</dt><dd>{memory.revision}</dd></div> <div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div> <div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl> </dl>
</a> </a>
{/each} {/each}
@@ -270,25 +330,79 @@
font-size: var(--font-size-compact); font-size: var(--font-size-compact);
} }
.subject-facts { .subject-overview {
grid-template-columns: repeat(5, minmax(0, 1fr)); display: grid;
gap: var(--space-3); 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 { .subject-overview > div {
display: block; display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
} }
.subject-facts dt { .subject-overview span,
white-space: normal; .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 { .subject-overview strong {
margin-top: var(--space-1); color: var(--text-strong);
color: var(--text);
overflow-wrap: anywhere; 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, .subject-state,
.availability, .availability,
.memory-pill, .memory-pill,
@@ -455,7 +569,7 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.subject-facts { .subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -472,10 +586,16 @@
gap: var(--space-2); gap: var(--space-2);
} }
.subject-facts { .subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.subject-overview {
padding: var(--space-3);
}
.memory-row { .memory-row {
padding-inline: 0; padding-inline: 0;
} }
@@ -63,6 +63,11 @@
return value.replaceAll('_', ' '); 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 { function rangeLabel(range: [number, number] | null | undefined): string {
return range ? `${range[0]}–${range[1]}` : 'None'; return range ? `${range[0]}–${range[1]}` : 'None';
} }
@@ -98,8 +103,8 @@
<header class="memory-page-header"> <header class="memory-page-header">
<div> <div>
<div class="pill-row"> <div class="pill-row">
<span class="memory-pill is-{memory.state}">{memory.state}</span> <span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{label(memory.kind)}</span> <span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if} {#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
</div> </div>
<h1 id="memory-detail-heading">{memory.claim}</h1> <h1 id="memory-detail-heading">{memory.claim}</h1>
@@ -109,11 +114,11 @@
</header> </header>
<dl class="memory-facts" aria-label="Memory details"> <dl class="memory-facts" aria-label="Memory details">
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div> <div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>State</dt><dd>{memory.state}</dd></div> <div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div> <div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div> <div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div> <div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl> </dl>
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading"> <section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
@@ -140,7 +145,7 @@
<section class="detail-section provenance-section" aria-labelledby="provenance-heading"> <section class="detail-section provenance-section" aria-labelledby="provenance-heading">
<header class="section-heading"> <header class="section-heading">
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div> <div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span> <span>{memory.source_candidates.length} shown</span>
</header> </header>
{#if memory.source_candidates.length === 0} {#if memory.source_candidates.length === 0}
@@ -226,7 +231,11 @@
</section> </section>
<section class="detail-section revision-section" aria-labelledby="revision-heading"> <section class="detail-section revision-section" aria-labelledby="revision-heading">
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header> <header class="section-heading">
<div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>
{#if data.revisions.data}<span>{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
</header>
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
{#if data.revisions.data} {#if data.revisions.data}
{#if revisions.length === 0} {#if revisions.length === 0}
<div> <div>
@@ -238,7 +247,7 @@
{#each revisions as revision (revision.revision)} {#each revisions as revision (revision.revision)}
<li class:current={revision.revision === memory.revision}> <li class:current={revision.revision === memory.revision}>
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}> <a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div> <div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
<p>{revision.claim}</p> <p>{revision.claim}</p>
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small> <small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
</a> </a>
@@ -386,6 +395,7 @@
} }
.memory-facts { .memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3); gap: var(--space-3);
} }
@@ -414,6 +424,7 @@
.why-useful, .why-useful,
.staleness, .staleness,
.change-reason, .change-reason,
.section-intro,
.empty-copy, .empty-copy,
.bounded-note { .bounded-note {
margin: 0; margin: 0;
@@ -73,6 +73,9 @@ test("renders the subject index as the Memory product entry", () => {
expect(screen.getByRole("link", { name: /Release coordinator/ })).not expect(screen.getByRole("link", { name: /Release coordinator/ })).not
.toBeNull(); .toBeNull();
expect(screen.getByText("subject-1")).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")) expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
.toBe("/w/workspace-1/memory"); .toBe("/w/workspace-1/memory");
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href")) 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", workspaceId: "workspace-1",
subjectId: "subject-1", subjectId: "subject-1",
cursor: "memory-current", cursor: "memory-current",
subject: result(subject()), subject: result({
...subject(),
current_worker: { display_name: "Release Worker" },
}),
surface: result({ surface: result({
subject_id: "subject-1", subject_id: "subject-1",
availability: "ready" as const, 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 })) expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
.not.toBeNull(); .not.toBeNull();
}); });
expect(screen.getByRole("article", { name: "Resident surface" })).not expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull(); .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, 0,
); );
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull(); 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"); .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 () => { test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = { const base = {
workspaceId: "workspace-1", workspaceId: "workspace-1",
@@ -303,8 +343,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
}), }),
}, },
} as never); } as never);
expect(screen.getByText("Resident surface is ready and empty.")).not expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull(); .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( await view.rerender(
{ {
@@ -317,7 +374,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
}, },
} as never, } 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( 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"); expect(screen.getByRole("alert").textContent).toContain("generation failed");
await view.rerender({ data: { ...base, surface: result(null) } } as never); 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(); .toBeNull();
await view.rerender( await view.rerender(
@@ -342,7 +400,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
} as never, } as never,
); );
expect(screen.getByRole("alert").textContent).toContain( expect(screen.getByRole("alert").textContent).toContain(
"Resident surface unavailable", "Resident context status unavailable",
); );
}); });
+50 -9
View File
@@ -61,9 +61,7 @@ async function assertSubjectCreateRejects(
assertEquals(error.status, expectedStatus); assertEquals(error.status, expectedStatus);
if (!error.message.includes(expectedMessage)) { if (!error.message.includes(expectedMessage)) {
throw new Error( throw new Error(
`expected ${JSON.stringify(error.message)} to include ${ `expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
JSON.stringify(expectedMessage)
}`,
); );
} }
return; 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") { function queryItem(id = "memory-1") {
return { return {
id, id,
@@ -187,14 +209,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
() => () =>
parseSubjektivSubjectListResponse({ parseSubjektivSubjectListResponse({
limit: 100, limit: 100,
items: Array.from({ length: 101 }, (_, index) => items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
subject(`subject-${index}`)),
has_more: false, has_more: false,
}), }),
"bounded array", "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", () => { Deno.test("Subject list parser preserves the exact bounded response", () => {
const response = { limit: 100, items: [subject()], has_more: false }; const response = { limit: 100, items: [subject()], has_more: false };
assertEquals(parseSubjektivSubjectListResponse(response), response); assertEquals(parseSubjektivSubjectListResponse(response), response);
@@ -328,8 +371,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
throw new TypeError("connection reset"); throw new TypeError("connection reset");
}) as typeof fetch; }) as typeof fetch;
await assertSubjectCreateRejects( await assertSubjectCreateRejects(
() => () => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
"unknown_outcome", "unknown_outcome",
"may have been created", "may have been created",
); );
@@ -433,8 +475,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
assertThrows( assertThrows(
() => () =>
parseSubjektivMemoryQueryResponse({ parseSubjektivMemoryQueryResponse({
items: Array.from({ length: 101 }, (_, index) => items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
queryItem(`memory-${index}`)),
has_more: false, has_more: false,
}), }),
"bounded array", "bounded array",