From 5eb0981dc70c5fd32316a8e6c28e139d7e62963a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:59:47 +0100 Subject: [PATCH] feat: convert large pasted text into file attachments (#23379) --- coderd/exp_chats.go | 38 +- coderd/exp_chats_test.go | 69 +++- .../ChatMessageInput/ChatMessageInput.tsx | 219 ++++++++--- .../ChatMessageInput/pasteHelpers.ts | 63 ++++ .../AgentsPage/components/AgentChatInput.tsx | 144 ++++++- .../AgentsPage/components/AgentCreateForm.tsx | 2 + .../ConversationTimeline.stories.tsx | 151 +++++++- .../AgentDetail/ConversationTimeline.tsx | 353 ++++++++++++------ .../components/AgentDetailContent.tsx | 4 +- .../components/AttachmentPreview.stories.tsx | 176 ++++++++- .../components/TextPreviewDialog.stories.tsx | 59 +++ .../components/TextPreviewDialog.tsx | 32 ++ .../AgentsPage/hooks/useFileAttachments.ts | 85 +++-- .../utils/fetchTextAttachment.test.ts | 54 +++ .../AgentsPage/utils/fetchTextAttachment.ts | 46 +++ .../AgentsPage/utils/pasteHelpers.test.ts | 150 ++++++++ 16 files changed, 1445 insertions(+), 200 deletions(-) create mode 100644 site/src/components/ChatMessageInput/pasteHelpers.ts create mode 100644 site/src/pages/AgentsPage/components/TextPreviewDialog.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/TextPreviewDialog.tsx create mode 100644 site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts create mode 100644 site/src/pages/AgentsPage/utils/fetchTextAttachment.ts create mode 100644 site/src/pages/AgentsPage/utils/pasteHelpers.test.ts diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index ad9c2b7b0c..208ebac73d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -14,6 +14,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strconv" "strings" "sync" @@ -2567,9 +2568,21 @@ var allowedChatFileMIMETypes = map[string]bool{ "image/jpeg": true, "image/gif": true, "image/webp": true, + "text/plain": true, "image/svg+xml": false, // SVG can contain scripts. } +func allowedChatFileMIMETypesStr() string { + var types []string + for t, allowed := range allowedChatFileMIMETypes { + if allowed { + types = append(types, t) + } + } + slices.Sort(types) + return strings.Join(types, ", ") +} + var ( webpMagicRIFF = []byte("RIFF") webpMagicWEBP = []byte("WEBP") @@ -3042,7 +3055,7 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { if allowed, ok := allowedChatFileMIMETypes[contentType]; !ok || !allowed { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Unsupported file type.", - Detail: "Allowed types: image/png, image/jpeg, image/gif, image/webp.", + Detail: fmt.Sprintf("Allowed types: %s.", allowedChatFileMIMETypesStr()), }) return } @@ -3061,13 +3074,32 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { return } - // Verify the actual content matches a safe image type so that + // Verify the actual content matches an allowed file type so that // a client cannot spoof Content-Type to serve active content. detected := detectChatFileType(peek) + if mediaType, _, err := mime.ParseMediaType(detected); err == nil { + detected = mediaType + } + if contentType == "text/plain" && strings.HasPrefix(detected, "text/") { + detected = "text/plain" + } if allowed, ok := allowedChatFileMIMETypes[detected]; !ok || !allowed { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Unsupported file type.", - Detail: "Allowed types: image/png, image/jpeg, image/gif, image/webp.", + Detail: fmt.Sprintf("Allowed types: %s.", allowedChatFileMIMETypesStr()), + }) + return + } + // The mismatch check below is security-critical: it prevents a text + // body from being uploaded under an image Content-Type (or vice + // versa) now that both text/plain and image types are in the + // allowlist. Combined with the X-Content-Type-Options: nosniff + // header applied globally, this ensures browsers respect the + // stored MIME type. + if detected != contentType { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "File content type does not match Content-Type header.", + Detail: fmt.Sprintf("Header declared %q but file content was detected as %q.", contentType, detected), }) return } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index e1f00af062..efa27cb466 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3901,13 +3901,25 @@ func TestPostChatFile(t *testing.T) { require.NotEqual(t, uuid.Nil, resp.ID) }) + t.Run("Success/TextPlain", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + data := []byte("This is a test paste.\nWith multiple lines.\n") + resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "test.txt", bytes.NewReader(data)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, resp.ID) + }) + t.Run("UnsupportedContentType", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "test.txt", bytes.NewReader([]byte("hello"))) + _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "application/pdf", "test.pdf", bytes.NewReader([]byte("%PDF-1.7"))) requireSDKError(t, err, http.StatusBadRequest) }) @@ -3929,9 +3941,32 @@ func TestPostChatFile(t *testing.T) { // Header says PNG but body is plain text. _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader([]byte("hello world"))) - requireSDKError(t, err, http.StatusBadRequest) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "does not match") }) + t.Run("ContentSniffingRejectsPNGAsText", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + // Valid 1x1 PNG declared as text/plain should still be rejected. + data := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x04, 0x00, 0x00, 0x00, 0xB5, 0x1C, 0x0C, + 0x02, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, + 0x54, 0x78, 0xDA, 0x63, 0xFC, 0xFF, 0x1F, 0x00, + 0x03, 0x03, 0x02, 0x00, 0xEF, 0x9A, 0x1A, 0x2A, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, + 0xAE, 0x42, 0x60, 0x82, + } + _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "test.txt", bytes.NewReader(data)) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "does not match") + }) t.Run("TooLarge", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -3945,6 +3980,18 @@ func TestPostChatFile(t *testing.T) { require.Error(t, err) }) + t.Run("Success/TextPlainHTMLLikeContent", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + data := []byte("\n

Paste me as plain text.

\n") + resp, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "snippet.txt", bytes.NewReader(data)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, resp.ID) + }) + t.Run("MissingOrganization", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -3955,6 +4002,7 @@ func TestPostChatFile(t *testing.T) { res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats/files", bytes.NewReader(data), func(r *http.Request) { r.Header.Set("Content-Type", "image/png") }) + require.NoError(t, err) defer res.Body.Close() err = codersdk.ReadBodyAsError(res) @@ -4028,6 +4076,22 @@ func TestGetChatFile(t *testing.T) { require.Equal(t, data, got) }) + t.Run("Success/TextPlain", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + data := []byte("This is a test paste.\nWith multiple lines.\n") + uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "test.txt", bytes.NewReader(data)) + require.NoError(t, err) + + got, contentType, err := client.GetChatFile(ctx, uploaded.ID) + require.NoError(t, err) + require.Equal(t, "text/plain", contentType) + require.Equal(t, data, got) + }) + t.Run("CacheHeaders", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -4044,6 +4108,7 @@ func TestGetChatFile(t *testing.T) { defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, "private, max-age=31536000, immutable", res.Header.Get("Cache-Control")) + require.Equal(t, "nosniff", res.Header.Get("X-Content-Type-Options")) require.Contains(t, res.Header.Get("Content-Disposition"), "inline") require.Contains(t, res.Header.Get("Content-Disposition"), "test.png") }) diff --git a/site/src/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/components/ChatMessageInput/ChatMessageInput.tsx index 8c7f0afa9f..e23e6ba978 100644 --- a/site/src/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/components/ChatMessageInput/ChatMessageInput.tsx @@ -16,6 +16,7 @@ import { COMMAND_PRIORITY_HIGH, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, + KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, type LexicalEditor, PASTE_COMMAND, @@ -36,6 +37,13 @@ import { $createFileReferenceNode, FileReferenceNode, } from "./FileReferenceNode"; +import { + createPasteFile, + getPasteDataTransfer, + getPastedPlainText, + isLargePaste, + type PasteCommandEvent, +} from "./pasteHelpers"; // Blocks Cmd+B/I/U and element formatting shortcuts so the editor // stays plain-text only. @@ -60,76 +68,170 @@ const DisableFormattingPlugin: FC = memo(function DisableFormattingPlugin() { return null; }); +function insertPlainTextIntoEditor(editor: LexicalEditor, text: string) { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText(text); + return; + } + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild) { + if (lastChild.getType() === "paragraph") { + const paragraph = lastChild as ParagraphNode; + const textNode = $createTextNode(text); + paragraph.append(textNode); + textNode.selectEnd(); + } else { + const textNode = $createTextNode(text); + lastChild.insertAfter(textNode); + textNode.selectEnd(); + } + } else { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + root.append(paragraph); + textNode.selectEnd(); + } + }); +} + // Intercepts paste events and inserts clipboard content as plain text, -// stripping any rich-text formatting. Image files are forwarded to -// the parent via the onFilePaste callback instead of being inserted. +// stripping any rich-text formatting. Image files and large pasted text +// are forwarded to the parent via the onFilePaste callback instead. +// +// Cmd/Ctrl+Shift+V ("paste and match style") is treated as an explicit +// user intent to paste inline, so the large-paste-to-attachment +// conversion is bypassed for that shortcut. const PasteSanitizationPlugin: FC<{ onFilePaste?: (file: File) => void; -}> = memo(function PasteSanitizationPlugin({ onFilePaste }) { + allowTextAttachmentPaste?: boolean; +}> = memo(function PasteSanitizationPlugin({ + onFilePaste, + allowTextAttachmentPaste = true, +}) { const [editor] = useLexicalComposerContext(); + const plainTextPasteRef = useRef(false); + const plainTextPasteTimeoutRef = useRef(null); useEffect(() => { - return editor.registerCommand( - PASTE_COMMAND, - (event: ClipboardEvent | null) => { - if (!event) return false; - const clipboardData = event.clipboardData; - if (!clipboardData) return false; - - // Check for image files in the clipboard (e.g. pasted - // screenshots). Forward them to the parent via callback - // instead of inserting text. - if (onFilePaste && clipboardData.files.length > 0) { - const images = Array.from(clipboardData.files).filter((f) => - f.type.startsWith("image/"), - ); - if (images.length > 0) { - event.preventDefault(); - for (const file of images) { - onFilePaste(file); + const unregister = mergeRegister( + // Detect Cmd/Ctrl+Shift+V so the PASTE_COMMAND handler + // can bypass attachment conversion for that shortcut. + editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + if ( + event.shiftKey && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "v" + ) { + plainTextPasteRef.current = true; + if (plainTextPasteTimeoutRef.current !== null) { + window.clearTimeout(plainTextPasteTimeoutRef.current); } + plainTextPasteTimeoutRef.current = window.setTimeout(() => { + plainTextPasteRef.current = false; + plainTextPasteTimeoutRef.current = null; + }, 500); + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + + editor.registerCommand( + PASTE_COMMAND, + (event: PasteCommandEvent | null) => { + if (!event) return false; + + const isPlainTextPaste = plainTextPasteRef.current; + plainTextPasteRef.current = false; + if (plainTextPasteTimeoutRef.current !== null) { + window.clearTimeout(plainTextPasteTimeoutRef.current); + plainTextPasteTimeoutRef.current = null; + } + const isNativePaste = "clipboardData" in event; + const dataTransfer = getPasteDataTransfer(event); + + // Some browsers deliver paste as beforeinput with + // payload on `event.data` / `dataTransfer` instead of + // a native ClipboardEvent. Consume that payload here so + // plain-text paste shortcuts never become a no-op. + if (!isNativePaste) { + const text = getPastedPlainText(event, dataTransfer); + if (!text) { + return false; + } + if ( + !isPlainTextPaste && + allowTextAttachmentPaste && + onFilePaste && + isLargePaste(text) + ) { + event.preventDefault(); + onFilePaste(createPasteFile(text)); + return true; + } + event.preventDefault(); + insertPlainTextIntoEditor(editor, text); return true; } - } + // Native paste event (ClipboardEvent). - const text = clipboardData.getData("text/plain"); - if (!text) return false; - - event.preventDefault(); - - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - selection.insertText(text); - } else { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild) { - if (lastChild.getType() === "paragraph") { - const paragraph = lastChild as ParagraphNode; - const textNode = $createTextNode(text); - paragraph.append(textNode); - textNode.selectEnd(); - } else { - const textNode = $createTextNode(text); - lastChild.insertAfter(textNode); - textNode.selectEnd(); + // Check for image files in the clipboard (e.g. + // pasted screenshots). Forward them to the parent + // via callback instead of inserting text. + if (onFilePaste && dataTransfer?.files.length) { + const images = Array.from(dataTransfer.files).filter((f) => + f.type.startsWith("image/"), + ); + if (images.length > 0) { + event.preventDefault(); + for (const file of images) { + onFilePaste(file); } - } else { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(text); - paragraph.append(textNode); - root.append(paragraph); - textNode.selectEnd(); + return true; } } - }); - return true; - }, - COMMAND_PRIORITY_HIGH, + const text = getPastedPlainText(event, dataTransfer); + if (!text) return false; + + // Convert large pastes to file attachments, but + // only for normal Cmd+V. Cmd+Shift+V is the + // user’s explicit "paste inline" escape hatch. + if ( + !isPlainTextPaste && + allowTextAttachmentPaste && + onFilePaste && + isLargePaste(text) + ) { + event.preventDefault(); + onFilePaste(createPasteFile(text)); + return true; + } + + // Small paste (or Cmd+Shift+V): insert as plain text. + event.preventDefault(); + insertPlainTextIntoEditor(editor, text); + + return true; + }, + COMMAND_PRIORITY_HIGH, + ), ); - }, [editor, onFilePaste]); + + return () => { + if (plainTextPasteTimeoutRef.current !== null) { + window.clearTimeout(plainTextPasteTimeoutRef.current); + plainTextPasteTimeoutRef.current = null; + } + unregister(); + }; + }, [allowTextAttachmentPaste, editor, onFilePaste]); return null; }); @@ -284,6 +386,7 @@ interface ChatMessageInputProps rows?: number; onEnter?: () => void; onFilePaste?: (file: File) => void; + allowTextAttachmentPaste?: boolean; disabled?: boolean; autoFocus?: boolean; "aria-label"?: string; @@ -313,6 +416,7 @@ const ChatMessageInput = memo( rows, onEnter, onFilePaste, + allowTextAttachmentPaste, disabled, autoFocus, "aria-label": ariaLabel, @@ -529,7 +633,10 @@ const ChatMessageInput = memo( /> - + diff --git a/site/src/components/ChatMessageInput/pasteHelpers.ts b/site/src/components/ChatMessageInput/pasteHelpers.ts new file mode 100644 index 0000000000..5d1b8fc479 --- /dev/null +++ b/site/src/components/ChatMessageInput/pasteHelpers.ts @@ -0,0 +1,63 @@ +export type PasteCommandEvent = ClipboardEvent | InputEvent; + +/** + * Returns clipboard-style data transfer content from the different event + * shapes Lexical uses for paste commands. + */ +export function getPasteDataTransfer( + event: PasteCommandEvent, +): DataTransfer | null { + if ("clipboardData" in event && event.clipboardData) { + return event.clipboardData; + } + if ("dataTransfer" in event && event.dataTransfer) { + return event.dataTransfer; + } + return null; +} + +/** + * Extracts plain text from a paste command, including beforeinput-based + * plain-text paste shortcuts such as Cmd/Ctrl+Shift+V. + */ +export function getPastedPlainText( + event: PasteCommandEvent, + dataTransfer?: DataTransfer | null, +): string { + const text = dataTransfer?.getData("text/plain"); + if (text) { + return text; + } + return "data" in event && typeof event.data === "string" ? event.data : ""; +} + +/** + * Determines whether a pasted text should be treated as a file + * attachment rather than inline editor content. + * + * The heuristic: text with 10+ lines OR 1000+ characters is + * considered "large" and should become an attachment. + */ +export function isLargePaste(text: string): boolean { + const LINE_THRESHOLD = 10; + const CHAR_THRESHOLD = 1000; + // A trailing newline intentionally counts as another line here. + // Users can use Cmd/Ctrl+Shift+V when they need to force an + // inline paste instead of creating an attachment. + const lineCount = text.split("\n").length; + return lineCount >= LINE_THRESHOLD || text.length >= CHAR_THRESHOLD; +} + +/** + * Creates a synthetic File object from pasted text for the + * attachment upload pipeline. + */ +export function createPasteFile(text: string): File { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad( + now.getDate(), + )}-${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + const fileName = `pasted-text-${timestamp}.txt`; + return new File([text], fileName, { type: "text/plain" }); +} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index db00aad0df..1dcb072ea5 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -20,6 +20,7 @@ import { AlertTriangleIcon, ArrowUpIcon, CheckIcon, + ClipboardPasteIcon, ImageIcon, MicIcon, PencilIcon, @@ -37,10 +38,15 @@ import { } from "react"; import { cn } from "utils/cn"; import { isMobileViewport } from "utils/mobile"; +import { + fetchTextAttachmentContent, + formatTextAttachmentPreview, +} from "../utils/fetchTextAttachment"; import { formatProviderLabel } from "../utils/modelOptions"; import { ImageLightbox } from "./ImageLightbox"; import { MCPServerPicker } from "./MCPServerPicker"; import { QueuedMessagesList } from "./QueuedMessagesList"; +import { TextPreviewDialog } from "./TextPreviewDialog"; export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput"; @@ -111,9 +117,11 @@ interface AgentChatInputProps { contextUsage?: AgentContextUsage | null; attachments?: readonly File[]; onAttach?: (files: File[]) => void; - onRemoveAttachment?: (index: number) => void; + onRemoveAttachment?: (attachment: number | File) => void; uploadStates?: Map; previewUrls?: Map; + textContents?: Map; + onTextPreview?: (content: string, fileName: string) => void; // MCP Server picker. mcpServers?: readonly TypesGen.MCPServerConfig[]; selectedMCPServerIds?: readonly string[]; @@ -261,18 +269,74 @@ export const ImageThumbnail: FC<{ /** Renders a horizontal strip of attachment thumbnails above the input. */ export const AttachmentPreview: FC<{ attachments: readonly File[]; - onRemove: (index: number) => void; + onRemove: (attachment: number | File) => void; uploadStates?: Map; previewUrls?: Map; onPreview?: (url: string) => void; -}> = ({ attachments, onRemove, uploadStates, previewUrls, onPreview }) => { + textContents?: Map; + onTextPreview?: (content: string, fileName: string) => void; + onInlineText?: (file: File, content?: string) => void; +}> = ({ + attachments, + onRemove, + uploadStates, + previewUrls, + onPreview, + textContents, + onTextPreview, + onInlineText, +}) => { + const textAttachmentLoadControllerRef = useRef(null); + + useEffect(() => { + return () => textAttachmentLoadControllerRef.current?.abort(); + }, []); + if (attachments.length === 0) return null; + const loadTextAttachmentContent = async ( + content: string | undefined, + fileId: string | undefined, + ): Promise => { + textAttachmentLoadControllerRef.current?.abort(); + if (content !== undefined || !fileId) { + textAttachmentLoadControllerRef.current = null; + return content; + } + const controller = new AbortController(); + textAttachmentLoadControllerRef.current = controller; + try { + const fetchedContent = await fetchTextAttachmentContent( + fileId, + controller.signal, + ); + if (textAttachmentLoadControllerRef.current === controller) { + textAttachmentLoadControllerRef.current = null; + } + return fetchedContent; + } catch (err) { + if (textAttachmentLoadControllerRef.current === controller) { + textAttachmentLoadControllerRef.current = null; + } + if (err instanceof Error && err.name === "AbortError") { + return undefined; + } + console.error("Failed to load text attachment:", err); + return undefined; + } + }; + return (
{attachments.map((file, index) => { const uploadState = uploadStates?.get(file); const previewUrl = previewUrls?.get(file) ?? ""; + const textContent = textContents?.get(file); + const textFileId = + uploadState?.status === "uploaded" ? uploadState.fileId : undefined; + const hasTextAttachment = + file.type === "text/plain" && + (textContent !== undefined || textFileId !== undefined); return (
+ ) : hasTextAttachment ? ( + ) : (
{file.name.split(".").pop()?.toUpperCase() || "FILE"}
)} + {hasTextAttachment && ( + + )} {uploadState?.status === "uploading" && (
@@ -319,7 +418,7 @@ export const AttachmentPreview: FC<{
), ], + beforeEach: () => { + mockTextAttachmentFetch(); + }, }; export default meta; type Story = StoryObj; @@ -150,6 +196,109 @@ export const UserMessageWithFileIdImage: Story = { }, }; +export const UserMessageWithTextAttachment: Story = { + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Here is some context from our docs:" }, + { + type: "file", + file_id: "storybook-test-text", + media_type: "text/plain", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const textButton = await canvas.findByRole("button", { + name: "View text attachment", + }); + expect(textButton).toBeInTheDocument(); + expect(textButton).toHaveTextContent(/Pasted text/i); + await userEvent.click(textButton); + expect( + await canvas.findByText(/Quarterly revenue increased 18%/i), + ).toBeInTheDocument(); + }, +}; + +export const UserMessageWithMultipleTextAttachments: Story = { + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + created_at: "2025-01-15T10:00:00Z", + role: "user", + content: [ + { type: "text", text: "Here are several context files:" }, + { + type: "file", + file_id: "storybook-text-1", + media_type: "text/plain", + }, + { + type: "file", + file_id: "storybook-text-2", + media_type: "text/plain", + }, + { + type: "file", + file_id: "storybook-text-3", + media_type: "text/plain", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const textButtons = await canvas.findAllByRole("button", { + name: "View text attachment", + }); + expect(textButtons).toHaveLength(3); + }, +}; + +export const UserMessageWithTextAttachmentOnly: Story = { + args: { + ...defaultArgs, + parsedMessages: parseMessagesWithMergedTools([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { + type: "file", + file_id: "storybook-text-only", + media_type: "text/plain", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const textButton = await canvas.findByRole("button", { + name: "View text attachment", + }); + expect(textButton).toHaveTextContent(/Pasted text/i); + await userEvent.click(textButton); + expect( + await canvas.findByText(/Runbook note: restart the worker/i), + ).toBeInTheDocument(); + }, +}; + /** Text-only messages must not produce spurious image thumbnails. */ export const UserMessageTextOnly: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 15738d26e5..17dc421d88 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -17,12 +17,13 @@ import { TooltipContent, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { PencilIcon } from "lucide-react"; +import { FileTextIcon, PencilIcon } from "lucide-react"; import { type FC, Fragment, memo, type ReactNode, + useEffect, useLayoutEffect, useRef, useState, @@ -30,9 +31,15 @@ import { import { Link } from "react-router"; import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; +import { + decodeInlineTextAttachment, + fetchTextAttachmentContent, + formatTextAttachmentPreview, +} from "../../utils/fetchTextAttachment"; import type { ChatDetailError } from "../../utils/usageLimitMessage"; import { ImageThumbnail } from "../AgentChatInput"; import { ImageLightbox } from "../ImageLightbox"; +import { TextPreviewDialog } from "../TextPreviewDialog"; import { useSmoothStreamingText } from "./SmoothText"; import type { MergedTool, @@ -92,6 +99,7 @@ type RenderBlockListParams = { subagentTitles?: Map; subagentStatusOverrides?: Map; onImageClick?: (src: string) => void; + onTextFileClick?: (content: string) => void; urlTransform?: UrlTransform; }; @@ -112,6 +120,140 @@ const SmoothedResponse: FC<{ return {visibleText}; }; +const InlineTextAttachmentButton: FC<{ + content: string; + onPreview?: (content: string) => void; +}> = ({ content, onPreview }) => { + return ( + + ); +}; + +const TextAttachmentButton: FC<{ + fileId: string; + onPreview?: (content: string) => void; +}> = ({ fileId, onPreview }) => { + const [content, setContent] = useState(null); + const controllerRef = useRef(null); + + useEffect(() => { + return () => controllerRef.current?.abort(); + }, []); + + return ( + { + if (content !== null) { + onPreview?.(content); + return; + } + + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + + let fetchedContent: string; + try { + fetchedContent = await fetchTextAttachmentContent( + fileId, + controller.signal, + ); + } catch (err) { + if (controllerRef.current === controller) { + controllerRef.current = null; + } + if (err instanceof Error && err.name === "AbortError") { + return; + } + console.error("Failed to load text attachment:", err); + return; + } + + if (controllerRef.current === controller) { + controllerRef.current = null; + } + setContent(fetchedContent); + onPreview?.(fetchedContent); + }} + /> + ); +}; + +type FileRenderBlock = Extract; + +type RenderFileBlockParams = { + block: FileRenderBlock; + key: string; + onImageClick?: (src: string) => void; + onTextFileClick?: (content: string) => void; +}; + +const renderFileBlock = ({ + block, + key, + onImageClick, + onTextFileClick, +}: RenderFileBlockParams): ReactNode => { + if (block.media_type === "text/plain") { + if (block.file_id) { + return ( + + ); + } + if (block.data != null) { + return ( + + ); + } + } + if (!block.media_type.startsWith("image/")) { + return null; + } + const src = block.file_id + ? `/api/experimental/chats/files/${block.file_id}` + : `data:${block.media_type};base64,${block.data}`; + return ( + + ); +}; + type RenderBlockListResult = { elements: ReactNode[]; renderedToolIDs: ReadonlySet; @@ -125,6 +267,7 @@ function renderBlockList({ subagentTitles, subagentStatusOverrides, onImageClick, + onTextFileClick, urlTransform, }: RenderBlockListParams): RenderBlockListResult { const renderedToolIDs = new Set(); @@ -207,30 +350,12 @@ function renderBlockList({ ); } case "file": - if (block.media_type.startsWith("image/")) { - const src = block.file_id - ? `/api/experimental/chats/files/${block.file_id}` - : `data:${block.media_type};base64,${block.data}`; - return ( - - ); - } - return null; + return renderFileBlock({ + block, + key: `${keyPrefix}-file-${block.file_id ?? index}`, + onImageClick, + onTextFileClick, + }); case "sources": return ( (null); + const [previewText, setPreviewText] = useState(null); const toolByID = new Map(parsed.tools.map((tool) => [tool.id, tool])); if ( @@ -316,6 +442,16 @@ const ChatMessageItem = memo<{ ) : []; + const userFileBlocks = isUser + ? parsed.blocks.filter( + (b): b is Extract => b.type === "file", + ) + : []; + + const hasUserMessageBody = + userInlineContent.length > 0 || Boolean(parsed.markdown?.trim()); + const hasFileBlocks = userFileBlocks.length > 0; + const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", }; @@ -324,6 +460,7 @@ const ChatMessageItem = memo<{ toolByID, keyPrefix: String(message.id), onImageClick: setPreviewImage, + onTextFileClick: (content) => setPreviewText(content), urlTransform, }); const remainingTools = parsed.tools.filter( @@ -355,93 +492,89 @@ const ChatMessageItem = memo<{ } >
-
- - {userInlineContent.length > 0 - ? userInlineContent.map((block, i) => - block.type === "response" ? ( - {block.text} - ) : ( - - ), - ) - : parsed.markdown || ""} - - {isSavingMessage && ( - - )} - {onEditUserMessage && !isSavingMessage && ( - - - - - Edit message - - )} -
- {(() => { - const imageBlocks = parsed.blocks.filter( - (b): b is Extract => - b.type === "file" && b.media_type.startsWith("image/"), - ); - if (imageBlocks.length === 0) return null; - return ( -
- {imageBlocks.map((block, i) => { - const src = block.file_id - ? `/api/experimental/chats/files/${block.file_id}` - : `data:${block.media_type};base64,${block.data}`; - return ( + {(hasUserMessageBody || hasFileBlocks) && ( +
+ {hasUserMessageBody && ( + + {userInlineContent.length > 0 + ? userInlineContent.map((block, i) => + block.type === "response" ? ( + {block.text} + ) : ( + + ), + ) + : parsed.markdown || ""} + + )} + {isSavingMessage && ( + + )} + {onEditUserMessage && !isSavingMessage && ( + + - ); - })} + + + Edit message + + + )} +
+ )} + {(() => { + if (userFileBlocks.length === 0) return null; + return ( +
+ {userFileBlocks.map((block, i) => + renderFileBlock({ + block, + key: `user-file-${block.file_id ?? i}`, + onImageClick: setPreviewImage, + onTextFileClick: setPreviewText, + }), + )}
); })()} @@ -488,6 +621,12 @@ const ChatMessageItem = memo<{ onClose={() => setPreviewImage(null)} /> )} + {previewText !== null && ( + setPreviewText(null)} + /> + )}
); }, diff --git a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx index de71a715e5..265215775a 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx @@ -266,6 +266,7 @@ export const AgentDetailInput: FC = ({ })(); const { attachments, + textContents, uploadStates, previewUrls, handleAttach, @@ -290,7 +291,7 @@ export const AgentDetailInput: FC = ({ ); const files = fileBlocks.map((block, i) => { const mt = block.media_type ?? "application/octet-stream"; - const ext = mt.split("/")[1] ?? "png"; + const ext = mt === "text/plain" ? "txt" : (mt.split("/")[1] ?? "png"); // Empty File used as a Map key only, its content is never // read because the existing file_id is reused at send time. return new File([], `attachment-${i}.${ext}`, { type: mt }); @@ -358,6 +359,7 @@ export const AgentDetailInput: FC = ({ onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} previewUrls={previewUrls} + textContents={textContents} inputRef={inputRef} initialValue={initialValue} onContentChange={onContentChange} diff --git a/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx b/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx index 6bf43ead99..decf1e6210 100644 --- a/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx +++ b/site/src/pages/AgentsPage/components/AttachmentPreview.stories.tsx @@ -1,13 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn, userEvent, within } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { AttachmentPreview, type UploadState } from "./AgentChatInput"; // Tiny 1x1 transparent PNG as data URI for previews. const TINY_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; -const createMockFile = (name: string, type: string) => - new File(["mock-data"], name, { type }); +const createMockFile = (name: string, type: string, size = 9) => + new File([new Uint8Array(size)], name, { type }); const meta: Meta = { title: "pages/AgentsPage/AttachmentPreview", @@ -22,6 +22,7 @@ const meta: Meta = { args: { onRemove: fn(), onPreview: fn(), + onTextPreview: fn(), }, }; @@ -39,6 +40,13 @@ export const SingleImage: Story = { previewUrls: new Map([[file, TINY_PNG]]), }; })(), + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const thumbnail = await canvas.findByRole("img", { name: "photo.png" }); + expect(thumbnail).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "photo.png" })); + expect(args.onPreview).toHaveBeenCalledWith(TINY_PNG); + }, }; export const MultipleImages: Story = { @@ -69,6 +77,10 @@ export const Uploading: Story = { previewUrls: new Map([[file, TINY_PNG]]), }; })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByTitle("Loading spinner")).toBeInTheDocument(); + }, }; export const UploadError: Story = { @@ -82,6 +94,18 @@ export const UploadError: Story = { previewUrls: new Map([[file, TINY_PNG]]), }; })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const overlay = canvas.getByLabelText("Upload error"); + expect(overlay).toBeInTheDocument(); + await userEvent.hover(overlay); + // After hover, the tooltip renders the error message. Use + // getAllByText because the text appears in both the tooltip + // trigger overlay and the tooltip content popover. + const body = within(canvasElement.ownerDocument.body); + const matches = await body.findAllByText(/Upload failed: server error/i); + expect(matches.length).toBeGreaterThanOrEqual(1); + }, }; export const FileTooLarge: Story = { @@ -120,6 +144,152 @@ export const NonImageFile: Story = { })(), }; +export const TextAttachment: Story = { + args: (() => { + const file = createMockFile("clipboard.txt", "text/plain", 2048); + return { + attachments: [file], + uploadStates: new Map([ + [file, { status: "uploaded", fileId: "file-1" }], + ]), + previewUrls: new Map(), + textContents: new Map([ + [ + file, + "This is the pasted text content.\nIt has multiple lines.\nAnd should be displayed in a readable card format.", + ], + ]), + }; + })(), + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const textCard = await canvas.findByRole("button", { + name: "View text attachment", + }); + expect(textCard).toHaveTextContent(/This is the pasted text content\./i); + await userEvent.click(textCard); + expect(args.onTextPreview).toHaveBeenCalledWith( + "This is the pasted text content.\nIt has multiple lines.\nAnd should be displayed in a readable card format.", + "clipboard.txt", + ); + }, +}; + +export const ThreeTextAttachments: Story = { + args: (() => { + const file1 = createMockFile("paste-1.txt", "text/plain", 2048); + const file2 = createMockFile("paste-2.txt", "text/plain", 3072); + const file3 = createMockFile("paste-3.txt", "text/plain", 1024); + return { + attachments: [file1, file2, file3], + uploadStates: new Map([ + [file1, { status: "uploaded", fileId: "file-1" }], + [file2, { status: "uploaded", fileId: "file-2" }], + [file3, { status: "uploaded", fileId: "file-3" }], + ]), + previewUrls: new Map(), + textContents: new Map([ + [ + file1, + "First pasted document with several lines of content.\nLine 2 of the first document.\nLine 3 continues here.", + ], + [ + file2, + "Second pasted text is a log file:\n[INFO] Server started on port 8080\n[WARN] Memory usage at 85%\n[ERROR] Connection timeout after 30s", + ], + [ + file3, + "Third paste is a short config:\nhost=localhost\nport=5432\ndb=myapp", + ], + ]), + }; + })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findAllByRole("button", { name: "View text attachment" }), + ).toHaveLength(3); + expect( + canvas.getByText( + /First pasted document with several lines of content\./i, + ), + ).toBeInTheDocument(); + }, +}; + +export const ThreeMixedAttachments: Story = { + args: (() => { + const imageFile = createMockFile("screenshot.png", "image/png"); + const textFile1 = createMockFile("logs.txt", "text/plain", 2048); + const textFile2 = createMockFile("config.txt", "text/plain", 1024); + return { + attachments: [imageFile, textFile1, textFile2], + uploadStates: new Map([ + [imageFile, { status: "uploaded", fileId: "img-1" }], + [textFile1, { status: "uploaded", fileId: "txt-1" }], + [textFile2, { status: "uploaded", fileId: "txt-2" }], + ]), + previewUrls: new Map([[imageFile, TINY_PNG]]), + textContents: new Map([ + [ + textFile1, + "[2025-01-15 10:30:00] Application started\n[2025-01-15 10:30:01] Connected to database\n[2025-01-15 10:30:02] Listening on :8080", + ], + [ + textFile2, + "DATABASE_URL=postgres://localhost/myapp\nREDIS_URL=redis://localhost:6379\nSECRET_KEY=abc123", + ], + ]), + }; + })(), +}; + +export const MixedImageAndText: Story = { + args: (() => { + const imageFile = createMockFile("photo.png", "image/png"); + const textFile = createMockFile("clipboard.txt", "text/plain", 2048); + return { + attachments: [imageFile, textFile], + uploadStates: new Map([ + [imageFile, { status: "uploaded", fileId: "file-1" }], + [textFile, { status: "uploaded", fileId: "file-2" }], + ]), + previewUrls: new Map([[imageFile, TINY_PNG]]), + textContents: new Map([ + [ + textFile, + "This is some pasted text content that appears alongside an image attachment.", + ], + ]), + }; + })(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByRole("img", { name: "photo.png" }), + ).toBeInTheDocument(); + expect( + canvas.getByRole("button", { name: "View text attachment" }), + ).toBeInTheDocument(); + }, +}; + +export const TextAttachmentUploading: Story = { + args: (() => { + const file = createMockFile("clipboard.txt", "text/plain", 2048); + return { + attachments: [file], + uploadStates: new Map([ + [file, { status: "uploading" }], + ]), + previewUrls: new Map(), + textContents: new Map([ + [file, "Uploading text content..."], + ]), + }; + })(), +}; + export const MixedStates: Story = { args: (() => { const uploaded = createMockFile("done.png", "image/png"); diff --git a/site/src/pages/AgentsPage/components/TextPreviewDialog.stories.tsx b/site/src/pages/AgentsPage/components/TextPreviewDialog.stories.tsx new file mode 100644 index 0000000000..0bb7e771f5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/TextPreviewDialog.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { TextPreviewDialog } from "./TextPreviewDialog"; + +const meta: Meta = { + title: "pages/AgentsPage/TextPreviewDialog", + component: TextPreviewDialog, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: + "This is some pasted text content.\nIt has multiple lines.\nAnd should be displayed in a readable format.", + onClose: () => {}, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect( + within(dialog).getByText(/This is some pasted text content\./i), + ).toBeInTheDocument(); + }, +}; + +export const LongContent: Story = { + args: { + content: Array(100) + .fill( + "This is a line of pasted text that demonstrates how the dialog handles very long content.", + ) + .join("\n"), + onClose: () => {}, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + const content = within(dialog).getByText( + /This is a line of pasted text that demonstrates how the dialog handles very long content\./i, + ); + expect(content).toBeInTheDocument(); + expect(content.parentElement).toHaveClass("overflow-auto"); + }, +}; + +export const NoFileName: Story = { + args: { + content: "Some pasted content without a filename.", + onClose: () => {}, + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole("dialog"); + expect(within(dialog).getByText("Pasted text")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/TextPreviewDialog.tsx b/site/src/pages/AgentsPage/components/TextPreviewDialog.tsx new file mode 100644 index 0000000000..3784c20c4c --- /dev/null +++ b/site/src/pages/AgentsPage/components/TextPreviewDialog.tsx @@ -0,0 +1,32 @@ +import { Dialog, DialogContent, DialogTitle } from "components/Dialog/Dialog"; +import type { FC } from "react"; + +interface TextPreviewDialogProps { + content: string; + fileName?: string; + onClose: () => void; +} + +export const TextPreviewDialog: FC = ({ + content, + fileName, + onClose, +}) => { + return ( + !open && onClose()}> + + + {fileName ?? "Pasted text"} + +
+
+						{content}
+					
+
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index 0f19ef9a65..faeb01733d 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -11,10 +11,11 @@ import type { UploadState } from "../components/AgentChatInput"; interface UseFileAttachmentsReturn { attachments: File[]; + textContents: Map; uploadStates: Map; previewUrls: Map; handleAttach: (files: File[]) => void; - handleRemoveAttachment: (index: number) => void; + handleRemoveAttachment: (attachment: number | File) => void; startUpload: (file: File) => void; resetAttachments: () => void; setAttachments: Dispatch>; @@ -30,6 +31,9 @@ export function useFileAttachments( () => new Map(), ); const [previewUrls, setPreviewUrls] = useState(() => new Map()); + const [textContents, setTextContents] = useState( + () => new Map(), + ); // Revoke blob URLs on unmount to prevent memory leaks. const previewUrlsRef = useRef(previewUrls); @@ -67,12 +71,13 @@ export function useFileAttachments( fileId: result.id, }), ); - // Pre-warm the browser HTTP cache so the timeline - // can render this image instantly after send. The - // server responds with Cache-Control: private, - // immutable, so the never hits the - // network again. - void fetch(`/api/experimental/chats/files/${result.id}`); + // Pre-warm the browser HTTP cache for images so the + // timeline can render them instantly after send. We + // intentionally skip text attachments because the + // composer already has the text content locally. + if (file.type.startsWith("image/")) { + void fetch(`/api/experimental/chats/files/${result.id}`); + } } catch (err: unknown) { const message = getErrorMessage(err, "Upload failed"); const detail = getErrorDetail(err); @@ -93,10 +98,33 @@ export function useFileAttachments( setPreviewUrls((prev) => { const next = new Map(prev); for (const file of files) { - next.set(file, URL.createObjectURL(file)); + if (file.type !== "text/plain") { + next.set(file, URL.createObjectURL(file)); + } } return next; }); + // Read text content for preview, but skip oversized files. + for (const file of files) { + if (file.type === "text/plain" && file.size <= maxSize) { + // Defensive: some test environments lack File.prototype.text(). + const readText = + typeof file.text === "function" + ? file.text() + : new Response(file).text(); + void readText + .then((content) => { + setTextContents((prev) => { + const next = new Map(prev); + next.set(file, content); + return next; + }); + }) + .catch((err) => { + console.error("Failed to read text file content:", err); + }); + } + } for (const file of files) { if (file.size > maxSize) { setUploadStates((prev) => @@ -111,23 +139,32 @@ export function useFileAttachments( } }; - const handleRemoveAttachment = (index: number) => { + const handleRemoveAttachment = (attachment: number | File) => { setAttachments((prev) => { - const removed = prev[index]; - if (removed) { - setUploadStates((prevStates) => { - const next = new Map(prevStates); - next.delete(removed); - return next; - }); - setPreviewUrls((prevUrls) => { - const url = prevUrls.get(removed); - if (url?.startsWith("blob:")) URL.revokeObjectURL(url); - const next = new Map(prevUrls); - next.delete(removed); - return next; - }); + const index = + typeof attachment === "number" ? attachment : prev.indexOf(attachment); + if (index === -1) { + return prev; } + + const removed = prev[index]; + setUploadStates((prevStates) => { + const next = new Map(prevStates); + next.delete(removed); + return next; + }); + setPreviewUrls((prevUrls) => { + const url = prevUrls.get(removed); + if (url?.startsWith("blob:")) URL.revokeObjectURL(url); + const next = new Map(prevUrls); + next.delete(removed); + return next; + }); + setTextContents((prevContents) => { + const next = new Map(prevContents); + next.delete(removed); + return next; + }); return prev.filter((_, i) => i !== index); }); }; @@ -137,12 +174,14 @@ export function useFileAttachments( if (url.startsWith("blob:")) URL.revokeObjectURL(url); } setPreviewUrls(new Map()); + setTextContents(new Map()); setUploadStates(new Map()); setAttachments([]); }; return { attachments, + textContents, uploadStates, previewUrls, handleAttach, diff --git a/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts b/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts new file mode 100644 index 0000000000..f437e84307 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/fetchTextAttachment.test.ts @@ -0,0 +1,54 @@ +import { + decodeInlineTextAttachment, + formatTextAttachmentPreview, +} from "./fetchTextAttachment"; + +const encodeUtf8Base64 = (value: string) => { + const bytes = new TextEncoder().encode(value); + return btoa(String.fromCharCode(...bytes)); +}; + +describe("formatTextAttachmentPreview", () => { + it('returns "Pasted text" for empty content', () => { + expect(formatTextAttachmentPreview("")).toBe("Pasted text"); + expect(formatTextAttachmentPreview(" \n\t ")).toBe("Pasted text"); + }); + + it("truncates longer text to the requested limit", () => { + expect(formatTextAttachmentPreview("abcdefgh", 5)).toBe("abcde"); + }); + + it("normalizes whitespace before building the preview", () => { + expect( + formatTextAttachmentPreview(" hello\n\nworld\tfrom\t tests "), + ).toBe("hello world from tests"); + }); + + it("preserves whole unicode code points when truncating emoji", () => { + expect(formatTextAttachmentPreview("πŸ™‚πŸ™‚πŸ™‚", 2)).toBe("πŸ™‚πŸ™‚"); + }); + + it("returns text shorter than the limit unchanged", () => { + expect(formatTextAttachmentPreview("short text", 20)).toBe("short text"); + }); +}); + +describe("decodeInlineTextAttachment", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("decodes base64-encoded UTF-8 text", () => { + const text = "Hello πŸ‘‹ cafΓ©"; + + expect(decodeInlineTextAttachment(encodeUtf8Base64(text))).toBe(text); + }); + + it("falls back to the raw string when base64 decoding fails", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const raw = "not-base64!"; + + expect(decodeInlineTextAttachment(raw)).toBe(raw); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts new file mode 100644 index 0000000000..846a9cbbec --- /dev/null +++ b/site/src/pages/AgentsPage/utils/fetchTextAttachment.ts @@ -0,0 +1,46 @@ +/** + * Roughly 1-2 lines of typical code at normal terminal width. + * Short enough to fit in attachment previews without excessive wrapping. + */ +const TEXT_ATTACHMENT_PREVIEW_LENGTH = 150; + +export function formatTextAttachmentPreview( + text: string, + maxLength = TEXT_ATTACHMENT_PREVIEW_LENGTH, +): string { + // Truncate before normalizing to keep cost bounded for large files. + const truncated = text.slice(0, maxLength * 4); + const normalized = truncated.replace(/\s+/g, " ").trim(); + const preview = Array.from(normalized).slice(0, maxLength).join(""); + return preview || "Pasted text"; +} + +/** + * Decodes inline text attachment data encoded as base64 UTF-8. + */ +export function decodeInlineTextAttachment(content: string): string { + try { + const decoded = atob(content); + const bytes = Uint8Array.from(decoded, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch (err) { + console.warn("Failed to decode inline text attachment:", err); + return content; + } +} + +/** + * Fetches the text content of a chat file attachment by its ID. + */ +export async function fetchTextAttachmentContent( + fileId: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(`/api/experimental/chats/files/${fileId}`, { + signal, + }); + if (!response.ok) { + throw new Error("Failed to fetch file"); + } + return response.text(); +} diff --git a/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts b/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts new file mode 100644 index 0000000000..3a2f7401e4 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/pasteHelpers.test.ts @@ -0,0 +1,150 @@ +// Tests for pasteHelpers utility functions (pure logic, no DOM). +import { beforeAll, describe, expect, it } from "vitest"; +import { + createPasteFile, + getPasteDataTransfer, + getPastedPlainText, + isLargePaste, +} from "../../../components/ChatMessageInput/pasteHelpers"; + +beforeAll(() => { + if (typeof File.prototype.text !== "function") { + Object.defineProperty(File.prototype, "text", { + configurable: true, + value: function () { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(this); + }); + }, + }); + } +}); + +type DataTransferLike = Pick; + +const createDataTransfer = ( + values: Record = {}, +): DataTransfer => { + const input = document.createElement("input"); + input.type = "file"; + return { + getData: (type: string) => values[type] ?? "", + files: input.files!, + } as DataTransfer; +}; + +const createClipboardPasteEvent = ( + clipboardData: DataTransferLike, +): ClipboardEvent => { + const event = new Event("paste") as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { + value: clipboardData, + }); + return event; +}; + +const createBeforeInputEvent = ( + data?: string, + dataTransfer?: DataTransferLike, +): InputEvent => { + const event = new Event("beforeinput") as InputEvent; + Object.defineProperty(event, "data", { + value: data ?? null, + }); + if (dataTransfer) { + Object.defineProperty(event, "dataTransfer", { + value: dataTransfer, + }); + } + return event; +}; + +describe("getPasteDataTransfer", () => { + it("prefers clipboardData from clipboard events", () => { + const clipboardData = createDataTransfer({ + "text/plain": "from clipboard", + }); + const event = createClipboardPasteEvent(clipboardData); + expect(getPasteDataTransfer(event)).toBe(clipboardData); + }); + + it("falls back to dataTransfer for beforeinput paste events", () => { + const dataTransfer = createDataTransfer({ + "text/plain": "from beforeinput", + }); + const event = createBeforeInputEvent(undefined, dataTransfer); + expect(getPasteDataTransfer(event)).toBe(dataTransfer); + }); +}); + +describe("getPastedPlainText", () => { + it("reads plain text from clipboard data when available", () => { + const dataTransfer = createDataTransfer({ + "text/plain": "clipboard", + }); + const event = createClipboardPasteEvent(dataTransfer); + expect(getPastedPlainText(event, dataTransfer)).toBe("clipboard"); + }); + + it("falls back to InputEvent.data for plain-text paste shortcuts", () => { + const event = createBeforeInputEvent("paste as plain text"); + expect(getPastedPlainText(event, null)).toBe("paste as plain text"); + }); + + it("returns empty string when no plain text is available", () => { + const dataTransfer = createDataTransfer(); + const event = createBeforeInputEvent(); + expect(getPastedPlainText(event, dataTransfer)).toBe(""); + }); +}); + +describe("isLargePaste", () => { + it("returns false for short single-line text", () => { + expect(isLargePaste("Hello world")).toBe(false); + }); + + it("returns false for 9 lines of short text", () => { + const text = Array(9).fill("short line").join("\n"); + expect(isLargePaste(text)).toBe(false); + }); + + it("returns true for 10+ lines", () => { + const text = Array(10).fill("line").join("\n"); + expect(isLargePaste(text)).toBe(true); + }); + + it("returns true for 1000+ characters even on one line", () => { + const text = "x".repeat(1000); + expect(isLargePaste(text)).toBe(true); + }); + + it("returns false for 999 characters on one line", () => { + const text = "x".repeat(999); + expect(isLargePaste(text)).toBe(false); + }); + + it("returns true for text meeting both thresholds", () => { + const text = Array(15).fill("x".repeat(100)).join("\n"); + expect(isLargePaste(text)).toBe(true); + }); +}); + +describe("createPasteFile", () => { + it("creates a File with text/plain type", () => { + const file = createPasteFile("hello"); + expect(file.type).toBe("text/plain"); + expect(file.name).toMatch( + /^pasted-text-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.txt$/, + ); + }); + + it("preserves the text content", async () => { + const text = "Hello\nWorld"; + const file = createPasteFile(text); + const content = await file.text(); + expect(content).toBe(text); + }); +});