From 48cdc3d19a319981a8a4128be98db3c283f81790 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 24 Oct 2026 18:34:12 +0900 Subject: feat: render live text-file embeds in expandable Markdown blocks Show rendered first/last six-line previews with expand, refresh and retry; bound and deduplicate context-guarded fetches. Verify 2586 tests twice with Node Web Storage compatibility flag, 2 live integration tests, typecheck, Biome and production build. --- src/features/markdown/adapter/text-file.test.ts | 168 +++++++++++++++++++++ src/features/markdown/adapter/text-file.ts | 171 +++++++++++++++++++++ src/features/markdown/index.ts | 3 + src/features/markdown/logic/text-file.test.ts | 117 +++++++++++++++ src/features/markdown/logic/text-file.ts | 116 +++++++++++++++ src/features/markdown/types.ts | 13 ++ src/features/markdown/ui/Markdown.svelte | 125 +++------------- src/features/markdown/ui/MarkdownContent.svelte | 116 +++++++++++++++ src/features/markdown/ui/TextFileEmbed.svelte | 113 ++++++++++++++ src/features/markdown/ui/text-file.test.ts | 190 ++++++++++++++++++++++++ 10 files changed, 1028 insertions(+), 104 deletions(-) create mode 100644 src/features/markdown/adapter/text-file.test.ts create mode 100644 src/features/markdown/adapter/text-file.ts create mode 100644 src/features/markdown/logic/text-file.test.ts create mode 100644 src/features/markdown/logic/text-file.ts create mode 100644 src/features/markdown/types.ts create mode 100644 src/features/markdown/ui/MarkdownContent.svelte create mode 100644 src/features/markdown/ui/TextFileEmbed.svelte create mode 100644 src/features/markdown/ui/text-file.test.ts (limited to 'src/features/markdown') diff --git a/src/features/markdown/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts new file mode 100644 index 0000000..2597271 --- /dev/null +++ b/src/features/markdown/adapter/text-file.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TextFileState } from "../types"; +import { createTextFileClient } from "./text-file"; + +const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +const file = (text = "# Plan") => ({ + path: "/project/a.md", + text, + format: "markdown", + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, +}); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +describe("context-bound live-file client", () => { + it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => { + const fetcher = vi + .fn() + .mockImplementation(async (url) => + response(String(url).endsWith("-context") ? context : file()), + ); + const client = createTextFileClient("/api", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + const offA = client.subscribe("notes/100%.md", (s) => a.push(s)); + const offB = client.subscribe("notes/100%.md", (s) => b.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + path: "notes/100%.md", + expectedCwd: "/project", + expectedComputerId: "", + }); + expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store"); + expect(a.at(-1)?.kind).toBe("ready"); + expect(b.at(-1)?.kind).toBe("ready"); + offA(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + offB(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + client.subscribe("notes/100%.md", () => {}); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("never reads a file before context, handles remote inheritance explicitly", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(response({ ...context, computerId: "remote" })); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("/same-local-path.md", (s) => states.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toMatchObject({ + kind: "error", + message: expect.stringContaining("remote computers"), + }); + client.dispose(); + }); + it.each([ + 400, 404, 409, 413, 422, + ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response({ error: `failure ${status}` }, status)) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("changed"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` }); + client.refresh("a.md"); + await flush(); + expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } }); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("guards late responses after refresh and disposal", async () => { + let finish: (r: Response) => void = () => {}; + const fetcher = vi + .fn() + .mockResolvedValueOnce(response(context)) + .mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("new"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + client.refresh("a.md"); + await flush(); + finish(response(file("old"))); + await flush(); + expect(states.at(-1)).toMatchObject({ file: { text: "new" } }); + client.dispose(); + expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true); + }); + it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => { + const fetcher = vi + .fn() + .mockImplementation(async (url) => + String(url).endsWith("-context") ? response(context) : new Promise(() => {}), + ); + const client = createTextFileClient("", "c", fetcher); + const overflow: TextFileState[] = []; + for (let i = 0; i < 40; i++) + client.subscribe(`${i}.md`, (s) => { + if (i >= 32) overflow.push(s); + }); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(overflow).toHaveLength(8); + expect(overflow[0]).toMatchObject({ kind: "error" }); + client.dispose(); + for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true); + }); + it("rejects oversized or malformed success data instead of truncating", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("a".repeat(262145)))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)?.kind).toBe("error"); + client.dispose(); + }); +}); + +it("invalidates every live document and reloads guards before publishing refreshed context", async () => { + let generation = 0; + const fetcher = vi.fn().mockImplementation(async (url) => { + if (String(url).endsWith("-context")) + return response({ ...context, cwd: generation ? "/new" : "/project" }); + const params = new URL(String(url), "http://localhost").searchParams; + return response({ + ...file(generation ? "new" : "old"), + path: `${params.get("expectedCwd")}/a.md`, + }); + }); + const client = createTextFileClient("", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + client.subscribe("a.md", (state) => a.push(state)); + client.subscribe("b.md", (state) => b.push(state)); + await flush(); + generation++; + client.refresh("a.md"); + expect(a.at(-1)?.kind).toBe("loading"); + expect(b.at(-1)?.kind).toBe("loading"); + await flush(); + expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } }); + expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } }); + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2); + client.dispose(); +}); diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts new file mode 100644 index 0000000..05ddb23 --- /dev/null +++ b/src/features/markdown/adapter/text-file.ts @@ -0,0 +1,171 @@ +import type { + TextFileContextResponse, + TextFileQuery, + TextFileResponse, +} from "@dispatch/transport-contract"; +import type { TextFileClient, TextFileState } from "../types"; + +const MAX_BYTES = 262144; +const MAX_ENTRIES = 32; +const MAX_PARALLEL = 4; +interface Entry { + readonly listeners: Set<(state: TextFileState) => void>; + state: TextFileState; + controller: AbortController; +} +/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */ +export function createTextFileClient( + apiBase: string, + conversationId: string, + fetchImpl: typeof fetch, +): TextFileClient { + const entries = new Map(); + let disposed = false; + let contextController = new AbortController(); + let context: Promise | null = null; + let active = 0; + const queue = new Map void>(); + const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`; + async function json(url: string, signal: AbortSignal): Promise { + const response = await fetchImpl(url, { signal, cache: "no-store" }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = data as { error?: string }; + throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`); + } + return data; + } + function getContext(): Promise { + context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => { + const c = data as TextFileContextResponse; + if ( + c.conversationId !== conversationId || + !(c.cwd === null || typeof c.cwd === "string") || + !(c.computerId === null || typeof c.computerId === "string") + ) { + throw new Error("Invalid text file context response"); + } + return c; + }); + return context; + } + function publish(entry: Entry, state: TextFileState): void { + entry.state = state; + for (const listener of entry.listeners) listener(state); + } + async function load(path: string, entry: Entry, controller: AbortController): Promise { + try { + if (controller.signal.aborted || disposed) return; + const c = await getContext(); + if (controller.signal.aborted || disposed) return; + if (c.computerId !== null) + throw new Error( + "Text-file embeds are not available for remote computers. No local file was read.", + ); + const query: TextFileQuery = { + path, + expectedCwd: c.cwd ?? "", + expectedComputerId: c.computerId ?? "", + }; + const params = new URLSearchParams(query as unknown as Record); + const data = (await json( + `${endpoint}/text-file?${params}`, + controller.signal, + )) as TextFileResponse; + if ( + typeof data.text !== "string" || + typeof data.path !== "string" || + !data.path.startsWith("/") || + !["markdown", "text"].includes(data.format) || + data.maxBytes !== MAX_BYTES || + !Number.isInteger(data.bytes) || + data.bytes < 0 || + data.bytes > MAX_BYTES || + new TextEncoder().encode(data.text).length !== data.bytes + ) { + throw new Error("Invalid or oversized text file response (maximum 256 KiB)"); + } + if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data }); + } catch (error) { + if (!controller.signal.aborted && !disposed) + publish(entry, { + kind: "error", + message: error instanceof Error ? error.message : "Unable to read text file", + }); + } + } + function drain(): void { + while (active < MAX_PARALLEL && queue.size) { + const next = queue.entries().next().value; + if (!next) return; + queue.delete(next[0]); + next[1](); + } + } + function start(path: string, entry: Entry): void { + entry.controller.abort(); + const controller = new AbortController(); + entry.controller = controller; + publish(entry, { kind: "loading" }); + queue.set(path, () => { + if (controller.signal.aborted || disposed) return; + active++; + void load(path, entry, controller).finally(() => { + active--; + drain(); + }); + }); + drain(); + } + return { + subscribe(path, listener) { + if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) { + listener({ + kind: "error", + message: "Too many text-file embeds (maximum 32 active files).", + }); + return () => {}; + } + let entry = entries.get(path); + if (!entry) { + entry = { + listeners: new Set(), + state: { kind: "loading" }, + controller: new AbortController(), + }; + entries.set(path, entry); + start(path, entry); + } + entry.listeners.add(listener); + listener(entry.state); + const current = entry; + return () => { + current.listeners.delete(listener); + if (!current.listeners.size) { + current.controller.abort(); + entries.delete(path); + queue.delete(path); + } + if (!entries.size) { + contextController.abort(); + contextController = new AbortController(); + context = null; + } + }; + }, + refresh(_path) { + // Refresh can reveal a context change: invalidate ALL live content before rereading. + contextController.abort(); + contextController = new AbortController(); + context = null; + for (const [path, entry] of entries) start(path, entry); + }, + dispose() { + disposed = true; + contextController.abort(); + for (const entry of entries.values()) entry.controller.abort(); + entries.clear(); + queue.clear(); + }, + }; +} diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index ff3aefa..2b50ab1 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -6,3 +6,6 @@ export const manifest = { name: "markdown", description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; + +export { createTextFileClient } from "./adapter/text-file"; +export type { TextFileClient, TextFileContext, TextFileState } from "./types"; diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan]()", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "![image](text-file:notes/a.md)", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "
Plan
", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts new file mode 100644 index 0000000..cb3fe8d --- /dev/null +++ b/src/features/markdown/types.ts @@ -0,0 +1,13 @@ +import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract"; + +export type TextFileState = + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ready"; readonly file: TextFileResponse }; +export interface TextFileClient { + subscribe(path: string, listener: (state: TextFileState) => void): () => void; + /** Refresh context as well as the live on-disk file. */ + refresh(path: string): void; + dispose(): void; +} +export type TextFileContext = TextFileContextResponse; diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index a0fd276..c33f9f6 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,116 +1,33 @@ -
- {#if streaming} - -
{@html rendered.prefixHtml}
-
{@html rendered.tailHtml}
- {:else} - - {@html rendered.tailHtml} - {/if} -
+{#if parts && textFiles} + {#each parts as part, i (i)} + {#if part.kind === "text-file"} + + {:else} + + {/if} + {/each} +{:else} + +{/if} diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte new file mode 100644 index 0000000..6149d6b --- /dev/null +++ b/src/features/markdown/ui/MarkdownContent.svelte @@ -0,0 +1,116 @@ + + +
+ {#if streaming} + +
{@html rendered.prefixHtml}
+
{@html rendered.tailHtml}
+ {:else} + + {@html rendered.tailHtml} + {/if} +
diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte new file mode 100644 index 0000000..2e1a7a3 --- /dev/null +++ b/src/features/markdown/ui/TextFileEmbed.svelte @@ -0,0 +1,113 @@ + + +
+
+ + {reference.label} + {reference.path} + +
+
+ {#if loadState.kind === "loading"} +

+ Loading text file… +

+ {:else if loadState.kind === "error"} + + {:else if loadState.file.text === ""} +

Empty file

+ {:else if preview} + {#if expanded} + {@render content(loadState.file.text, loadState.file.format)} + {:else} + {@render content(preview.head, loadState.file.format)} + {#if preview.omitted > 0} +

+ ⋯ {preview.omitted} source lines omitted ⋯ +

+ {@render content(preview.tail, loadState.file.format)} + {/if} + {/if} + +

+ Live file · Refresh rereads disk · maximum 256 KiB +

+ {/if} +
+
+ +{#snippet content(text: string, format: "markdown" | "text")} + {#if format === "markdown"} + + + {:else} +
{text}
+ {/if} +{/snippet} diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts new file mode 100644 index 0000000..cbbf11e --- /dev/null +++ b/src/features/markdown/ui/text-file.test.ts @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import { createTextFileClient } from "../adapter/text-file"; +import Markdown from "./Markdown.svelte"; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +function fixture(text: string, format: "markdown" | "text" = "markdown") { + const fetcher = vi.fn().mockImplementation(async (url) => + json( + String(url).endsWith("-context") + ? context + : { + path: "/project/notes/plan.md", + text, + format, + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, + }, + ), + ); + return { fetcher, client: createTextFileClient("/api", "c", fetcher) }; +} +describe("rendered live text-file embeds", () => { + it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => { + const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n"); + const { client, fetcher } = fixture(text); + const { unmount } = render(Markdown, { + text: "Summary\n\n[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading text file"); + await screen.findByRole("heading", { name: "Line 1" }); + expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await fireEvent.click(screen.getByRole("button", { name: "Collapse document" })); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => { + const text = + '# Short\n\n[nested](text-file:secret.md)\n\n\n\n[bad](javascript:alert(1))'; + const { client, fetcher } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Short" }); + expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1); + expect( + container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"), + ).toBeNull(); + expect(container.textContent).toContain("nested"); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("does not render omitted images before expansion; restores tail fence context", async () => { + const text = + "# Head\n\n1\n2\n3\n4\n![hidden](https://hidden.example/a.png)\n```js\n" + + Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") + + "\n```"; + const { client } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Head" }); + expect( + screen.queryByRole("button", { name: "Load image from hidden.example" }), + ).not.toBeInTheDocument(); + expect(container.querySelector("code.language-js")).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect( + screen.getByRole("button", { name: "Load image from hidden.example" }), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + unmount(); + client.dispose(); + }); + it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => { + const { client } = fixture("# heading\n \n\tlast", "text"); + const { container, unmount } = render(Markdown, { + text: "[Text](text-file:a.txt)", + textFiles: client, + }); + await waitFor(() => + expect(container.querySelector("pre code")?.textContent).toBe( + "# heading\n \n\tlast", + ), + ); + expect(container.querySelector("script,h1")).toBeNull(); + unmount(); + client.dispose(); + }); + it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => { + const { client, fetcher } = fixture(""); + fetcher + .mockResolvedValueOnce(json(context)) + .mockResolvedValueOnce(json({ error: "Missing file" }, 404)); + const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client }); + expect(await screen.findByRole("alert")).toHaveTextContent("Missing file"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Empty file"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText("Empty file"); + expect(fetcher).toHaveBeenCalledTimes(6); + unmount(); + client.dispose(); + }); + it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => { + const { client, fetcher } = fixture("body"); + const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" }); + await rerender({ + text: '
\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```', + textFiles: client, + }); + await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client }); + expect(fetcher).not.toHaveBeenCalled(); + await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client }); + await screen.findByText("body"); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: true, + textFiles: client, + }); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: false, + textFiles: client, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("ignores old conversation results after the context-bound client changes", async () => { + let finish: (r: Response) => void = () => {}; + const old = fixture("old"); + old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const next = fixture("new"); + const { rerender, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: old.client, + }); + await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2)); + await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client }); + await screen.findByText("new"); + finish( + json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }), + ); + await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument()); + expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + unmount(); + old.client.dispose(); + next.client.dispose(); + }); + it("deduplicates repeated blocks without replacing them on per-token appends", async () => { + const { client, fetcher } = fixture("# shared"); + const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n"; + const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client }); + await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement, + ); + await rerender({ text: `${text}growing`, streaming: true, textFiles: client }); + expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); +}); -- cgit v1.2.3