summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/markdown/ui')
-rw-r--r--src/features/markdown/ui/Markdown.svelte69
-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/markdown.test.ts151
-rw-r--r--src/features/markdown/ui/text-file.test.ts190
5 files changed, 592 insertions, 47 deletions
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index 72b892b..c33f9f6 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,58 +1,33 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/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;
+ image?: MarkdownImageOptions | undefined;
+ /** Explicit opt-in; embedded documents never receive this port. */
+ textFiles?: TextFileClient | undefined;
} = $props();
-
- // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
- const html = $derived(renderMarkdown(text, { streaming }));
-
- let container: HTMLElement;
-
- // One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when `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 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}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
-</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/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts
index d65b3d1..b86cc0a 100644
--- a/src/features/markdown/ui/markdown.test.ts
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/svelte";
+import hljs from "highlight.js/lib/core";
import { describe, expect, it, vi } from "vitest";
import Markdown from "./Markdown.svelte";
@@ -37,4 +38,154 @@ describe("Markdown", () => {
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
});
+
+ it.each([
+ false,
+ true,
+ ])("loads only the clicked remote image (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '![first](https://evil.example/a.png "A title")\n\n![second](https://other.example/b.png)',
+ streaming,
+ },
+ });
+ expect(container.querySelector("img")).toBeNull();
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ await fireEvent.click(button);
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toBe("https://evil.example/a.png");
+ expect(img?.getAttribute("alt")).toBe("first");
+ expect(img?.getAttribute("title")).toBe("A title");
+ expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer");
+ expect(
+ screen.getByRole("button", { name: "Load image from other.example" }),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>',
+ streaming,
+ },
+ });
+ expect(container.querySelector("video,svg,input,[style],img")).toBeNull();
+ await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" }));
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "https://evil.example/allowed-after-click",
+ );
+ expect(container.querySelector("video,svg,input,[style]")).toBeNull();
+ });
+
+ it("rejects an unsafe URL in a forged placeholder", async () => {
+ const { container } = render(Markdown, {
+ props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' },
+ });
+ await fireEvent.click(screen.getByRole("button", { name: "Load" }));
+ expect(container.querySelector("img")).toBeNull();
+ });
+
+ it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => {
+ const { container, rerender } = render(Markdown, { props: { text: "start" } });
+ await rerender({ text: "[![pic](https://evil.example/a.png)](https://other.example)" });
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ expect(await fireEvent.click(button)).toBe(false);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png");
+ });
+
+ it("non-streaming renders a single container (today's full-render path)", () => {
+ const { container } = render(Markdown, { props: { text: "# Hello" } });
+ expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+ expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+ // The rendered blocks are DIRECT children of .markdown-body (no wrappers).
+ const body = container.querySelector(".markdown-body");
+ expect(body?.querySelector("h1")).not.toBeNull();
+ expect(body?.children[0]?.tagName).toBe("H1");
+ });
+
+ it("streaming renders the prefix/tail split with content matching the full render", () => {
+ const { container } = render(Markdown, {
+ props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true },
+ });
+
+ const prefix = container.querySelector<HTMLElement>(".markdown-stream-prefix");
+ const tail = container.querySelector<HTMLElement>(".markdown-stream-tail");
+ expect(prefix).not.toBeNull();
+ expect(tail).not.toBeNull();
+ // Same visible content as today's render, split across the two containers.
+ expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+ expect(container.textContent).toContain("growing tail");
+ expect(prefix?.querySelector("h1")).not.toBeNull();
+ });
+
+ it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const { container, rerender } = render(Markdown, {
+ props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true },
+ });
+
+ const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML;
+ expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ // Appends that settle nothing leave the prefix string (and its DOM) alone.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true });
+ await rerender({
+ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more",
+ streaming: true,
+ });
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore);
+ expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight
+ });
+
+ it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const { container, rerender } = render(Markdown, {
+ props: { text: "intro\n\n```js\nconst x", streaming: true },
+ });
+
+ const tail = container.querySelector<HTMLElement>(".markdown-stream-tail");
+ expect(tail?.innerHTML).toContain("<pre><code");
+ expect(tail?.innerHTML).toContain("data-copy"); // same copy button
+ expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open
+ expect(spy).not.toHaveBeenCalled();
+
+ // Close the fence → highlighted EXACTLY once, settled into the prefix.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword");
+ });
+
+ it("streaming → done flips to the single-container full render (completion backstop)", async () => {
+ const { container, rerender } = render(Markdown, {
+ props: { text: "# Hello\n\nbody", streaming: true },
+ });
+ expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull();
+
+ await rerender({ text: "# Hello\n\nbody", streaming: false });
+ expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+ expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+ expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+ });
+
+ it("streaming: the delegated copy-button listener still works for tail code blocks", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+
+ const { container } = render(Markdown, {
+ props: { text: "para\n\n```js\nconst y = 2;", streaming: true },
+ });
+
+ const button = container.querySelector<HTMLElement>("[data-copy]");
+ expect(button).not.toBeNull();
+ if (button === null) throw new Error("expected a copy button");
+ await fireEvent.click(button);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;");
+ });
});
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();
+ });
+});