feat(web): redesign Memory document reader

This commit is contained in:
2026-09-30 07:48:28 +09:00
parent cafbb73a22
commit a0944f66c8
14 changed files with 875 additions and 105 deletions
@@ -0,0 +1,149 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
const sectionCount = Deno.args[2] === undefined ? 90 : Number(Deno.args[2]);
if (
!Number.isInteger(port) || !buildRoot || !Number.isInteger(sectionCount) || sectionCount < 3 ||
sectionCount > 100
) {
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
}
const workspaceId = "memory-review";
const paragraphs = Array.from(
{ length: sectionCount },
(_, index) =>
`## Section ${
index + 1
}\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${
index + 1
}\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${
index + 1
}.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`,
).join("\n\n");
const bodyMd =
`# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
"wide-value-".repeat(16)
} | stable | keep scrolling local |\n\n\`\`\`rust\nfn example() { println!("${
"wide-code-".repeat(18)
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
async function staticResponse(pathname: string): Promise<Response> {
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const normalized = normalize(relative);
if (normalized.startsWith("..")) return new Response("not found", { status: 404 });
let filePath = join(buildRoot, normalized);
try {
const stat = await Deno.stat(filePath);
if (stat.isDirectory) filePath = join(filePath, "index.html");
return new Response(await Deno.readFile(filePath), {
headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" },
});
} catch {
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/workspaces") {
return json([{
workspace_id: workspaceId,
owner_account_id: "owner",
display_name: "Memory Review",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
}]);
}
if (url.pathname === `/api/w/${workspaceId}/workspace`) {
return json({
workspace_id: workspaceId,
display_name: "Memory Review",
record_authority: "fixture",
schema_version: 1,
auth: {
Passkey: {
rp_id: "127.0.0.1",
origin: `http://127.0.0.1:${port}`,
public_base_url: `http://127.0.0.1:${port}`,
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: false,
manage_secrets: false,
manage_runtimes: false,
delete_workspace: false,
},
extension_points: {
store: "fixture",
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
},
});
}
if (url.pathname === `/api/w/${workspaceId}/repositories`) {
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
if (url.pathname === `/api/w/${workspaceId}/memory`) {
return json({
body_md: bodyMd,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: new TextEncoder().encode(bodyMd).byteLength,
record_source: "sqlite_workspace_authority.memory_document",
});
}
if (
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method !== "subscribe_events") return;
const requestId = frame.message.params.request_id;
socket.send(JSON.stringify({
protocol_version: 1,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: requestId,
subscription_id: "fixture-workers",
selector: { topic: "workspace_workers" },
snapshot_revision: 1,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
};
return response;
}
return await staticResponse(url.pathname);
});
@@ -0,0 +1,236 @@
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const workspaceRoot = join(repositoryRoot, "web/workspace");
const fixtureServer = join(
repositoryRoot,
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
);
const workspaceId = "memory-review";
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Retry until the bounded deadline while the owned server starts.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`fixture server did not become ready: ${url}`);
}
async function mainScrollState(page: Page) {
return await page.locator("main").evaluate((main) => ({
scrollTop: main.scrollTop,
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
}));
}
async function markerIsInsideMain(page: Page): Promise<boolean> {
return await page.evaluate(() => {
const document = (globalThis as any).document;
const main = document.querySelector("main");
const marker = [...document.querySelectorAll("h2")].find((heading) =>
heading.textContent === "T-661 UNIQUE END MARKER"
);
if (!main || !marker) return false;
const mainRect = main.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
return markerRect.top >= mainRect.top && markerRect.bottom <= mainRect.bottom;
});
}
async function openMemoryPage(
page: Page,
baseUrl: string,
mobile = false,
): Promise<void> {
await page.goto(`${baseUrl}/w/${workspaceId}/memory`);
const heading = page.locator("h1", { hasText: "Workspace Memory" });
await heading.waitFor({ state: "attached" });
if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click();
await heading.waitFor();
}
async function swipeMainToEnd(page: Page): Promise<void> {
const session = await page.context().newCDPSession(page);
for (let attempt = 0; attempt < 100; attempt += 1) {
const state = await mainScrollState(page);
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return;
await session.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 300, y: 700, id: 1 }],
});
for (const y of [580, 460, 340, 220, 100]) {
await session.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 300, y, id: 1 }],
});
}
await session.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(20);
}
throw new Error("touch input did not reach the end of the Memory document");
}
Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: workspaceRoot,
stdout: "piped",
stderr: "piped",
}).output();
if (!build.success) {
throw new Error(
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
);
}
const port = await freePort();
const baseUrl = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
fixtureServer,
String(port),
join(workspaceRoot, "build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
await waitForServer(`${baseUrl}/health`);
const browser = await chromium.launch({ headless: true });
try {
for (
const scenario of [
{ label: "wheel", width: 1440, height: 900, colorScheme: "light" as const },
{ label: "keyboard", width: 768, height: 900, colorScheme: "dark" as const },
{
label: "touch",
width: 390,
height: 844,
colorScheme: "light" as const,
mobile: true,
},
]
) {
const context = await browser.newContext({
viewport: { width: scenario.width, height: scenario.height },
colorScheme: scenario.colorScheme,
hasTouch: scenario.mobile ?? false,
isMobile: scenario.mobile ?? false,
});
const page = await context.newPage();
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(String(error)));
await openMemoryPage(page, baseUrl, scenario.mobile);
const initial = await mainScrollState(page);
assert(initial.scrollHeight > initial.clientHeight);
assertEquals(await markerIsInsideMain(page), false);
assertEquals(
await page.evaluate(() => {
const document = (globalThis as any).document;
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
}),
true,
);
if (scenario.label === "wheel") {
assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]);
assertEquals(
await page.locator("strong").filter({ hasText: "important context" }).count() > 0,
true,
);
assertEquals(await page.locator("blockquote").count() > 0, true);
assertEquals(await page.locator("ul li").count() > 0, true);
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
assertEquals(await page.locator("article img, article script").count(), 0);
assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0);
for (const label of ["Markdown table", "rust code block"]) {
const region = page.getByRole("region", { name: label });
assertEquals(await region.getAttribute("tabindex"), "0");
assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth));
}
await page.locator("main").hover();
await page.mouse.wheel(0, 100_000);
} else if (scenario.label === "keyboard") {
await page.getByText("Document details", { exact: true }).focus();
await page.keyboard.press("End");
} else {
await swipeMainToEnd(page);
}
await page.waitForTimeout(500);
const afterInteraction = await mainScrollState(page);
assert(
afterInteraction.scrollTop > 0,
`${scenario.label} did not move the main scroll owner: ${
JSON.stringify(afterInteraction)
}`,
);
assertEquals(await markerIsInsideMain(page), true);
assertEquals(errors, []);
await context.close();
}
const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const emptyPage = await stateContext.newPage();
await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
body_md: " \n",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 2,
record_source: "fixture",
}),
});
});
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
await emptyPage.getByText("Memory document is empty.").waitFor();
assertEquals(await emptyPage.getByRole("alert").count(), 0);
const errorPage = await stateContext.newPage();
await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
});
await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
const alert = errorPage.getByRole("alert");
await alert.waitFor();
assert((await alert.textContent())?.includes("Memory document unavailable."));
assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0);
await stateContext.close();
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
await server.status;
}
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts"
},
"fmt": {
"lineWidth": 100,
+1
View File
@@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
@@ -20,13 +20,9 @@
</script>
<script lang="ts">
import SvelteMarkdown, {
buildUnsupportedHTML,
type Renderers,
type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown";
import type { Renderers } from "@humanspeak/svelte-markdown";
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
type Props = {
text: string;
@@ -36,24 +32,15 @@
let { text, streamId = "static", class: className = "" }: Props = $props();
const options = {
breaks: true,
gfm: true,
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
const renderers = {
code: ShikiCode,
html: buildUnsupportedHTML(),
link: MarkdownLink,
} satisfies Partial<Renderers>;
</script>
<div class={`rich-markdown ${className}`}>
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
</div>
<SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
<style>
.rich-markdown {
:global(.rich-markdown) {
color: inherit;
line-height: 1.55;
}
@@ -496,11 +496,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
assert(
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
memoryDocumentLoad.includes('"/memory"') &&
memoryDocumentPage.includes("Memory Document") &&
memoryDocumentPage.includes("This view is read-only") &&
memoryDocumentPage.includes("DocumentMarkdown from") &&
memoryDocumentPage.includes("Read-only") &&
memoryDocumentPage.includes("data.memory.data.body_md") &&
memoryDocumentPage.includes("data.memory.data.updated_at"),
"Memory Document page should read the scoped API and expose the durable document without mutation controls",
memoryDocumentPage.includes("data.memory.data.updated_at") &&
memoryDocumentPage.includes('role="alert"') &&
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
);
assert(
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
@@ -0,0 +1,17 @@
<script lang="ts">
let {
lang = "",
text,
}: {
lang?: string;
text: string;
codeBlockStyle?: "indented";
} = $props();
const label = $derived(lang ? `${lang} code block` : "Code block");
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
<div class="markdown-code-region" role="region" aria-label={label} tabindex="0">
<pre><code>{text}</code></pre>
</div>
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { cleanup, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import DocumentMarkdown from "./DocumentMarkdown.svelte";
afterEach(cleanup);
test("renders document Markdown semantics through the shared safe policy", async () => {
const source = [
"# Memory heading",
"",
"A paragraph with **strong context** and `inline-code`.",
"",
"- first item",
"- second item",
"",
"> durable quote",
"",
"| Decision | Owner |",
"| --- | --- |",
"| Keep the document safe | Workspace |",
"",
"```rust",
'fn main() { println!("safe"); }',
"```",
"",
"[safe](https://example.com) [script](javascript:alert(1)) [relative](/internal)",
"",
'<img src="x" onerror="alert(1)"><script>alert(1)</script>',
].join("\n");
const { container } = render(DocumentMarkdown, { text: source });
await waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("Memory heading");
expect(container.querySelector("table")).not.toBeNull();
});
expect(container.querySelector("strong")?.textContent).toBe("strong context");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.querySelector("blockquote")?.textContent).toContain("durable quote");
expect(container.querySelector('a[href="https://example.com"]')).not.toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(1);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
const tableRegion = container.querySelector<HTMLElement>(
'[role="region"][aria-label="Markdown table"]',
);
const codeRegion = container.querySelector<HTMLElement>(
'[role="region"][aria-label="rust code block"]',
);
expect(tableRegion?.tabIndex).toBe(0);
expect(codeRegion?.tabIndex).toBe(0);
expect(codeRegion?.querySelector("pre > code")?.textContent).toContain("println!");
});
test("wraps long prose and identifiers without turning them into document-level controls", async () => {
const longIdentifier = "memory-identifier-".repeat(40);
const { container } = render(DocumentMarkdown, {
text: `Long value: ${longIdentifier}\n\n<details open><summary>unsafe HTML</summary></details>`,
});
await waitFor(() => expect(container.textContent).toContain(longIdentifier));
expect(container.querySelector("details")).toBeNull();
expect(container.querySelector("button")).toBeNull();
});
@@ -0,0 +1,144 @@
<script lang="ts">
import type { Renderers } from "@humanspeak/svelte-markdown";
import DocumentCodeBlock from "./DocumentCodeBlock.svelte";
import MarkdownTable from "./MarkdownTable.svelte";
import SafeMarkdown from "./SafeMarkdown.svelte";
let { text }: { text: string } = $props();
const renderers = {
code: DocumentCodeBlock,
table: MarkdownTable,
} satisfies Partial<Renderers>;
</script>
<SafeMarkdown {text} class="document-markdown" {renderers} />
<style>
:global(.document-markdown) {
min-width: 0;
max-width: 100%;
color: var(--text);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
overflow-wrap: anywhere;
}
:global(.document-markdown > :first-child) {
margin-top: 0;
}
:global(.document-markdown > :last-child) {
margin-bottom: 0;
}
:global(.document-markdown h1) {
margin: var(--space-6) 0 var(--space-4);
color: var(--text-strong);
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
:global(.document-markdown h2),
:global(.document-markdown h3),
:global(.document-markdown h4) {
margin: var(--space-6) 0 var(--space-2);
color: var(--text-strong);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
:global(.document-markdown h2) {
font-weight: 700;
}
:global(.document-markdown h3),
:global(.document-markdown h4) {
font-weight: 600;
}
:global(.document-markdown p),
:global(.document-markdown ul),
:global(.document-markdown ol),
:global(.document-markdown blockquote),
:global(.document-markdown .markdown-code-region),
:global(.document-markdown .markdown-table-region) {
margin: 0 0 var(--space-4);
}
:global(.document-markdown ul),
:global(.document-markdown ol) {
padding-left: var(--space-5);
}
:global(.document-markdown li + li) {
margin-top: var(--space-1);
}
:global(.document-markdown blockquote) {
margin-left: 0;
padding-left: var(--space-4);
border-left: 2px solid var(--line-strong);
color: var(--text-muted);
}
:global(.document-markdown a) {
color: var(--accent);
text-underline-offset: 0.18em;
}
:global(.document-markdown :not(pre) > code) {
padding: 0 var(--space-1);
background: var(--bg-subtle);
color: var(--code);
overflow-wrap: anywhere;
}
:global(.document-markdown .markdown-code-region),
:global(.document-markdown .markdown-table-region) {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
:global(.document-markdown .markdown-code-region) {
background: var(--bg-raised);
color: var(--code);
}
:global(.document-markdown .markdown-code-region pre) {
width: max-content;
min-width: 100%;
margin: 0;
padding: var(--space-4);
background: transparent;
color: inherit;
white-space: pre;
}
:global(.document-markdown .markdown-table-region table) {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
:global(.document-markdown .markdown-table-region th),
:global(.document-markdown .markdown-table-region td) {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
white-space: nowrap;
}
:global(.document-markdown .markdown-table-region th) {
color: var(--text-strong);
font-weight: 600;
}
:global(.document-markdown hr) {
margin: var(--space-6) 0;
border: 0;
border-top: 1px solid var(--line);
}
</style>
@@ -0,0 +1,10 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
<div class="markdown-table-region" role="region" aria-label="Markdown table" tabindex="0">
<table>{@render children?.()}</table>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import SvelteMarkdown, {
buildUnsupportedHTML,
type Renderers,
type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown";
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
type Props = {
text: string;
streamId?: string | number;
class?: string;
streaming?: boolean;
renderers?: Partial<Renderers>;
};
let {
text,
streamId = "static",
class: className = "",
streaming = false,
renderers = {},
}: Props = $props();
const options = {
breaks: true,
gfm: true,
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
const safeRenderers = $derived({
...renderers,
html: buildUnsupportedHTML(),
link: MarkdownLink,
} satisfies Partial<Renderers>);
</script>
<div class={className}>
<SvelteMarkdown source={text} {streamId} {options} renderers={safeRenderers} {streaming} />
</div>
@@ -1,123 +1,165 @@
<script lang="ts">
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
</script>
<svelte:head>
<title>Memory Document · Yoi Workspace</title>
<meta name="description" content="Workspace Memory Document" />
<title>Memory · Yoi Workspace</title>
<meta name="description" content="Workspace Memory document" />
</svelte:head>
<section class="card memory-document-card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace memory</p>
<h2>Memory Document</h2>
</div>
{#if data.memory.data}
<span>{data.memory.data.bytes} bytes</span>
{/if}
</div>
<p class="section-note">
Durable workspace Memory as a single Markdown document. This view is read-only.
</p>
<section class="memory-document-page" aria-label="Memory document">
{#if data.memory.data}
<div class="memory-document-summary" aria-label="Memory document summary">
<div>
<span>Updated</span>
<strong>{formatDate(data.memory.data.updated_at)}</strong>
</div>
<div>
<span>Created</span>
<strong>{formatDate(data.memory.data.created_at)}</strong>
</div>
<div>
<span>Lines</span>
<strong>{lineCount}</strong>
</div>
<div>
<span>Source</span>
<strong>{data.memory.data.record_source}</strong>
</div>
<div class="memory-document-meta">
<p>
<span>Read-only</span>
<span aria-hidden="true">·</span>
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
</p>
<details>
<summary>Document details</summary>
<dl>
<div>
<dt>Created</dt>
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
</div>
<div>
<dt>Size</dt>
<dd>{data.memory.data.bytes} bytes</dd>
</div>
<div>
<dt>Source</dt>
<dd><code>{data.memory.data.record_source}</code></dd>
</div>
</dl>
</details>
</div>
{#if data.memory.data.body_md.trim().length === 0}
<p>No Memory document content is present.</p>
<div class="memory-document-state" role="status">
<strong>Memory document is empty.</strong>
<p>Durable Workspace context will appear here when it is available.</p>
</div>
{:else}
<pre class="memory-document-body">{data.memory.data.body_md}</pre>
<article class="memory-document-content" aria-label="Memory document content">
<DocumentMarkdown text={data.memory.data.body_md} />
</article>
{/if}
{:else if data.memory.error}
<p class="error">{data.memory.error}</p>
<div class="memory-document-state memory-document-error">
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
</div>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/memory</code>…</p>
<div class="memory-document-state" role="status">
<p>Memory document data is unavailable.</p>
</div>
{/if}
</section>
<style>
.memory-document-card {
overflow: hidden;
}
.memory-document-summary {
.memory-document-page {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 1rem 0;
flex: 0 0 auto;
gap: var(--space-5);
width: 100%;
min-width: 0;
max-width: 78rem;
margin-inline: auto;
}
.memory-document-summary div {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
padding: 0.8rem;
}
.memory-document-summary span {
.memory-document-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
color: var(--text-muted);
display: block;
font-size: var(--font-size-compact);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: var(--line-height-compact);
}
.memory-document-summary strong {
.memory-document-meta > p {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
}
.memory-document-meta details {
flex: 0 0 auto;
}
.memory-document-meta summary {
color: var(--text-muted);
cursor: pointer;
}
.memory-document-meta dl {
display: grid;
gap: var(--space-2);
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
margin: var(--space-3) 0 0;
padding: var(--space-3) 0 0;
border-top: 1px solid var(--line);
}
.memory-document-meta dl > div {
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
gap: var(--space-3);
}
.memory-document-meta dt {
color: var(--text-faint);
}
.memory-document-meta dd {
min-width: 0;
margin: 0;
color: var(--text);
display: block;
font-size: var(--font-size-compact);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.memory-document-body {
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.9rem;
color: var(--text);
font-family: var(--font-mono);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
margin: 1rem 0 0;
overflow: auto;
padding: 1rem;
white-space: pre-wrap;
.memory-document-content {
min-width: 0;
}
@media (max-width: 900px) {
.memory-document-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
.memory-document-state {
padding-block: var(--space-5);
color: var(--text-muted);
}
.memory-document-state strong {
color: var(--text-strong);
}
.memory-document-state p {
margin: var(--space-1) 0 0;
}
.memory-document-state > p:first-child {
margin-top: 0;
}
.memory-document-error,
.memory-document-error strong {
color: var(--danger);
}
@media (max-width: 600px) {
.memory-document-meta {
display: grid;
gap: var(--space-2);
}
}
@media (max-width: 640px) {
.memory-document-summary {
grid-template-columns: 1fr;
.memory-document-meta details {
width: 100%;
}
.memory-document-meta dl {
min-width: 0;
}
}
</style>
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import MemoryPage from "./+page.svelte";
import type { PageProps } from "./$types";
function pageProps(memory: {
data: {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
} | null;
error: string | null;
}): PageProps {
return {
data: { workspaceId: "workspace-1", memory },
params: { workspaceId: "workspace-1" },
} as unknown as PageProps;
}
afterEach(cleanup);
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
const { container } = render(
MemoryPage,
pageProps({
data: {
body_md: "# Durable context\n\nRead the **current** document.",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 48,
record_source: "sqlite_workspace_authority.memory_document",
},
error: null,
}),
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
});
expect(screen.getByText("Read-only")).not.toBeNull();
expect(screen.getByText("Document details")).not.toBeNull();
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
});
test("distinguishes an empty document from request failure and unavailable data", async () => {
const view = render(
MemoryPage,
pageProps({
data: {
body_md: " \n",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
bytes: 3,
record_source: "fixture",
},
error: null,
}),
);
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
expect(screen.queryByRole("alert")).toBeNull();
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
expect(screen.queryByText("Memory document is empty.")).toBeNull();
await view.rerender(pageProps({ data: null, error: null }));
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
expect(screen.queryByRole("alert")).toBeNull();
});