From 21c18127ab42dc0106195193168da9c4b2fad085 Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:35:34 +0100 Subject: [PATCH] feat(studio): chat attachments, produced files, and previews First advanced-chat tier, a bounded delta onto the Chats core's named seams. lib/attachment-store.ts keeps sent attachment bytes in IndexedDB (bounded to the last 20 sends) so attachments survive refreshes; the hook merges them back on transcript rehydrate. The composer gains the attachment pill and picker with paste/drop; HEIC and oversized images re-encode through a canvas before send. Message bubbles render attachment chips and dedupe produced-file chips off the daemon's real Write arg key via lib/file-meta (which reaches its final exported state here); file-preview.tsx renders image and PDF previews; chat-view hosts the attachment side panel with the object-URL lifecycle. Co-Authored-By: Claude Fable 5 --- .../workspace/_components/chat-input.test.tsx | 51 +++- .../app/workspace/_components/chat-input.tsx | 237 +++++++++++++++++- .../workspace/chat/_components/chat-view.tsx | 146 ++++++++++- .../chat/_components/file-preview.test.tsx | 82 ++++++ .../chat/_components/file-preview.tsx | 152 +++++++++++ .../chat/_components/message-bubble.tsx | 102 +++++++- .../workspace/chat/_components/side-panel.tsx | 176 +++++++++++++ .../features/agent/hooks/use-agent-chat.ts | 176 ++++++++++++- studio/src/lib/attachment-store.ts | 81 ++++++ studio/src/lib/file-meta.ts | 8 +- 10 files changed, 1189 insertions(+), 22 deletions(-) create mode 100644 studio/src/app/workspace/chat/_components/file-preview.test.tsx create mode 100644 studio/src/app/workspace/chat/_components/file-preview.tsx create mode 100644 studio/src/app/workspace/chat/_components/side-panel.tsx create mode 100644 studio/src/lib/attachment-store.ts diff --git a/studio/src/app/workspace/_components/chat-input.test.tsx b/studio/src/app/workspace/_components/chat-input.test.tsx index 51991d9258..80a0397b88 100644 --- a/studio/src/app/workspace/_components/chat-input.test.tsx +++ b/studio/src/app/workspace/_components/chat-input.test.tsx @@ -1,6 +1,6 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { ChatInput } from "./chat-input"; +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AttachmentPill, ChatInput } from "./chat-input"; // jsdom cannot host a live ProseMirror view (TipTap mounts asynchronously and // owns its own capture-phase handlers), so the editor is stubbed to its @@ -19,3 +19,48 @@ describe("ChatInput", () => { ); }); }); + +describe("AttachmentPill", () => { + // jsdom has no object-URL implementation; stub the pair the pill uses. + const createObjectURL = vi.fn(() => "blob:thumb"); + const revokeObjectURL = vi.fn(); + + beforeEach(() => { + createObjectURL.mockClear(); + revokeObjectURL.mockClear(); + vi.stubGlobal("URL", { + ...URL, + createObjectURL, + revokeObjectURL, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows a thumbnail for an image file and revokes its object URL on unmount", async () => { + const file = new File(["x"], "photo.png", { type: "image/png" }); + const { container, unmount } = render( + {}} />, + ); + await waitFor(() => + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "blob:thumb", + ), + ); + unmount(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:thumb"); + }); + + it("shows a file-kind glyph, not a thumbnail, for a non-image file", () => { + const file = new File(["x"], "report.pdf", { type: "application/pdf" }); + const { container } = render( + {}} />, + ); + expect(container.querySelector("img")).toBeNull(); + expect(createObjectURL).not.toHaveBeenCalled(); + expect(container.querySelector('[aria-label="PDF"]')).toBeTruthy(); + expect(container.textContent).toContain("report.pdf"); + }); +}); diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx index dae0827e19..c4840b6b38 100644 --- a/studio/src/app/workspace/_components/chat-input.tsx +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -4,7 +4,15 @@ import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import type { SuggestionProps } from "@tiptap/suggestion"; -import { ArrowUp, Bot, Check, ChevronDown, Mic } from "lucide-react"; +import { + ArrowUp, + Bot, + Check, + ChevronDown, + Mic, + Paperclip, + Plus, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +25,7 @@ import { getAgentMentions, getSlashCommands, } from "@/features/agent/composer-capabilities"; +import { fileKindMeta } from "@/lib/file-meta"; import { cn } from "@/lib/utils"; import { type ComposerMenuItem, @@ -29,7 +38,9 @@ interface ChatInputProps { placeholder?: string; rows?: number; compact?: boolean; - onSend?: (content: string) => void; + onSend?: (content: string, files?: File[]) => void; + /** Preview an attached file in the canvas panel. */ + onPreviewAttachment?: (file: File) => void; disabled?: boolean; isStreaming?: boolean; appendText?: string | null; @@ -49,6 +60,42 @@ interface ChatInputProps { const GHOST_TRIGGER_CLASS = "h-7 gap-1 rounded-full px-2.5 text-sm font-normal text-foreground bg-transparent hover:bg-zinc-200 dark:hover:bg-zinc-700 border-0 shadow-none"; +/** The composer's "+" button: opens the file picker directly to attach files. */ +function FilesDropdown({ + onFilesSelected, +}: { + onFilesSelected?: (files: File[]) => void; +}) { + const fileInputRef = useRef(null); + + return ( + <> + { + const selected = e.target.files; + if (selected && selected.length > 0) { + onFilesSelected?.(Array.from(selected)); + } + e.target.value = ""; + }} + /> + + + ); +} + /** * Controls whether the agent draws on (and writes to) its long-term memory for * this conversation. A pill matching the model selector, opening a small On/Off @@ -205,6 +252,74 @@ function commandMenuItems(query: string): ComposerMenuItem[] { })); } +/** + * One attached-file chip. Image files show a small thumbnail of the file + * itself (an object URL — cheaper than a data-URL read) before the name; the + * URL is revoked when the pill unmounts (remove/send), so attach/remove + * cycles never leak blob URLs. Non-image files (and an image whose thumbnail + * has not resolved yet) show their file-kind glyph — image/PDF/code — with + * the paperclip as the fallback. Exported for its unit test. + */ +export function AttachmentPill({ + file, + onRemove, + onPreview, +}: { + file: File; + onRemove: () => void; + /** Opens the file in the canvas panel (main composer only). */ + onPreview?: () => void; +}) { + const [previewUrl, setPreviewUrl] = useState(null); + useEffect(() => { + if (!file.type.startsWith("image/")) { + setPreviewUrl(null); + return; + } + const url = URL.createObjectURL(file); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); + const kind = fileKindMeta(file.name, file.type); + const KindIcon = kind.icon; + + return ( + + + + + ); +} + /** Open autocomplete menu state, mirrored from TipTap's suggestion lifecycle. */ interface ComposerMenu { kind: "agent" | "command"; @@ -259,12 +374,18 @@ export function ChatInput({ onAppendConsumed, initialText, onInitialTextConsumed, + onPreviewAttachment, }: ChatInputProps) { const placeholder = placeholderProp ?? DEFAULT_PLACEHOLDER; // Plain-text mirror of the editor, kept in sync via onUpdate. Used only for // "is there something to send" checks; the editor document is the source of // truth for the message itself. const [text, setText] = useState(""); + const [attachedFiles, setAttachedFiles] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + const [isWindowDrag, setIsWindowDrag] = useState(false); + const dragCountRef = useRef(0); + const windowDragCountRef = useRef(0); // Autocomplete menu, mirrored from TipTap's suggestion lifecycle so we can // render the same full-width popover the pre-TipTap composer used. `menuRef` @@ -366,6 +487,34 @@ export function ChatInput({ } }, [initialText, onInitialTextConsumed, editor]); + useEffect(() => { + const onEnter = (e: DragEvent) => { + if (e.dataTransfer?.types.includes("Files")) { + windowDragCountRef.current++; + setIsWindowDrag(true); + } + }; + const onLeave = () => { + windowDragCountRef.current--; + if (windowDragCountRef.current <= 0) { + windowDragCountRef.current = 0; + setIsWindowDrag(false); + } + }; + const onDrop = () => { + windowDragCountRef.current = 0; + setIsWindowDrag(false); + }; + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragleave", onLeave); + window.addEventListener("drop", onDrop); + return () => { + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("drop", onDrop); + }; + }, []); + const voice = useVoiceInput( useCallback( (transcript: string) => { @@ -379,10 +528,12 @@ export function ChatInput({ const handleSend = useCallback(() => { const trimmed = editor ? composerText(editor) : ""; if (!trimmed || disabled) return; - onSend?.(trimmed); + const files = attachedFiles.length > 0 ? attachedFiles : undefined; + onSend?.(trimmed, files); editor?.commands.clearContent(); setText(""); - }, [editor, disabled, onSend]); + setAttachedFiles([]); + }, [editor, disabled, onSend, attachedFiles]); // Menu nav + Enter-to-send are wired with a native capture-phase keydown // listener on the editor DOM, re-subscribed each render with fresh closures @@ -414,7 +565,40 @@ export function ChatInput({ const hasText = text.trim().length > 0; return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments +
{ + e.preventDefault(); + dragCountRef.current++; + setIsDragOver(true); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(e) => { + e.preventDefault(); + dragCountRef.current--; + if (dragCountRef.current <= 0) { + dragCountRef.current = 0; + setIsDragOver(false); + } + }} + onDrop={(e) => { + e.preventDefault(); + dragCountRef.current = 0; + setIsDragOver(false); + // Only images can cross the wire (the daemon's prompt parts are + // image/audio only), so only images attach. + const droppedFiles = Array.from(e.dataTransfer.files).filter((f) => + f.type.startsWith("image/"), + ); + if (droppedFiles.length > 0) { + setAttachedFiles((prev) => [...prev, ...droppedFiles]); + } + }} + className="relative rounded-2xl bg-zinc-50 dark:bg-zinc-900" + > {/* Autocomplete popover (agent @-mentions or slash commands), floating above the input box. Driven by TipTap's suggestion lifecycle. */} {menu && menu.items.length > 0 && ( @@ -460,7 +644,16 @@ export function ChatInput({ Has its own rounded border. The toolbar row below has a matching border on its top/sides/bottom; the two borders meet along the input box's bottom edge, sharing a single visible line. */} -
+
{voice.isListening && (
@@ -469,6 +662,31 @@ export function ChatInput({
)} + {isDragOver && ( +
+
+ + Drop files to attach +
+
+ )} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((f, i) => ( + onPreviewAttachment(f) : undefined + } + // biome-ignore lint/suspicious/noArrayIndexKey: files may share names + key={`${f.name}-${i}`} + file={f} + onRemove={() => + setAttachedFiles((prev) => prev.filter((_, j) => j !== i)) + } + /> + ))} +
+ )}
{/* TipTap composer: resolved @agent / /skill mentions are atomic green chips (Backspace removes a whole chip); Enter sends and @@ -482,8 +700,13 @@ export function ChatInput({
- {/* Bottom row: mic on the left; send right */} + {/* Bottom row: attach (+), mic on the left; send right */}
+ + setAttachedFiles((prev) => [...prev, ...newFiles]) + } + /> {voice.isSupported && ( - {sidebarOpen ? "Hide sidebar" : "Show sidebar"} + {panelHoldsSidebarSlot + ? "Close preview" + : sidebarOpen + ? "Hide sidebar" + : "Show sidebar"} ); return (
-
+
{sidebarSide === "left" && sidebarToggle} {isStreaming && ( @@ -412,6 +519,9 @@ export function ChatView({ + setPanel({ kind: "attachment", attachment }) + } botName={botName} showActivity={showActivity} /> @@ -480,6 +590,7 @@ export function ChatView({ ) : (
+ {panel !== null && ( + + )}
); } + +/** Renders the right-hand panel for the active kind. */ +function SidePanelForKind({ + panel, + onClose, + maximized, + onToggleMaximize, +}: { + panel: ActivePanel; + onClose: () => void; + maximized: boolean; + onToggleMaximize: () => void; +}) { + const shared = { onClose, maximized, onToggleMaximize }; + switch (panel.kind) { + case "attachment": + return ; + } +} diff --git a/studio/src/app/workspace/chat/_components/file-preview.test.tsx b/studio/src/app/workspace/chat/_components/file-preview.test.tsx new file mode 100644 index 0000000000..c647d54556 --- /dev/null +++ b/studio/src/app/workspace/chat/_components/file-preview.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FilePreview, previewKind } from "./file-preview"; + +const PNG_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +/** + * FilePreview routes on the shared file-kind classification: images and PDFs + * render from a url/data-URI source, markdown as prose, recognised source + * files through the code viewer, and only a file with nothing displayable + * hits the "No preview available" floor. + */ +describe("previewKind", () => { + it("routes each kind to its renderer", () => { + expect(previewKind("photo.png", undefined, PNG_DATA_URI)).toBe("image"); + expect( + previewKind("chart.svg", undefined, "data:image/svg+xml;utf8,x"), + ).toBe("image"); + expect( + previewKind("report.pdf", undefined, "data:application/pdf;base64,x"), + ).toBe("pdf"); + expect(previewKind("README.md", "# Hello")).toBe("markdown"); + expect(previewKind("logger.ts", "const x = 1;")).toBe("code"); + expect(previewKind("notes.log", "plain text")).toBe("text"); + }); + + it("accepts a data-URI riding content for binary kinds", () => { + expect(previewKind("photo.png", PNG_DATA_URI)).toBe("image"); + }); + + it("floors to none only when nothing is displayable", () => { + expect(previewKind("photo.png")).toBe("none"); + expect(previewKind("report.pdf")).toBe("none"); + expect(previewKind("anything.txt")).toBe("none"); + // An image whose content is NOT displayable text keeps the text fallback + // instead of a broken . + expect(previewKind("photo.png", "not a data uri")).toBe("text"); + }); +}); + +describe("FilePreview", () => { + it("renders an image from its url", () => { + render(); + const img = screen.getByAltText("photo.png"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", PNG_DATA_URI); + }); + + it("renders a PDF in an iframe behind a blob: URL", () => { + // data:application/pdf iframes render blank in Chrome/Safari, so the + // frame must swap the same bytes onto a blob: URL. + const src = "data:application/pdf;base64,JVBERi0="; + render(); + const frame = screen.getByTitle("report.pdf"); + expect(frame.tagName).toBe("IFRAME"); + expect(frame.getAttribute("src")).toMatch(/^blob:/); + }); + + it("renders markdown as prose, not raw source", () => { + render(); + expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument(); + }); + + it("renders recognised source files through the code viewer", async () => { + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("[data-highlighted='true']")).toBeTruthy(), + ); + expect(container.querySelectorAll("tbody tr").length).toBe(2); + expect(container.textContent).toContain("const x = 1;"); + }); + + it("shows the no-preview floor when nothing is displayable", () => { + render(); + expect( + screen.getByText("No preview available for this file"), + ).toBeInTheDocument(); + }); +}); diff --git a/studio/src/app/workspace/chat/_components/file-preview.tsx b/studio/src/app/workspace/chat/_components/file-preview.tsx new file mode 100644 index 0000000000..51e6a23eab --- /dev/null +++ b/studio/src/app/workspace/chat/_components/file-preview.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + CODE_FILE_EXTENSIONS, + extensionOf, + fileKindMeta, +} from "@/lib/file-meta"; +import { CodeBlock } from "./code-block"; +import { langForExtension } from "./code-highlighter"; +import { mdComponents } from "./markdown-components"; + +/** + * Renders a file's contents in the right-hand preview panel, choosing how to + * display it from the file name (the kind classification is shared with the + * file-kind icons in src/lib/file-meta.ts): + * + * - Images render inline via when a displayable source exists (a `url` + * — data: URI or fetchable — or `content` that is itself a data: URI). + * - PDFs render in an