diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx
new file mode 100644
index 00000000..7354433e
--- /dev/null
+++ b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx
@@ -0,0 +1,419 @@
+// @vitest-environment jsdom
+/**
+ * AttachmentTextPreview — inline text/code preview with expand + truncate.
+ *
+ * Uses a streaming fetch (ReadableStream) to read up to 256 KB of text.
+ * State machine: idle → loading → ready/error. Ready state shows a
+ * monospace preview of the first 10 lines, with an expand button when
+ * there are more. Shows a "truncated" note when the file exceeds 256 KB.
+ * Error falls back to AttachmentChip.
+ *
+ * NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
+ *
+ * Covers:
+ * - Renders loading skeleton (320×80) with aria-label
+ * - Renders text preview with correct content in ready state
+ * - Shows filename in header
+ * - Expand button appears when lines > 10
+ * - Expand button hidden when all lines shown
+ * - Expand button calls setExpanded(true) and button text updates
+ * - Download button calls onDownload
+ * - tone=user applies blue/accent border
+ * - tone=agent applies neutral border
+ * - Error state renders AttachmentChip fallback
+ * - Cleans up on unmount
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import React from "react";
+
+import { AttachmentTextPreview } from "../AttachmentTextPreview";
+import type { ChatAttachment } from "../types";
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
+ (id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
+);
+const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
+
+vi.mock("../uploads", () => ({
+ isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
+ resolveAttachmentHref: (id: string, uri: string) =>
+ mockResolveAttachmentHref(id, uri),
+}));
+
+vi.mock("@/lib/api", () => ({
+ platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
+}));
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeAttachment(name: string, size?: number): ChatAttachment {
+ return { name, uri: `workspace:/tmp/${name}`, size };
+}
+
+beforeEach(() => {
+ mockIsPlatformAttachment.mockReturnValue(true);
+ mockResolveAttachmentHref.mockReturnValue(
+ (id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`,
+ );
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ─── Fetch mock helpers ───────────────────────────────────────────────────────
+
+/**
+ * Mock a streaming fetch that returns text content.
+ * Mimics ReadableStream.read() yielding text chunks.
+ */
+function mockFetchText(completeText: string) {
+ const encoder = new TextEncoder();
+ const chunks: Uint8Array[] = [];
+ // Yield in 50-byte chunks
+ let offset = 0;
+ while (offset < completeText.length) {
+ chunks.push(encoder.encode(completeText.slice(offset, offset + 50)));
+ offset += 50;
+ }
+ let chunkIndex = 0;
+ const mockReader = {
+ read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>(
+ async () => {
+ if (chunkIndex < chunks.length) {
+ return { done: false, value: chunks[chunkIndex++] };
+ }
+ return { done: true };
+ },
+ ),
+ cancel: vi.fn(),
+ };
+ const mockBody = {
+ getReader: vi.fn(() => mockReader),
+ };
+ global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ body: mockBody,
+ headers: new Map([["content-type", "text/plain"]]),
+ }) as unknown as Response,
+ );
+ return mockReader;
+}
+
+function mockFetchError() {
+ global.fetch = vi.fn(() =>
+ Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
+ );
+}
+
+/**
+ * Mock a fetch where body.getReader() returns null (no streaming body).
+ */
+function mockFetchTextNoBody(text: string) {
+ const encoder = new TextEncoder();
+ global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ body: null,
+ text: () => Promise.resolve(text),
+ headers: new Map([["content-type", "text/plain"]]),
+ }) as unknown as Response,
+ );
+}
+
+// ─── Loading / idle state ─────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview — loading/idle", () => {
+ it("renders loading skeleton (320×80) with aria-label", () => {
+ mockFetchText("hello world");
+ const att = makeAttachment("log.txt", 1024);
+ const { container } = render(
+ ,
+ );
+ const skeleton = container.querySelector('[aria-label]') as HTMLElement;
+ expect(skeleton?.getAttribute("aria-label")).toContain("log.txt");
+ expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
+ expect(skeleton?.style.width).toBe("320px");
+ expect(skeleton?.style.height).toBe("80px");
+ });
+});
+
+// ─── Ready state ───────────────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview — ready", () => {
+ beforeEach(() => {
+ mockFetchText("hello world");
+ });
+
+ it("renders text preview with correct content", async () => {
+ mockFetchText("line1\nline2\nline3");
+ const att = makeAttachment("log.txt");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ const code = document.querySelector("code");
+ expect(code).toBeTruthy();
+ });
+ const code = document.querySelector("code");
+ expect(code?.textContent).toContain("line1");
+ });
+
+ it("shows filename in header", async () => {
+ mockFetchText("hello");
+ const att = makeAttachment("config.yaml");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ // Header should contain the filename
+ const header = document.querySelector("code")?.closest("div");
+ expect(header?.textContent).toContain("config.yaml");
+ });
+
+ it("shows expand button when lines > 10", async () => {
+ const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n");
+ mockFetchText(longText);
+ const att = makeAttachment("long.txt");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ const btn = document.querySelector("button");
+ expect(btn).toBeTruthy();
+ });
+ // Should have a button saying "Show all N lines"
+ const btns = Array.from(document.querySelectorAll("button"));
+ const expandBtn = btns.find((b) => b.textContent?.includes("Show all"));
+ expect(expandBtn).toBeTruthy();
+ expect(expandBtn?.textContent).toContain("15 lines");
+ });
+
+ it("hides expand button when all lines shown (<= 10)", async () => {
+ const shortText = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join("\n");
+ mockFetchText(shortText);
+ const att = makeAttachment("short.txt");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ const btns = Array.from(document.querySelectorAll("button"));
+ const expandBtn = btns.find((b) => b.textContent?.includes("Show all"));
+ expect(expandBtn).toBeUndefined();
+ });
+
+ it("expand button updates button text to all lines", async () => {
+ const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n");
+ mockFetchText(longText);
+ const att = makeAttachment("long.txt");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ const btns = Array.from(document.querySelectorAll("button"));
+ expect(btns.find((b) => b.textContent?.includes("Show all"))).toBeTruthy();
+ });
+ const btns = Array.from(document.querySelectorAll("button"));
+ const expandBtn = btns.find((b) => b.textContent?.includes("Show all")) as HTMLButtonElement;
+ expandBtn.click();
+ await vi.waitFor(() => {
+ const newBtns = Array.from(document.querySelectorAll("button"));
+ expect(newBtns.find((b) => b.textContent?.includes("Show all"))).toBeUndefined();
+ });
+ });
+
+ it("download button calls onDownload", async () => {
+ mockFetchText("hello");
+ const onDownload = vi.fn();
+ const att = makeAttachment("log.txt");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ // Find the download button (aria-label contains "Download")
+ const downloadBtn = document.querySelector('[aria-label^="Download"]') as HTMLButtonElement;
+ expect(downloadBtn).toBeTruthy();
+ downloadBtn.click();
+ expect(onDownload).toHaveBeenCalledWith(att);
+ });
+
+ it("tone=user applies blue/accent border classes", async () => {
+ mockFetchText("hello");
+ const att = makeAttachment("log.txt");
+ const { container } = render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ const rootDiv = container.firstChild as HTMLElement;
+ expect(rootDiv.className).toContain("border-blue-400");
+ expect(rootDiv.className).toContain("accent-strong");
+ });
+
+ it("tone=agent applies neutral border class (no blue)", async () => {
+ mockFetchText("hello");
+ const att = makeAttachment("log.txt");
+ const { container } = render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ const rootDiv = container.firstChild as HTMLElement;
+ expect(rootDiv.className).not.toContain("border-blue-400");
+ });
+});
+
+// ─── Truncated state ───────────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview — truncated", () => {
+ it("shows truncated notice when file exceeds 256 KB", async () => {
+ // Simulate a response where the reader yields chunks until MAX_FETCH_BYTES (256KB)
+ const encoder = new TextEncoder();
+ const bytesNeeded = 256 * 1024;
+ const mockReader = {
+ read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>(
+ async () => {
+ // Return one chunk that's >= 256KB total (we'll cap at MAX_FETCH_BYTES)
+ const chunk = encoder.encode("x".repeat(300 * 1024));
+ return { done: false, value: chunk };
+ },
+ ),
+ cancel: vi.fn(),
+ };
+ const mockBody = { getReader: vi.fn(() => mockReader) };
+ global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ body: mockBody,
+ headers: new Map([["content-type", "text/plain"]]),
+ }) as unknown as Response,
+ );
+ const att = makeAttachment("huge.log");
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ const truncated = document.querySelector("code");
+ expect(truncated).toBeTruthy();
+ });
+ // Should show truncated notice
+ const truncatedNote = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.includes("download full file"),
+ );
+ expect(truncatedNote).toBeTruthy();
+ });
+});
+
+// ─── Error state ───────────────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview — error", () => {
+ it("renders AttachmentChip fallback when fetch fails", async () => {
+ mockFetchError();
+ const onDownload = vi.fn();
+ const att = makeAttachment("broken.txt", 256);
+ render(
+ ,
+ );
+ await vi.waitFor(() => {
+ const chip = document.querySelector("button");
+ expect(chip).toBeTruthy();
+ expect(chip?.textContent).toContain("broken.txt");
+ });
+ const chip = document.querySelector("button") as HTMLButtonElement;
+ chip.click();
+ expect(onDownload).toHaveBeenCalledWith(att);
+ });
+});
+
+// ─── Cleanup ──────────────────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview — cleanup", () => {
+ it("cleans up on unmount", async () => {
+ mockFetchText("hello");
+ const att = makeAttachment("log.txt");
+ const { unmount } = render(
+ ,
+ );
+ await vi.waitFor(() => {
+ expect(document.querySelector("code")).toBeTruthy();
+ });
+ expect(document.querySelector("code")).toBeTruthy();
+ unmount();
+ expect(document.querySelector("code")).toBeNull();
+ });
+});