web: expose memory staging view

This commit is contained in:
2026-07-24 14:10:52 +09:00
parent b9067a8110
commit b8cc58ac29
9 changed files with 631 additions and 0 deletions
@@ -56,6 +56,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
assert(
!sidebar.includes("CompanionNavSection") &&
sidebar.includes("TicketsNavSection") &&
sidebar.includes("MemoryNavSection") &&
sidebar.includes("WorkersNavSection"),
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
);
@@ -109,6 +110,32 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
);
});
Deno.test("workspace Memory Staging surface uses read-only scoped memory API", async () => {
const memoryNav = await Deno.readTextFile(
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
);
const memoryLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.ts", import.meta.url),
);
const memoryPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/memory/staging/+page.svelte", import.meta.url),
);
assert(
memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") &&
memoryNav.includes("pending extraction candidates"),
"Memory sidebar section should link to the workspace Memory Staging surface",
);
assert(
memoryLoad.includes("workspaceApiPath(params.workspaceId, '/memory/staging')") &&
memoryPage.includes("Memory Staging") &&
memoryPage.includes("Workspace Server memory authority") &&
memoryPage.includes("data.staging.data.invalid_count") &&
memoryPage.includes("entry.record.evidence"),
"Memory Staging page should read the scoped API and expose staged records without mutation controls",
);
});
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
const layoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
@@ -0,0 +1,22 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory/staging') : '/');
</script>
<section class="nav-section">
<header class="section-header">
<span>Memory</span>
</header>
<a class="objective-link" class:active={currentPath.startsWith(stagingHref)} href={stagingHref}>
<span class="item-title">Staging</span>
<span class="item-meta">pending extraction candidates</span>
</a>
</section>
@@ -2,6 +2,7 @@
import { workspaceRoute } from '$lib/workspace/api/http';
import './sidebar.css';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
@@ -78,6 +79,7 @@
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<MemoryNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
@@ -318,6 +318,67 @@ export type RepositoryLogResponse = {
diagnostics: Diagnostic[];
};
export type MemoryCandidateKind =
| 'preference'
| 'working_assumption'
| 'constraint'
| 'decision'
| 'open_question'
| 'lesson';
export type MemorySourceRef = {
segment_id: string;
range: [number, number];
};
export type MemoryStagingEvidence = {
id: string;
kind: string;
entry_range?: [number, number] | null;
excerpt?: string | null;
summary?: string | null;
};
export type MemorySourceEvidenceRef = {
session_id?: string | null;
segment_id?: string | null;
entry_range?: [number, number] | null;
evidence_id?: string | null;
evidence_kind?: string | null;
label?: string | null;
summary?: string | null;
};
export type MemoryStagingRecord = {
schema_version: number;
id: string;
extract_run_id: string;
source: MemorySourceRef;
kind: MemoryCandidateKind;
claim: string;
why_useful: string;
staleness?: string | null;
evidence?: MemoryStagingEvidence[];
source_refs?: MemorySourceEvidenceRef[];
};
export type MemoryStagingEntry = {
id: string;
byte_len: number;
record: MemoryStagingRecord;
};
export type MemoryStagingListResponse = {
limit: number;
returned_count: number;
total_valid_count: number;
invalid_count: number;
truncated: boolean;
order: string;
record_authority: string;
items: MemoryStagingEntry[];
};
export type TicketSummary = {
id: string;
title: string;
@@ -0,0 +1,298 @@
<script lang="ts">
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const entries = $derived(data.staging.data?.items ?? []);
function kindLabel(kind: string): string {
return kind.replaceAll('_', ' ');
}
function sourceLabel(record: MemoryStagingRecord): string {
const source = record.source;
return `${source.segment_id}:${source.range[0]}-${source.range[1]}`;
}
function evidenceCount(entry: MemoryStagingEntry): number {
return entry.record.evidence?.length ?? 0;
}
function sourceRefCount(entry: MemoryStagingEntry): number {
return entry.record.source_refs?.length ?? 0;
}
</script>
<svelte:head>
<title>Memory Staging · Yoi Workspace</title>
<meta name="description" content="Workspace Memory Staging" />
</svelte:head>
<section class="card memory-staging-card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace memory</p>
<h2>Memory Staging</h2>
</div>
{#if data.staging.data}
<span>{data.staging.data.returned_count} / {data.staging.data.total_valid_count} staged</span>
{/if}
</div>
<p class="section-note">
Pending Memory extraction candidates staged for consolidation. This view is read-only and uses the Workspace Server memory authority.
</p>
{#if data.staging.data}
<div class="staging-summary-grid" aria-label="Memory staging summary">
<div>
<span>Valid records</span>
<strong>{data.staging.data.total_valid_count}</strong>
</div>
<div>
<span>Invalid records</span>
<strong>{data.staging.data.invalid_count}</strong>
</div>
<div>
<span>Order</span>
<strong>{data.staging.data.order}</strong>
</div>
<div>
<span>Authority</span>
<strong>{data.staging.data.record_authority}</strong>
</div>
</div>
{#if data.staging.data.truncated}
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
{/if}
{#if entries.length === 0}
<p>No Memory Staging records are present.</p>
{:else}
<div class="staging-list">
{#each entries as entry (entry.id)}
<article class="staging-entry">
<header>
<div>
<span class="kind-pill">{kindLabel(entry.record.kind)}</span>
<h3>{entry.record.claim}</h3>
</div>
<code>{entry.id}</code>
</header>
<p>{entry.record.why_useful}</p>
{#if entry.record.staleness}
<p class="staleness">Staleness: {entry.record.staleness}</p>
{/if}
<dl class="staging-meta">
<div>
<dt>Extract run</dt>
<dd>{entry.record.extract_run_id}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{sourceLabel(entry.record)}</dd>
</div>
<div>
<dt>Evidence</dt>
<dd>{evidenceCount(entry)}</dd>
</div>
<div>
<dt>Source refs</dt>
<dd>{sourceRefCount(entry)}</dd>
</div>
<div>
<dt>Bytes</dt>
<dd>{entry.byte_len}</dd>
</div>
</dl>
{#if entry.record.evidence && entry.record.evidence.length > 0}
<details>
<summary>Evidence</summary>
<ul class="evidence-list">
{#each entry.record.evidence as evidence (evidence.id)}
<li>
<strong>{evidence.id}</strong>
<span>{evidence.kind}</span>
{#if evidence.summary}
<p>{evidence.summary}</p>
{/if}
{#if evidence.excerpt}
<blockquote>{evidence.excerpt}</blockquote>
{/if}
</li>
{/each}
</ul>
</details>
{/if}
</article>
{/each}
</div>
{/if}
{:else if data.staging.error}
<p class="error">{data.staging.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/memory/staging</code></p>
{/if}
</section>
<style>
.memory-staging-card {
overflow: hidden;
}
.staging-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.staging-summary-grid div {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
padding: 0.8rem;
}
.staging-summary-grid span,
.staging-meta dt {
color: var(--text-muted);
display: block;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.staging-summary-grid strong {
color: var(--text);
display: block;
font-size: 0.95rem;
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.staging-list {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
}
.staging-entry {
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--bg-raised);
padding: 1rem;
}
.staging-entry header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.staging-entry h3 {
color: var(--text-strong);
font-size: 1rem;
margin: 0.35rem 0 0;
}
.staging-entry p {
color: var(--text);
margin: 0.75rem 0 0;
}
.staging-entry code,
.staging-meta dd {
overflow-wrap: anywhere;
}
.kind-pill {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--accent);
display: inline-flex;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.18rem 0.55rem;
text-transform: uppercase;
}
.staleness {
color: var(--warning);
}
.staging-meta {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0 0;
}
.staging-meta div {
min-width: 0;
}
.staging-meta dd {
color: var(--text);
margin: 0.25rem 0 0;
}
details {
border-top: 1px solid var(--line);
margin-top: 1rem;
padding-top: 0.75rem;
}
summary {
color: var(--text-muted);
cursor: pointer;
font-weight: 700;
}
.evidence-list {
display: grid;
gap: 0.75rem;
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.evidence-list li {
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--bg-subtle);
padding: 0.75rem;
}
.evidence-list span {
color: var(--text-muted);
margin-left: 0.5rem;
}
blockquote {
border-left: 3px solid var(--line-strong);
color: var(--text-muted);
margin: 0.75rem 0 0;
padding-left: 0.75rem;
}
@media (max-width: 900px) {
.staging-summary-grid,
.staging-meta {
grid-template-columns: 1fr;
}
.staging-entry header {
display: grid;
}
}
</style>
@@ -0,0 +1,13 @@
import { loadJson, workspaceApiPath } from '$lib/workspace/api/http';
import type { MemoryStagingListResponse } from '$lib/workspace/sidebar/types';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId: params.workspaceId,
staging: await loadJson<MemoryStagingListResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, '/memory/staging')}?limit=200`,
),
};
};