summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
committerAdam Malczewski <[email protected]>2026-10-24 18:34:12 +0900
commit48cdc3d19a319981a8a4128be98db3c283f81790 (patch)
tree0fa6531f3b05c81348f9d3427e9021dbb4832b5d /src/features
parentdaefad66bdf8dee132d701a3b43ce0d227dde241 (diff)
downloaddispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.tar.gz
dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.zip
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.
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/ui/ChatView.svelte6
-rw-r--r--src/features/markdown/adapter/text-file.test.ts168
-rw-r--r--src/features/markdown/adapter/text-file.ts171
-rw-r--r--src/features/markdown/index.ts3
-rw-r--r--src/features/markdown/logic/text-file.test.ts117
-rw-r--r--src/features/markdown/logic/text-file.ts116
-rw-r--r--src/features/markdown/types.ts13
-rw-r--r--src/features/markdown/ui/Markdown.svelte125
-rw-r--r--src/features/markdown/ui/MarkdownContent.svelte116
-rw-r--r--src/features/markdown/ui/TextFileEmbed.svelte113
-rw-r--r--src/features/markdown/ui/text-file.test.ts190
11 files changed, 1033 insertions, 105 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index c7f566a..84e66d8 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -11,7 +11,7 @@
viewTurnMetrics,
type TurnMetricsEntry,
} from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import { Markdown, type TextFileClient } from "../../markdown";
import CacheTimerBadge from "./CacheTimerBadge.svelte";
import LazyCollapse from "./LazyCollapse.svelte";
import ToolCard from "./ToolCard.svelte";
@@ -32,6 +32,7 @@
providerRetry = null,
apiBaseUrl = "",
cwd = null,
+ textFiles = undefined,
cacheTimer = null,
}: {
chunks: readonly RenderedChunk[];
@@ -68,6 +69,8 @@
* and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`.
*/
cwd?: string | null;
+ /** Context-bound, injected live-file display port (never model history). */
+ textFiles?: TextFileClient | undefined;
/**
* The cache timer (gaps between model requests): a running step gap shows
* live at the bottom (under the executing tools), a running turn gap live
@@ -216,6 +219,7 @@
text={rendered.chunk.text}
streaming={rendered.streaming ?? false}
image={{ apiBase: apiBaseUrl, cwd }}
+ {textFiles}
/>
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
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<typeof fetch>()
+ .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<typeof fetch>()
+ .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<typeof fetch>()
+ .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<typeof fetch>()
+ .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<typeof fetch>()
+ .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<typeof fetch>()
+ .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<typeof fetch>().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<string, Entry>();
+ let disposed = false;
+ let contextController = new AbortController();
+ let context: Promise<TextFileContextResponse> | null = null;
+ let active = 0;
+ const queue = new Map<string, () => void>();
+ const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`;
+ async function json(url: string, signal: AbortSignal): Promise<unknown> {
+ 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<TextFileContextResponse> {
+ 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<void> {
+ 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<string, string>);
+ 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](<text-file:notes/my plan.md>)", "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")',
+ "<div data-text-file='a.md'>Plan</div>",
+ ])("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 @@
<script lang="ts">
- import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
- import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
+ import type { MarkdownImageOptions } from "../logic/markdown";
+ import { splitTextFiles } from "../logic/text-file";
+ import type { TextFileClient } from "../types";
+ import MarkdownContent from "./MarkdownContent.svelte";
+ import TextFileEmbed from "./TextFileEmbed.svelte";
let {
text,
streaming = false,
image = undefined,
+ textFiles = undefined,
}: {
text: string;
- /** Balance dangling delimiters while the message is still generating. */
streaming?: boolean;
- /**
- * Per-conversation local-image context: the backend HTTP base and the
- * conversation cwd. Markdown image srcs that are local paths are rewritten
- * through `${apiBase}/local-image?path=…` (relative paths resolve against
- * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
- * backend images load inline. Omit it to skip local-path rewriting.
- */
- image?: MarkdownImageOptions;
+ image?: MarkdownImageOptions | undefined;
+ /** Explicit opt-in; embedded documents never receive this port. */
+ textFiles?: TextFileClient | undefined;
} = $props();
-
- // Keep the optional `image` out of the options object when absent
- // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
- // keys on apiBase/cwd values.
- const renderOpts = $derived(image === undefined ? undefined : { image });
-
- // Checkpoint streaming state: one per component instance (per streaming
- // message). It caches the settled blocks' HTML so each delta re-renders only
- // the live tail — never the settled prefix (see logic/streaming-markdown).
- const streamingState = createStreamingMarkdown();
-
- // While streaming: the checkpoint split (settled prefix + live tail), which
- // equals today's full render at every step modulo inter-block newlines and
- // highlight-on-close. When the message completes (or for every non-streaming
- // chunk): ONE full authoritative render — today's exact behavior, so the
- // final/persisted output is byte-identical by construction.
- const rendered = $derived.by(() => {
- if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
- return advance(streamingState, text, renderOpts);
- });
-
- let container: HTMLElement;
-
- // One delegated listener on the stable container handles image consent and
- // every code block's copy button — including blocks re-created when the tail html changes
- // (streaming), since the listener lives on the container, not the buttons.
- // Clipboard is the edge effect; absent (insecure context) → no-op.
- $effect(() => {
- const el = container;
- if (el === undefined) return;
-
- const onClick = (event: Event): void => {
- const target = event.target;
- if (!(target instanceof Element)) return;
- const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
- if (remote !== null && el.contains(remote)) {
- event.preventDefault(); // A Markdown image may be wrapped in a link.
- event.stopPropagation();
- // data-* is authorable in raw HTML: validate again at the DOM edge.
- let url: URL;
- try {
- url = new URL(remote.dataset.src ?? "");
- } catch {
- return;
- }
- if (url.protocol !== "http:" && url.protocol !== "https:") return;
- const img = el.ownerDocument.createElement("img");
- img.alt = remote.dataset.alt ?? "";
- if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
- img.setAttribute("referrerpolicy", "no-referrer");
- img.src = url.href;
- remote.replaceWith(img);
- return;
- }
- const button = target.closest<HTMLButtonElement>("[data-copy]");
- if (button === null) return;
-
- const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
- const clipboard = navigator.clipboard;
- if (clipboard === undefined) return;
-
- void clipboard
- .writeText(code)
- .then(() => {
- const prev = button.textContent;
- button.textContent = "Copied";
- setTimeout(() => {
- button.textContent = prev;
- }, 1200);
- })
- .catch(() => {
- // Clipboard denied — leave the button as-is.
- });
- };
-
- el.addEventListener("click", onClick);
- return () => el.removeEventListener("click", onClick);
- });
+ const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null);
</script>
-<div class="markdown-body" bind:this={container}>
- {#if streaming}
- <!-- Two sibling wrappers (no padding/border/min-height, so block margins
- collapse across them exactly as if the blocks were direct children).
- Svelte 5 skips the {@html} DOM write when the string is unchanged, so
- the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
- only the tail's subtree is replaced per delta. Both strings are
- DOMPurify-sanitized in the logic modules. -->
- <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
- <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
- {:else}
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html rendered.tailHtml}
- {/if}
-</div>
+{#if parts && textFiles}
+ {#each parts as part, i (i)}
+ {#if part.kind === "text-file"}
+ <TextFileEmbed reference={part.reference} client={textFiles} {image} />
+ {:else}
+ <MarkdownContent text={part.text} {streaming} {image} />
+ {/if}
+ {/each}
+{:else}
+ <MarkdownContent {text} {streaming} {image} />
+{/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 @@
+<script lang="ts">
+ import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
+ import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
+
+ let {
+ text,
+ streaming = false,
+ image = undefined,
+ }: {
+ text: string;
+ /** Balance dangling delimiters while the message is still generating. */
+ streaming?: boolean;
+ /**
+ * Per-conversation local-image context: the backend HTTP base and the
+ * conversation cwd. Markdown image srcs that are local paths are rewritten
+ * through `${apiBase}/local-image?path=…` (relative paths resolve against
+ * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
+ * backend images load inline. Omit it to skip local-path rewriting.
+ */
+ image?: MarkdownImageOptions | undefined;
+ } = $props();
+
+ // Keep the optional `image` out of the options object when absent
+ // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
+ // keys on apiBase/cwd values.
+ const renderOpts = $derived(image === undefined ? undefined : { image });
+
+ // Checkpoint streaming state: one per component instance (per streaming
+ // message). It caches the settled blocks' HTML so each delta re-renders only
+ // the live tail — never the settled prefix (see logic/streaming-markdown).
+ const streamingState = createStreamingMarkdown();
+
+ // While streaming: the checkpoint split (settled prefix + live tail), which
+ // equals today's full render at every step modulo inter-block newlines and
+ // highlight-on-close. When the message completes (or for every non-streaming
+ // chunk): ONE full authoritative render — today's exact behavior, so the
+ // final/persisted output is byte-identical by construction.
+ const rendered = $derived.by(() => {
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
+ return advance(streamingState, text, renderOpts);
+ });
+
+ let container: HTMLElement;
+
+ // One delegated listener on the stable container handles image consent and
+ // every code block's copy button — including blocks re-created when the tail html changes
+ // (streaming), since the listener lives on the container, not the buttons.
+ // Clipboard is the edge effect; absent (insecure context) → no-op.
+ $effect(() => {
+ const el = container;
+ if (el === undefined) return;
+
+ const onClick = (event: Event): void => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
+ if (remote !== null && el.contains(remote)) {
+ event.preventDefault(); // A Markdown image may be wrapped in a link.
+ event.stopPropagation();
+ // data-* is authorable in raw HTML: validate again at the DOM edge.
+ let url: URL;
+ try {
+ url = new URL(remote.dataset.src ?? "");
+ } catch {
+ return;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
+ const img = el.ownerDocument.createElement("img");
+ img.alt = remote.dataset.alt ?? "";
+ if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
+ img.setAttribute("referrerpolicy", "no-referrer");
+ img.src = url.href;
+ remote.replaceWith(img);
+ return;
+ }
+ const button = target.closest<HTMLButtonElement>("[data-copy]");
+ if (button === null) return;
+
+ const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+
+ void clipboard
+ .writeText(code)
+ .then(() => {
+ const prev = button.textContent;
+ button.textContent = "Copied";
+ setTimeout(() => {
+ button.textContent = prev;
+ }, 1200);
+ })
+ .catch(() => {
+ // Clipboard denied — leave the button as-is.
+ });
+ };
+
+ el.addEventListener("click", onClick);
+ return () => el.removeEventListener("click", onClick);
+ });
+</script>
+
+<div class="markdown-body" bind:this={container}>
+ {#if streaming}
+ <!-- Two sibling wrappers (no padding/border/min-height, so block margins
+ collapse across them exactly as if the blocks were direct children).
+ Svelte 5 skips the {@html} DOM write when the string is unchanged, so
+ the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
+ only the tail's subtree is replaced per delta. Both strings are
+ DOMPurify-sanitized in the logic modules. -->
+ <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
+ <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
+ {:else}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html rendered.tailHtml}
+ {/if}
+</div>
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 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { MarkdownImageOptions } from "../logic/markdown";
+ import { textFilePreview, type TextFileReference } from "../logic/text-file";
+ import type { TextFileClient, TextFileState } from "../types";
+ import MarkdownContent from "./MarkdownContent.svelte";
+
+ let {
+ reference,
+ client,
+ image = undefined,
+ }: {
+ reference: TextFileReference;
+ client: TextFileClient;
+ image?: MarkdownImageOptions | undefined;
+ } = $props();
+ let loadState = $state<TextFileState>({ kind: "loading" });
+ let expanded = $state(false);
+ const preview = $derived(
+ loadState.kind === "ready"
+ ? textFilePreview(loadState.file.text, loadState.file.format === "markdown")
+ : null,
+ );
+ const path = $derived(reference.path);
+ const currentClient = $derived(client);
+ $effect(() => {
+ const source = currentClient;
+ const destination = path;
+ expanded = false;
+ // The callback can publish synchronously. Only routing changes, not reads
+ // inside a subscriber or unrelated parent prop updates, own this effect.
+ return untrack(() =>
+ source.subscribe(destination, (next) => {
+ loadState = next;
+ }),
+ );
+ });
+ // Use the resolved file's directory for local images, not the chat's cwd.
+ const documentImage = $derived(
+ loadState.kind === "ready" && image
+ ? {
+ apiBase: image.apiBase,
+ cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/",
+ }
+ : image,
+ );
+</script>
+
+<section
+ class="inset-well my-2 w-full min-w-0 rounded-box text-sm"
+ aria-label={`Text file: ${reference.label}`}
+>
+ <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium">
+ <span aria-hidden="true">▤</span>
+ <span>{reference.label}</span>
+ <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60"
+ >{reference.path}</span
+ >
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => client.refresh(reference.path)}
+ disabled={loadState.kind === "loading"}
+ >
+ {loadState.kind === "error" ? "Retry" : "Refresh"}
+ </button>
+ </header>
+ <div class="min-w-0 px-4 pb-4">
+ {#if loadState.kind === "loading"}
+ <p role="status">
+ <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file…
+ </p>
+ {:else if loadState.kind === "error"}
+ <p role="alert" class="text-error">{loadState.message}</p>
+ {:else if loadState.file.text === ""}
+ <p class="italic text-base-content/60">Empty file</p>
+ {: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}
+ <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60">
+ ⋯ {preview.omitted} source lines omitted ⋯
+ </p>
+ {@render content(preview.tail, loadState.file.format)}
+ {/if}
+ {/if}
+ <button
+ type="button"
+ class="btn btn-soft btn-xs mt-2"
+ aria-expanded={expanded}
+ onclick={() => {
+ expanded = !expanded;
+ }}
+ >
+ {expanded ? "Collapse document" : "Expand document"}
+ </button>
+ <p class="mt-2 text-xs text-base-content/50">
+ Live file · Refresh rereads disk · maximum 256 KiB
+ </p>
+ {/if}
+ </div>
+</section>
+
+{#snippet content(text: string, format: "markdown" | "text")}
+ {#if format === "markdown"}
+ <!-- No textFiles port: nested references are inert, even in expanded content. -->
+ <MarkdownContent {text} image={documentImage} />
+ {:else}
+ <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre>
+ {/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<typeof fetch>().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<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\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 <script>bad</script>\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 <script>bad</script>\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: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\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();
+ });
+});