From a0944f66c8c49e0ae9922b0d7a267e6f5aa49af7 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 30 Sep 2026 07:42:49 +0900 Subject: [PATCH] feat(web): redesign Memory document reader --- .../memory_document_fixture_server.ts | 149 +++++++++++ .../memory_document_shell_test.ts | 236 ++++++++++++++++++ tools/web-ux/deno.json | 2 +- web/workspace/README.md | 1 + .../lib/workspace/console/RichMarkdown.svelte | 21 +- .../console/worker-console.ui.test.ts | 10 +- .../markdown/DocumentCodeBlock.svelte | 17 ++ .../markdown/DocumentMarkdown.browser.spec.ts | 67 +++++ .../markdown/DocumentMarkdown.svelte | 144 +++++++++++ .../{console => markdown}/MarkdownLink.svelte | 0 .../workspace/markdown/MarkdownTable.svelte | 10 + .../workspace/markdown/SafeMarkdown.svelte | 39 +++ .../w/[workspaceId]/memory/+page.svelte | 208 +++++++++------ .../memory/memory-page.browser.spec.ts | 76 ++++++ 14 files changed, 875 insertions(+), 105 deletions(-) create mode 100644 tools/web-ux/browser-tests/memory_document_fixture_server.ts create mode 100644 tools/web-ux/browser-tests/memory_document_shell_test.ts create mode 100644 web/workspace/src/lib/workspace/markdown/DocumentCodeBlock.svelte create mode 100644 web/workspace/src/lib/workspace/markdown/DocumentMarkdown.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/markdown/DocumentMarkdown.svelte rename web/workspace/src/lib/workspace/{console => markdown}/MarkdownLink.svelte (100%) create mode 100644 web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte create mode 100644 web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts new file mode 100644 index 000000000..3d9c44183 --- /dev/null +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -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 [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\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 = { + ".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 { + 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); +}); diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts new file mode 100644 index 000000000..75f13de3d --- /dev/null +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 172a00ebc..69d6761a9 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -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, diff --git a/web/workspace/README.md b/web/workspace/README.md index b54b4ecf7..686d4bbe2 100644 --- a/web/workspace/README.md +++ b/web/workspace/README.md @@ -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. diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte index 5e7e16b05..e36af3f0b 100644 --- a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte +++ b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte @@ -20,13 +20,9 @@ -
- -
+ diff --git a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte b/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte similarity index 100% rename from web/workspace/src/lib/workspace/console/MarkdownLink.svelte rename to web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte diff --git a/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte b/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte new file mode 100644 index 000000000..6753c32ef --- /dev/null +++ b/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte @@ -0,0 +1,10 @@ + + + +
+ {@render children?.()}
+
diff --git a/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte b/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte new file mode 100644 index 000000000..576e7d09c --- /dev/null +++ b/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte @@ -0,0 +1,39 @@ + + +
+ +
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index d852919c8..d8d7aee70 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -1,123 +1,165 @@ - Memory Document · Yoi Workspace - + Memory · Yoi Workspace + -
-
-
-

Workspace memory

-

Memory Document

-
- {#if data.memory.data} - {data.memory.data.bytes} bytes - {/if} -
- -

- Durable workspace Memory as a single Markdown document. This view is read-only. -

- +
{#if data.memory.data} -
-
- Updated - {formatDate(data.memory.data.updated_at)} -
-
- Created - {formatDate(data.memory.data.created_at)} -
-
- Lines - {lineCount} -
-
- Source - {data.memory.data.record_source} -
+
+

+ Read-only + + Updated +

+
+ Document details +
+
+
Created
+
+
+
+
Size
+
{data.memory.data.bytes} bytes
+
+
+
Source
+
{data.memory.data.record_source}
+
+
+
{#if data.memory.data.body_md.trim().length === 0} -

No Memory document content is present.

+
+ Memory document is empty. +

Durable Workspace context will appear here when it is available.

+
{:else} -
{data.memory.data.body_md}
+
+ +
{/if} {:else if data.memory.error} -

{data.memory.error}

+
+

Memory document unavailable. {data.memory.error}

+
{:else} -

Waiting for /api/w/{data.workspaceId}/memory…

+
+

Memory document data is unavailable.

+
{/if}
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts new file mode 100644 index 000000000..f1152218d --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -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(); +});