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 (
+
+ );
+};
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);
+ });
+});