From 87303d6f0de95e07eaf3e67cb57778fc0a17aa2e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:25:25 +0200 Subject: [PATCH] feat(site): route non-attachment chat files to workspace uploads (#27080) ## What Frontend for chat workspace uploads: - Files whose declared MIME type is outside the attachment allowlist are automatically routed to the workspace upload endpoint (picker, drag-drop, and paste). Empty/`application/octet-stream` types stay on the attachment path so the server can sniff bytes. - `useWorkspaceFileUploads`: react-query mutation with eager upload on select, bounded to 3 concurrent streams, per-entry abort (removing a chip cancels its in-flight request), and generation-scoped workers so switching chats mid-upload cannot leak a stale upload into the new chat. - Composer chips (`WorkspaceUploadPreview`) show queued ("Waiting to upload..." beyond the 3 in flight), uploading, uploaded, and error states; removal copy makes explicit that already-uploaded bytes stay in the workspace. - Timeline chips (`WorkspaceFileChip`) render `workspace-file-reference` parts with name/size and the absolute path in a tooltip. - References survive message edits and queued-message edits. - Upload error text is the server message plus its detail when present, without a doubled period or the generic developer-console hint. The shared `formatAgentAttachmentUploadError` helper had the same defect for inline attachments and is fixed there. - The affordance is gated on a bound workspace with a connected agent; otherwise the input falls back to attachments only. A disabled (read-only) composer refuses workspace routing entirely. The target agent is the one the server resolves for the chat binding (main now enriches the binding server-side), so the frontend no longer mirrors `agentselect.FindChatAgent`. - Message edits only rehydrate `workspace-file-reference` parts whose `workspace_file_workspace_id` matches the currently bound workspace; references from a previous binding are dropped from the edit draft (the server also rejects them). ## Why Completes CODAGT-211: dropping a `.zip`/`.tar.gz`/dataset whose MIME type is outside the attachment allowlist into chat lands it at `~/.coder/chats//files/` where the agent can act on it, without pushing bytes through the LLM context. ## How tested - `pnpm vitest` for the upload hook (including a chat-switch race regression test), routing predicate, message parsing/helpers; Storybook interaction tests for the chip states; `tsc`, biome, React Compiler check, and knip all clean. ## Follow-ups - Persist workspace-upload metadata per chat so composer chips survive a failed workspace rebind and navigating away from and back to a chat. Today the hook resets when the chat or bound workspace changes (the server rejects references from a previous binding) and the upload list is client state only, so the chips are dropped even though the bytes stay in the workspace. ([CODAGT-1172](https://linear.app/codercom/issue/CODAGT-1172)) - Expose workspace-upload availability from the backend (the agent `agentselect.FindChatAgent` would pick) instead of the client gating on the bound agent or, when the chat has none yet, on any connected root agent; a multi-root-agent workspace whose selected agent is disconnected currently gets the server's 409 on upload. ([CODAGT-1174](https://linear.app/codercom/issue/CODAGT-1174)) - Reset or re-validate composer chips when `repairChatAgentIDs` replaces the bound agent after a workspace rebuild (the hooks scope on chat and workspace ID only); belongs with the upload-metadata persistence above. - Render timeline chips whose `workspace_file_workspace_id` differs from the chat's current binding in a stale/unavailable state (the prompt already renders such references as unavailable); needs the current binding threaded into the timeline and a chip variant. ([CODAGT-1173](https://linear.app/codercom/issue/CODAGT-1173)) - Files with an unknown MIME type (empty or `application/octet-stream`, for example a random `.bin`) stay on the attachment path by design, where the server classifies the bytes and rejects them with "Unsupported file type"; routing that rejection to the workspace upload instead is a follow-up ([CODAGT-1170](https://linear.app/codercom/issue/CODAGT-1170)). `text/csv` is an allowlisted attachment type and stays inline (UAT finding). - Route large files of allowlisted types to workspace upload by size ([CODAGT-1169](https://linear.app/codercom/issue/CODAGT-1169)). Part of CODAGT-211. Stack: agent API -> coderd endpoint -> UI (this PR). > This PR was authored by Mux (AI) working on Mike's behalf. --- site/src/api/api.ts | 35 +- site/src/api/queries/chatMessageEdits.ts | 10 + site/src/pages/AgentsPage/AgentChatPage.tsx | 18 +- .../pages/AgentsPage/AgentChatPageView.tsx | 7 +- .../components/AgentChatInput.test.tsx | 192 +++++++++++ .../AgentsPage/components/AgentChatInput.tsx | 142 ++++++-- .../ChatConversation/ConversationTimeline.tsx | 18 +- .../ChatConversation/UserMessageContent.tsx | 22 +- .../ChatConversation/WorkspaceFileChip.tsx | 46 +++ .../ChatConversation/messageHelpers.ts | 13 +- .../ChatConversation/messageParsing.test.ts | 24 +- .../ChatConversation/messageParsing.ts | 32 +- .../ChatConversation/submitChatTurn.ts | 8 +- .../ChatMessageInput/ChatMessageInput.tsx | 33 +- .../components/ChatPageContent.test.tsx | 58 +++- .../AgentsPage/components/ChatPageContent.tsx | 260 ++++++++++++++- .../components/QueuedMessagesList.tsx | 8 +- .../WorkspaceUploadPreview.stories.tsx | 84 +++++ .../WorkspaceUploadPreview.test.tsx | 36 +++ .../components/WorkspaceUploadPreview.tsx | 103 ++++++ .../hooks/useConversationEditingState.test.ts | 49 ++- .../hooks/useConversationEditingState.ts | 28 +- .../hooks/useWorkspaceFileUploads.test.ts | 306 ++++++++++++++++++ .../hooks/useWorkspaceFileUploads.ts | 216 +++++++++++++ .../AgentsPage/utils/chatAttachments.test.ts | 31 ++ .../pages/AgentsPage/utils/chatAttachments.ts | 16 + .../AgentsPage/utils/chatInputContent.test.ts | 31 ++ .../AgentsPage/utils/chatInputContent.ts | 21 +- .../utils/fileAttachmentLimits.test.ts | 48 +++ .../AgentsPage/utils/fileAttachmentLimits.ts | 24 +- 30 files changed, 1742 insertions(+), 177 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatConversation/WorkspaceFileChip.tsx create mode 100644 site/src/pages/AgentsPage/components/WorkspaceUploadPreview.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/WorkspaceUploadPreview.test.tsx create mode 100644 site/src/pages/AgentsPage/components/WorkspaceUploadPreview.tsx create mode 100644 site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.test.ts create mode 100644 site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.ts create mode 100644 site/src/pages/AgentsPage/utils/fileAttachmentLimits.test.ts diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 15712f5ce3..feba10708d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -377,6 +377,16 @@ export const mcpServerOAuth2ConnectPath = (organization: string, id: string) => const mcpServerOAuth2DisconnectPath = (id: string) => `/api/v2/mcp/servers/${encodeURIComponent(id)}/oauth2/disconnect`; +// Headers for chat file upload endpoints that stream the raw File as +// the request body. The filename travels in Content-Disposition using +// RFC 5987 encoding to support non-ASCII characters; placing the raw +// name directly in the header causes XMLHttpRequest to throw because +// HTTP headers only allow ISO-8859-1 code points. +const chatFileUploadHeaders = (file: File) => ({ + "Content-Type": file.type || "application/octet-stream", + "Content-Disposition": `attachment; filename="file"; filename*=UTF-8''${encodeURIComponent(file.name)}`, +}); + type Claims = { license_expires: number; // nbf is a standard JWT claim for "not before" - the license valid from date @@ -3217,6 +3227,22 @@ class ExperimentalApiMethods { return res.data; }; + uploadChatWorkspaceFile = async ( + chatId: string, + file: File, + signal?: AbortSignal, + ): Promise => { + const response = await this.axios.post( + `/api/v2/chats/${chatId}/workspace-files`, + file, + { + headers: chatFileUploadHeaders(file), + signal, + }, + ); + return response.data; + }; + uploadChatFile = async ( file: File, organizationId: string, @@ -3225,14 +3251,7 @@ class ExperimentalApiMethods { `/api/v2/chats/files?organization=${organizationId}`, file, { - headers: { - "Content-Type": file.type || "application/octet-stream", - // Use RFC 5987 encoding for the filename to support - // non-ASCII characters. Placing the raw name directly in - // the header causes XMLHttpRequest to throw because HTTP - // headers only allow ISO-8859-1 code points. - "Content-Disposition": `attachment; filename="file"; filename*=UTF-8''${encodeURIComponent(file.name)}`, - }, + headers: chatFileUploadHeaders(file), }, ); return response.data; diff --git a/site/src/api/queries/chatMessageEdits.ts b/site/src/api/queries/chatMessageEdits.ts index 84235a007c..0a6994a405 100644 --- a/site/src/api/queries/chatMessageEdits.ts +++ b/site/src/api/queries/chatMessageEdits.ts @@ -30,6 +30,16 @@ const buildOptimisticEditedContent = ({ content: part.content ?? "", }; } + if (part.type === "workspace-file-reference") { + return { + type: "workspace-file-reference", + workspace_file_path: part.workspace_file_path ?? "", + workspace_file_name: part.workspace_file_name ?? "", + workspace_file_size: part.workspace_file_size ?? 0, + workspace_file_media_type: part.workspace_file_media_type, + workspace_file_workspace_id: part.workspace_file_workspace_id ?? "", + }; + } const fileId = part.file_id ?? ""; return ( existingFilePartsByID.get(fileId) ?? { diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 7ae0bcdb03..a9b012414b 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -70,11 +70,13 @@ import { submitChatTurn } from "./components/ChatConversation/submitChatTurn"; import { useChatToolInvalidations } from "./components/ChatConversation/useChatToolInvalidations"; import { useWorkspaceWatch } from "./components/ChatConversation/useWorkspaceWatch"; import { isChatAgentBindingUnresolved } from "./components/ChatConversation/watchedWorkspace"; -import type { PendingAttachment } from "./components/ChatPageContent"; import { workspaceSkillsFromChat } from "./components/ChatPageContent"; import { getModelSelectorHelp } from "./components/ModelSelectorHelp"; import { useAgentChatPanelPreference } from "./components/RightPanel/useAgentChatPanelPreference"; -import { useConversationEditingState } from "./hooks/useConversationEditingState"; +import { + type SendChatTurnOptions, + useConversationEditingState, +} from "./hooks/useConversationEditingState"; import { useGitWatcher } from "./hooks/useGitWatcher"; import { draftInputStorageKeyPrefix, @@ -660,15 +662,17 @@ const AgentChatPage: FC = () => { setCachedChatPlanMode, }; - async function handleSend( - message: string, - attachments?: readonly PendingAttachment[], - editedMessageID?: number, - ) { + async function handleSend({ + message, + attachments, + workspaceUploads, + editedMessageID, + }: SendChatTurnOptions) { await submitChatTurn({ ...chatTurnDeps, message, attachments, + workspaceUploads, editedMessageID, composerParts: editing.chatInputRef.current?.getContentParts() ?? [], }); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index e36f9aa8c1..bfa657bbe8 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -42,7 +42,7 @@ import { import { QueuedForCapacityCallout } from "./components/ChatConversation/QueuedForCapacityCallout"; import { DesktopPanelContext } from "./components/ChatElements/tools/DesktopPanelContext"; -import type { PendingAttachment } from "./components/ChatPageContent"; +import type { SendChatMessageOptions } from "./components/ChatPageContent"; import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; import { ChatSummaryPanel } from "./components/ChatSummaryPanel"; import { getEffectiveTabId } from "./components/ChatsSidebar/tabs/getEffectiveTabId"; @@ -96,10 +96,7 @@ type EditingState = { fileBlocks?: readonly ChatMessagePart[], ) => void; handleCancelHistoryEdit: () => void; - handleSendFromInput: ( - message: string, - attachments?: readonly PendingAttachment[], - ) => void; + handleSendFromInput: (options: SendChatMessageOptions) => void; handleContentChange: ( content: string, serializedEditorState: string, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx index 05962a6c96..18595a78d7 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.test.tsx @@ -314,4 +314,196 @@ describe("AgentChatInput", () => { expect(start).toHaveBeenCalledTimes(1); expect(screen.getByRole("button", { name: "Stop" })).toBeEnabled(); }); + + it("falls back to pasted text when every pasted file is refused", async () => { + const onAttach = vi.fn(); + const inputRef = createRef(); + + renderInput( + , + ); + + const target = screen.getByRole("textbox", { name: "Chat message" }); + target.focus(); + // Clipboard carrying both a file and a text payload. Without + // workspaceUploads the zip cannot be routed anywhere, so the + // paste must fall back to inserting the clipboard text. + fireEvent.paste(target, { + clipboardData: { + files: [createMockFile("dataset.zip", "application/zip")], + types: ["Files", "text/plain"], + getData: (type: string) => + type === "text/plain" ? "notes about the archive" : "", + }, + }); + + await waitFor(() => { + expect(inputRef.current?.getValue()).toContain("notes about the archive"); + }); + expect(onAttach).not.toHaveBeenCalled(); + }); + + it("routes workspace files to workspace uploads instead of attachments", () => { + const onAttach = vi.fn(); + const onWorkspaceAttach = vi.fn(); + + renderInput( + , + ); + + const zip = createMockFile("dataset.zip", "application/zip"); + fireEvent.drop(screen.getByRole("textbox", { name: "Chat message" }), { + dataTransfer: { files: [zip] }, + }); + + expect(onWorkspaceAttach).toHaveBeenCalledWith([zip]); + expect(onAttach).not.toHaveBeenCalled(); + }); + + it("refuses workspace files while the composer is disabled", () => { + const onAttach = vi.fn(); + const onWorkspaceAttach = vi.fn(); + const toastError = vi.spyOn(toast, "error"); + + renderInput( + , + ); + + fireEvent.drop(screen.getByRole("textbox", { name: "Chat message" }), { + dataTransfer: { + files: [createMockFile("dataset.zip", "application/zip")], + }, + }); + + expect(onWorkspaceAttach).not.toHaveBeenCalled(); + expect(onAttach).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith( + "This file type is uploaded into the chat's workspace. Attach a running workspace to the chat, then try again.", + ); + }); + + it("refuses workspace files while a send is pending", () => { + const onAttach = vi.fn(); + const onWorkspaceAttach = vi.fn(); + const toastError = vi.spyOn(toast, "error"); + + renderInput( + , + ); + + // The post-send reset would discard the chip after the bytes + // already landed, so the drop is refused with a wait message + // rather than the "attach a workspace" one. + fireEvent.drop(screen.getByRole("textbox", { name: "Chat message" }), { + dataTransfer: { + files: [createMockFile("dataset.zip", "application/zip")], + }, + }); + + expect(onWorkspaceAttach).not.toHaveBeenCalled(); + expect(onAttach).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith( + "Wait for the current message to finish sending, then add the file again.", + ); + }); + + it("asks for a workspace when workspace uploads are wired but unavailable", () => { + const onAttach = vi.fn(); + const toastError = vi.spyOn(toast, "error"); + + renderInput( + , + ); + + const zip = createMockFile("archive.zip", "application/zip"); + fireEvent.drop(screen.getByRole("textbox", { name: "Chat message" }), { + dataTransfer: { files: [zip] }, + }); + + expect(onAttach).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith( + "This file type is uploaded into the chat's workspace. Attach a running workspace to the chat, then try again.", + ); + }); }); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 9e2d78540c..9dee80a1db 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -72,6 +72,10 @@ import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useMCPOAuthFlow } from "../hooks/useMCPOAuthFlow"; import { useOverflowCount } from "../hooks/useOverflowCount"; import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; +import { + isWorkspaceUploadInProgress, + type WorkspaceFileUpload, +} from "../hooks/useWorkspaceFileUploads"; import { getAgentChatSendShortcut, MODIFIER_AGENT_CHAT_SEND_SHORTCUT, @@ -79,6 +83,7 @@ import { import { chatAttachmentAcceptAttribute, isChatAttachmentFile, + shouldRouteFileToWorkspace, } from "../utils/chatAttachments"; import type { ChatSlashCommand } from "../utils/slashCommands"; import { AgentSetupNotice } from "./AgentSetupNotice"; @@ -98,6 +103,7 @@ import { ImageLightbox } from "./ImageLightbox"; import { QueuedMessagesList } from "./QueuedMessagesList"; import { TextPreviewDialog } from "./TextPreviewDialog"; import { WorkspacePill } from "./WorkspacePill"; +import { WorkspaceUploadPreview } from "./WorkspaceUploadPreview"; export { ImageThumbnail, @@ -107,6 +113,23 @@ export { export type { ChatMessageInputRef } from "./ChatMessageInput/ChatMessageInput"; export type { AgentContextUsage } from "./ContextUsageIndicator"; +type WorkspaceUploadsProps = { + uploads: readonly WorkspaceFileUpload[]; + // Present only when the chat has a bound workspace with a + // connected agent; its absence hides the whole affordance. + onAttach?: (files: File[]) => void; + onRemove: (id: string) => void; + // Toast shown when a workspace-routed file arrives while onAttach + // is unavailable. Overridden on the new-chat page, where the fix + // is selecting a workspace rather than attaching one to the chat. + unavailableMessage?: string; +}; + +const workspaceRequiredAttachmentMessage = + "This file type is uploaded into the chat's workspace. Attach a running workspace to the chat, then try again."; +const workspaceUploadPendingSendMessage = + "Wait for the current message to finish sending, then add the file again."; + type AgentChatInputProps = { onSend: (message: string) => void; placeholder?: string; @@ -181,6 +204,7 @@ type AgentChatInputProps = { uploadStates?: Map; previewUrls?: Map; textContents?: Map; + workspaceUploads?: WorkspaceUploadsProps; onTextPreview?: ( content: string, fileName: string, @@ -409,6 +433,7 @@ export const AgentChatInput: FC = ({ uploadStates, previewUrls, textContents, + workspaceUploads, onTextPreview, mcpServers, selectedMCPServerIds, @@ -800,19 +825,75 @@ export const AgentChatInput: FC = ({ }; }, [composerElement]); + // Workspace uploads eagerly write bytes into the workspace, so a + // disabled (read-only) composer must not route files to them. The + // same holds while a send is pending: the post-send reset would drop + // the chip after the bytes already landed in the workspace. + const workspaceAttachBlockedBySend = + isLoading && workspaceUploads?.onAttach !== undefined; + const onWorkspaceAttach = + isDisabled || isLoading ? undefined : workspaceUploads?.onAttach; + + // Splits files between the attachment pipeline and the workspace + // upload path by declared MIME type. Unknown or octet-stream files + // stay on the attachment path where the server classifies bytes. + // Returns whether any file was routed. + const routeFiles = (files: File[]): boolean => { + const attachable: File[] = []; + const forWorkspace: File[] = []; + const rejected: File[] = []; + // Workspace-routable files that arrived while the workspace + // upload feature is wired but currently unavailable (no + // connected agent, read-only composer, pending send). They need + // a workspace, not a different file type, so they get their own + // toast. + const workspaceRequired: File[] = []; + for (const file of files) { + if (onWorkspaceAttach && shouldRouteFileToWorkspace(file)) { + forWorkspace.push(file); + } else if (isChatAttachmentFile(file)) { + attachable.push(file); + } else if (workspaceUploads && shouldRouteFileToWorkspace(file)) { + workspaceRequired.push(file); + } else { + rejected.push(file); + } + } + if (workspaceRequired.length > 0) { + toast.error( + workspaceAttachBlockedBySend + ? workspaceUploadPendingSendMessage + : (workspaceUploads?.unavailableMessage ?? + workspaceRequiredAttachmentMessage), + ); + } + if (rejected.length > 0) { + toast.error( + `Unsupported file type: ${rejected.map((file) => file.name).join(", ")}`, + ); + } + if (attachable.length === 0 && forWorkspace.length === 0) { + return false; + } + resetPromptCycle(); + if (attachable.length > 0) { + onAttach?.(attachable); + } + if (forWorkspace.length > 0) { + onWorkspaceAttach?.(forWorkspace); + } + return true; + }; + const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files && onAttach) { - resetPromptCycle(); - onAttach(Array.from(e.target.files)); + if (e.target.files?.length) { + routeFiles(Array.from(e.target.files)); } // Reset so the same file can be selected again. e.target.value = ""; }; - const handleFilePaste = (file: File) => { - resetPromptCycle(); - onAttach?.([file]); - }; + const handleFilePaste = (file: File) => routeFiles([file]); const handleInlineText = (file: File, nextContent?: string) => { const content = nextContent ?? textContents?.get(file); @@ -857,18 +938,8 @@ export const AgentChatInput: FC = ({ const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); - if (!onAttach || !e.dataTransfer.files.length) return; - const dropped = Array.from(e.dataTransfer.files); - const attachable = dropped.filter(isChatAttachmentFile); - const rejected = dropped.filter((file) => !isChatAttachmentFile(file)); - if (rejected.length > 0) { - toast.error( - `Unsupported file type: ${rejected.map((file) => file.name).join(", ")}`, - ); - } - if (attachable.length === 0) return; - resetPromptCycle(); - onAttach(attachable); + if (!e.dataTransfer.files.length) return; + routeFiles(Array.from(e.dataTransfer.files)); }; // Track whether the editor has content so we can gate the @@ -912,14 +983,18 @@ export const AgentChatInput: FC = ({ internalRef.current?.focus(); } }, [isLoading]); - const hasActiveUploads = attachments.some((file) => - isUploadInProgress(uploadStates?.get(file)), - ); - const hasUploadedAttachments = attachments.some( - (f) => uploadStates?.get(f)?.status === "uploaded", - ); + const workspaceUploadEntries = workspaceUploads?.uploads ?? []; + const hasActiveUploads = + attachments.some((file) => isUploadInProgress(uploadStates?.get(file))) || + workspaceUploadEntries.some(isWorkspaceUploadInProgress); + const hasUploadedAttachments = + attachments.some((f) => uploadStates?.get(f)?.status === "uploaded") || + workspaceUploadEntries.some((upload) => upload.status === "uploaded"); const hasDraftContext = - hasContent || attachments.length > 0 || hasFileReferences; + hasContent || + attachments.length > 0 || + workspaceUploadEntries.length > 0 || + hasFileReferences; const isComposerEffectivelyEmpty = !hasDraftContext; const hasSendableContent = hasContent || hasUploadedAttachments || hasFileReferences; @@ -1210,6 +1285,12 @@ export const AgentChatInput: FC = ({ onInlineText={handleInlineText} /> )} + {workspaceUploads && ( + + )} = ({ )} - {/* Hidden file input for attaching any server-accepted file type. */} + {/* Hidden file input for attaching files. When workspace + uploads are available every file type is selectable: + non-allowlisted types stream into the workspace instead + of the attachment pipeline. */} {onAttach && ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 6a60cd014f..d1519f7792 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -179,13 +179,8 @@ const ChatMessageItem = memo<{ isAwaitingFirstStreamChunk, }) : undefined; - // Editing rebuilds the message from text + attachments and would - // drop workspace file references, so such messages are read-only. const canEditUserMessage = - isUser && - messageId !== undefined && - Boolean(onEditUserMessage) && - !displayState?.hasWorkspaceFileReferences; + isUser && messageId !== undefined && Boolean(onEditUserMessage); const canJumpBetweenUserMessages = isUser && Boolean(onJumpToUserMessage) && @@ -315,16 +310,9 @@ const ChatMessageItem = memo<{ className="size-6" aria-label="Edit message" onClick={() => { - const editablePayload = + const { text, fileBlocks } = getEditableUserMessagePayload(message); - if (!editablePayload) { - return; - } - onEditUserMessage?.( - messageId, - editablePayload.text, - editablePayload.fileBlocks, - ); + onEditUserMessage?.(messageId, text, fileBlocks); }} > diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 94ff5f676b..746bd7b566 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -15,6 +15,7 @@ import type { MessageDisplayState, UserInlineRenderBlock, } from "./messageHelpers"; +import { WorkspaceFileChip } from "./WorkspaceFileChip"; const getInlineParts = ( blocks: readonly UserInlineRenderBlock[], @@ -57,11 +58,6 @@ const renderUserInlineContent = (blocks: readonly UserInlineRenderBlock[]) => { ); }; -const workspaceFilePlaceholderText = (count: number): string => - count === 1 - ? "Workspace file attached. Display support is coming soon." - : `${count} workspace files attached. Display support is coming soon.`; - export const UserMessageContent: FC<{ displayState: MessageDisplayState; markdown: string; @@ -115,10 +111,20 @@ export const UserMessageContent: FC<{ )} {displayState.hasWorkspaceFileReferences && ( -
- {workspaceFilePlaceholderText( - displayState.workspaceFileReferenceCount, +
+ {displayState.workspaceFileBlocks.map((block, index) => ( + + ))}
)}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/WorkspaceFileChip.tsx b/site/src/pages/AgentsPage/components/ChatConversation/WorkspaceFileChip.tsx new file mode 100644 index 0000000000..697055aa23 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/WorkspaceFileChip.tsx @@ -0,0 +1,46 @@ +import { HardDriveIcon } from "lucide-react"; +import prettyBytes from "pretty-bytes"; +import type { FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; + +/** + * Timeline chip for a workspace-file-reference message part. The + * bytes live on the workspace filesystem, so unlike attachments there + * is nothing to preview or download here; the tooltip surfaces the + * absolute path the agent can read. The trigger is a button so the + * path is reachable from the keyboard, not only on hover. + */ +export const WorkspaceFileChip: FC<{ + name: string; + path: string; + size: number; +}> = ({ name, path, size }) => ( + + + + + +

{path}

+
+
+); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts index c642753180..f399254d14 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageHelpers.ts @@ -21,6 +21,7 @@ export type MessageDisplayState = { shouldHide: boolean; userInlineContent: UserInlineRenderBlock[]; userFileBlocks: FileRenderBlock[]; + workspaceFileBlocks: WorkspaceFileReferenceBlock[]; workspaceFileReferenceCount: number; hasUserMessageBody: boolean; hasFileBlocks: boolean; @@ -74,8 +75,8 @@ const getRenderableContentState = (parsed: ParsedMessageContent) => { }), ); const visibleToolIds = new Set(visibleTools.map((tool) => tool.id)); - // Workspace file references render via the display-state - // placeholder, not as standalone timeline blocks. + // Workspace file references render as chips from the display + // state, not as standalone timeline blocks. const visibleBlocks = parsed.blocks.filter( (block) => block.type !== "workspace-file-reference" && @@ -156,9 +157,10 @@ export const deriveMessageDisplayState = ({ ? parsed.blocks.filter(isUserInlineRenderBlock) : []; const userFileBlocks = isUser ? parsed.blocks.filter(isFileRenderBlock) : []; - const workspaceFileReferenceCount = isUser - ? parsed.blocks.filter(isWorkspaceFileReferenceBlock).length - : 0; + const workspaceFileBlocks = isUser + ? parsed.blocks.filter(isWorkspaceFileReferenceBlock) + : []; + const workspaceFileReferenceCount = workspaceFileBlocks.length; const hasWorkspaceFileReferences = workspaceFileReferenceCount > 0; const hasFileAttachments = parsed.blocks.some(isFileRenderBlock); const hasUserMessageBody = @@ -186,6 +188,7 @@ export const deriveMessageDisplayState = ({ shouldHide: shouldHideTimelineEntry({ message, parsed }), userInlineContent, userFileBlocks, + workspaceFileBlocks, workspaceFileReferenceCount, hasUserMessageBody, hasFileBlocks, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index e5df2626b2..66ca2576c1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -155,25 +155,25 @@ describe("getEditableUserMessagePayload", () => { } }); - it("returns undefined for messages with workspace file references", () => { + it("preserves workspace file references as editable file blocks", () => { + const workspacePart = { + type: "workspace-file-reference", + workspace_file_path: "/home/coder/.coder/chats/chat-1/files/data.csv", + workspace_file_name: "data.csv", + workspace_file_size: 42, + workspace_file_workspace_id: "ws-1", + } as const; const message: ChatMessage = { id: 1, chat_id: "chat-1", created_at: "2026-04-21T00:00:00.000Z", role: "user", - content: [ - { type: "text", text: "Please use this file." }, - { - type: "workspace-file-reference", - workspace_file_path: "/home/coder/.coder/chats/chat-1/files/data.csv", - workspace_file_name: "data.csv", - workspace_file_size: 42, - workspace_file_workspace_id: "ws-1", - }, - ], + content: [{ type: "text", text: "Please use this file." }, workspacePart], }; - expect(getEditableUserMessagePayload(message)).toBeUndefined(); + const payload = getEditableUserMessagePayload(message); + expect(payload?.text).toBe("Please use this file."); + expect(payload?.fileBlocks).toEqual([workspacePart]); }); it("preserves whitespace-only text parts when concatenating", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 8ed1a67c2e..7d5542c1db 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -342,32 +342,18 @@ const isEditableAttachmentMediaType = (mediaType: string): boolean => const isEditableUserMessageFileBlock = ( block: RenderBlock, -): block is TypesGen.ChatFilePart => - block.type === "file" && isEditableAttachmentMediaType(block.media_type); - -const messageHasWorkspaceFileReferences = ( - message: TypesGen.ChatMessage, -): boolean => - (message.content ?? []).some( - (part) => part.type === "workspace-file-reference", - ); +): block is TypesGen.ChatFilePart | TypesGen.ChatWorkspaceFileReferencePart => + (block.type === "file" && isEditableAttachmentMediaType(block.media_type)) || + // Workspace file bytes already live in the workspace; editing + // preserves the reference (or drops it when the chip is removed). + block.type === "workspace-file-reference"; export const getEditableUserMessagePayload = ( message: TypesGen.ChatMessage, -): - | { - text: string; - fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined; - } - | undefined => { - // Editing rebuilds the message from text + editable attachment - // blocks, which would silently drop workspace file references. - // Until the edit flow preserves them, such messages are not - // editable. - if (messageHasWorkspaceFileReferences(message)) { - return undefined; - } - +): { + text: string; + fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined; +} => { // Concatenate text parts verbatim to match the server-side string_agg in // GetChatUserPromptsByChatID; parseMessageContent/appendText is for streaming and drops whitespace-only chunks. const text = (message.content ?? []) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/submitChatTurn.ts b/site/src/pages/AgentsPage/components/ChatConversation/submitChatTurn.ts index fd06d457a5..a9bf93c5ad 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/submitChatTurn.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/submitChatTurn.ts @@ -19,7 +19,10 @@ import { chatSlashCommandTriggerText, resolveChatSlashCommandAvailability, } from "../../utils/slashCommands"; -import type { PendingAttachment } from "../ChatPageContent"; +import type { + PendingAttachment, + PendingWorkspaceUpload, +} from "../ChatPageContent"; import { buildInactiveChatQueueReconciliation, reconcilePromotedQueueHead, @@ -32,6 +35,7 @@ import type { ChatStore } from "./chatStore"; export type SubmitChatTurnParams = { message: string; attachments?: readonly PendingAttachment[]; + workspaceUploads?: readonly PendingWorkspaceUpload[]; editedMessageID?: number; composerParts?: readonly ChatComposerContentPart[]; clearPlanMode?: boolean; @@ -244,6 +248,7 @@ export async function submitChatTurn( const { message, attachments, + workspaceUploads, editedMessageID, composerParts, clearPlanMode = false, @@ -278,6 +283,7 @@ export async function submitChatTurn( const { content, hasContent } = buildChatInputContent({ message, attachments, + workspaceUploads, composerParts, }); if (!hasContent || isSubmissionPending || !hasModelOptions) { diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 5dbc372d12..b79bf96353 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -40,7 +40,6 @@ import { DEFAULT_AGENT_CHAT_SEND_SHORTCUT, MODIFIER_AGENT_CHAT_SEND_SHORTCUT, } from "../../utils/agentChatSendShortcut"; -import { isChatAttachmentFile } from "../../utils/chatAttachments"; import { filterSkillsByQuery, isPersonalSkillTriggerToken, @@ -153,7 +152,7 @@ function replacePlainTextInEditor(editor: LexicalEditor, text: string) { // user intent to paste inline, so the large-paste-to-attachment // conversion is bypassed for that shortcut. const PasteSanitizationPlugin: FC<{ - onFilePaste?: (file: File) => void; + onFilePaste?: (file: File) => boolean; allowTextAttachmentPaste?: boolean; }> = function PasteSanitizationPlugin({ onFilePaste, @@ -228,20 +227,25 @@ const PasteSanitizationPlugin: FC<{ } // Native paste event (ClipboardEvent). - // Check for attachable files in the clipboard (e.g. - // pasted screenshots). Forward them to the parent - // via callback instead of inserting text. + // Check for files in the clipboard, such as pasted + // screenshots or archives. Forward all files to the + // parent, which routes each one to the attachment + // pipeline or a workspace upload by MIME type. if (onFilePaste && dataTransfer?.files.length) { - const attachable = Array.from(dataTransfer.files).filter( - isChatAttachmentFile, - ); - if (attachable.length > 0) { - event.preventDefault(); - for (const file of attachable) { - onFilePaste(file); + let routed = false; + for (const file of Array.from(dataTransfer.files)) { + if (onFilePaste(file)) { + routed = true; } + } + if (routed) { + event.preventDefault(); return true; } + // Every file was refused (for example an archive + // pasted while workspace uploads are unavailable). + // Fall through so accompanying clipboard text still + // pastes. } const text = getPastedPlainText(event, dataTransfer); @@ -501,7 +505,10 @@ type ChatMessageInputProps = Omit< rows?: number; onEnter?: () => void; sendShortcut?: TypesGen.AgentChatSendShortcut; - onFilePaste?: (file: File) => void; + // Returns whether the file was routed anywhere (attachment or + // workspace upload). Refused files let the paste fall back to + // the clipboard's text payload. + onFilePaste?: (file: File) => boolean; allowTextAttachmentPaste?: boolean; disabled?: boolean; autoFocus?: boolean; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.test.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.test.tsx index 68999645d7..e680757c20 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.test.tsx @@ -1,6 +1,8 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; +import type * as TypesGen from "#/api/typesGenerated"; import { MockChat } from "#/testHelpers/chatEntities"; import { renderWithAuth } from "#/testHelpers/renderHelpers"; import { createChatStore } from "./ChatConversation/chatStore"; @@ -8,7 +10,7 @@ import { ChatPageInput } from "./ChatPageContent"; const renderChatPageInput = ( store: ReturnType, - onInterrupt: () => void, + overrides: Partial> = {}, ) => renderWithAuth( , ); +const workspaceFileReference = ( + name: string, + workspaceId: string, +): TypesGen.ChatWorkspaceFileReferencePart => ({ + type: "workspace-file-reference", + workspace_file_path: `/home/coder/${name}`, + workspace_file_name: name, + workspace_file_size: 42, + workspace_file_workspace_id: workspaceId, + workspace_file_media_type: "text/csv", +}); + describe("ChatPageInput", () => { it("routes Stop to onInterrupt while the chat requires action", async () => { const user = userEvent.setup(); @@ -47,9 +62,44 @@ describe("ChatPageInput", () => { const store = createChatStore(); store.setChatStatus("requires_action"); - renderChatPageInput(store, onInterrupt); + renderChatPageInput(store, { onInterrupt }); await user.click(await screen.findByRole("button", { name: "Stop" })); expect(onInterrupt).toHaveBeenCalledTimes(1); }); + + it("rehydrates edited workspace file references only from the bound workspace", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + renderChatPageInput(createChatStore(), { + chat: { + ...MockChat, + id: "", + organization_id: "", + workspace_id: "ws-1", + }, + onSend, + isEditing: true, + initialValue: "edited", + editingFileBlocks: [ + workspaceFileReference("current.csv", "ws-1"), + workspaceFileReference("other.csv", "ws-2"), + workspaceFileReference("unbound.csv", ""), + ], + }); + + await user.click(await screen.findByRole("button", { name: "Save Edit" })); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].workspaceUploads).toEqual([ + { + path: "/home/coder/current.csv", + name: "current.csv", + size: 42, + mediaType: "text/csv", + workspaceId: "ws-1", + }, + ]); + }); }); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index f281511cea..a29e767d94 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -1,5 +1,12 @@ import { cn } from "cn"; -import { type FC, Profiler, type ReactNode, useEffect, useRef } from "react"; +import { + type FC, + Profiler, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; @@ -12,10 +19,19 @@ import { workspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import type { ModelSelectorOption } from "#/modules/aiModels/ModelSelector"; +import { getWorkspaceAgents } from "#/utils/workspace"; import { useChatDraftAttachments } from "../hooks/useChatDraftAttachments"; import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useFileAttachments } from "../hooks/useFileAttachments"; -import { getChatFileURL } from "../utils/chatAttachments"; +import { + isWorkspaceUploadInProgress, + useWorkspaceFileUploads, + type WorkspaceFileUpload, +} from "../hooks/useWorkspaceFileUploads"; +import { + getChatFileURL, + isWorkspaceFileReferencePart, +} from "../utils/chatAttachments"; import { getProviderForModelOption, resolveCompactionThreshold, @@ -256,14 +272,27 @@ export type PendingAttachment = { mediaType: string; }; +export type PendingWorkspaceUpload = { + path: string; + name: string; + size: number; + mediaType: string; + // The workspace whose filesystem holds the bytes, echoed back to + // the server which rejects references from a stale binding. + workspaceId: string; +}; + +export type SendChatMessageOptions = { + message: string; + attachments?: readonly PendingAttachment[]; + workspaceUploads?: readonly PendingWorkspaceUpload[]; +}; + type ChatPageInputProps = { chat: TypesGen.Chat; store: ChatStoreHandle; models: readonly TypesGen.ChatModel[] | undefined; - onSend: ( - message: string, - attachments?: readonly PendingAttachment[], - ) => Promise | void; + onSend: (options: SendChatMessageOptions) => Promise | void; onDeleteQueuedMessage: (id: number) => Promise; onPromoteQueuedMessage: (id: number) => Promise; onInterrupt: () => void; @@ -432,6 +461,23 @@ export const ChatPageInput: FC = ({ const composeAttachments = useChatDraftAttachments(organizationId, chatId, { provider: getProviderForModelOption(modelOptions, selectedModel), }); + // Scope on the chat's bound workspace ID (known with the chat + // record) rather than the async-loaded workspace object, so a + // rebind resets immediately and query resolution never does. + const composeWorkspaceUploads = useWorkspaceFileUploads( + chatId, + selectedWorkspaceId ?? undefined, + ); + const editWorkspaceUploads = useWorkspaceFileUploads( + chatId, + selectedWorkspaceId ?? undefined, + ); + // Workspace file references preserved from the message being + // edited. They are already uploaded; editing only re-references + // them (or drops them when the chip is removed). + const [preservedWorkspaceUploads, setPreservedWorkspaceUploads] = useState< + readonly WorkspaceFileUpload[] + >([]); const editAttachments = useFileAttachments(organizationId, { provider: getProviderForModelOption(modelOptions, selectedModel), }); @@ -456,6 +502,8 @@ export const ChatPageInput: FC = ({ // draft. Clear them when navigation changes the chat scope. const editScopeRef = useRef({ organizationId, chatId }); + const { reset: resetEditWorkspaceUploads } = editWorkspaceUploads; + useEffect(() => { const previous = editScopeRef.current; const scopeChanged = @@ -463,9 +511,25 @@ export const ChatPageInput: FC = ({ editScopeRef.current = { organizationId, chatId }; if (scopeChanged) { resetEditAttachments(); + setPreservedWorkspaceUploads([]); } }, [organizationId, chatId, resetEditAttachments]); + // Preserved references point at files inside the bound workspace's + // filesystem. Rebinding or clearing the workspace mid-edit makes + // them unreadable for the agent, so drop them (regular attachments + // are chat files and survive workspace changes). Scope tracks the + // chat's bound workspace ID, not the async-loaded workspace + // object, which resolves after mount without a rebind. + const editWorkspaceScopeRef = useRef(selectedWorkspaceId); + useEffect(() => { + if (editWorkspaceScopeRef.current === selectedWorkspaceId) { + return; + } + editWorkspaceScopeRef.current = selectedWorkspaceId; + setPreservedWorkspaceUploads([]); + }, [selectedWorkspaceId]); + // Pre-populate the edit bucket from existing file blocks only // while explicitly editing a message. useEffect(() => { @@ -513,24 +577,150 @@ export const ChatPageInput: FC = ({ setEditUploadStates, ]); + // Workspace file references from the edited message become + // preserved (already-uploaded) entries so they survive the + // edit unless explicitly removed. Only references uploaded to + // the currently bound workspace qualify: after a rebind the + // old paths are unreadable for the agent and the server + // rejects them. Compare against the chat's bound workspace ID + // (available with the chat record) rather than the + // async-loaded workspace object, whose late resolution would + // otherwise drop references hydrated before it arrived. + // Kept separate from the attachment hydration above and keyed on + // the blocks reference plus the workspace binding: a rebind mid-edit + // only recomputes the references (ordinary attachments added during + // the edit survive), a failed edit submission restores the same + // array on rollback (same key, no re-run, so uploads added mid-edit + // survive), and rebinding the workspace away and back re-runs + // preservation so references valid for the restored binding + // reappear. Editing a different message passes a fresh array. + const hydratedEditBlocksRef = useRef<{ + blocks: readonly TypesGen.ChatMessagePart[] | null; + workspaceId: string | null; + } | null>(null); + useEffect(() => { + if (!isEditing) { + return; + } + if ( + hydratedEditBlocksRef.current !== null && + hydratedEditBlocksRef.current.blocks === (editingFileBlocks ?? null) && + hydratedEditBlocksRef.current.workspaceId === selectedWorkspaceId + ) { + return; + } + hydratedEditBlocksRef.current = { + blocks: editingFileBlocks ?? null, + workspaceId: selectedWorkspaceId, + }; + resetEditWorkspaceUploads(); + setPreservedWorkspaceUploads( + (editingFileBlocks ?? []) + .filter(isWorkspaceFileReferencePart) + .filter( + (part) => + selectedWorkspaceId !== null && + part.workspace_file_workspace_id === selectedWorkspaceId, + ) + .map( + (part, i): WorkspaceFileUpload => ({ + id: `preserved-${i}-${part.workspace_file_path}`, + file: new File([], part.workspace_file_name, { + type: + part.workspace_file_media_type || "application/octet-stream", + }), + status: "uploaded", + response: { + path: part.workspace_file_path, + name: part.workspace_file_name, + size: part.workspace_file_size, + media_type: + part.workspace_file_media_type || "application/octet-stream", + workspace_id: part.workspace_file_workspace_id, + }, + }), + ), + ); + }, [ + isEditing, + editingFileBlocks, + selectedWorkspaceId, + resetEditWorkspaceUploads, + ]); + // Exiting edit mode should only clear the edit bucket. Compose draft // attachments must survive canceling or completing an edit. useEffect(() => { - if (wasEditingRef.current && !isEditing) { - resetEditAttachments(); + if (isEditing) { + wasEditingRef.current = true; + return; } - wasEditingRef.current = isEditing; - }, [isEditing, resetEditAttachments]); + if (!wasEditingRef.current) { + return; + } + // History edits clear isEditing before the edit mutation + // settles and restore it on failure. Defer cleanup until the + // submission resolves so a failed edit keeps its uploads and + // attachments for retry. + if (isSendPending) { + return; + } + wasEditingRef.current = false; + hydratedEditBlocksRef.current = null; + resetEditAttachments(); + resetEditWorkspaceUploads(); + setPreservedWorkspaceUploads([]); + }, [ + isEditing, + isSendPending, + resetEditAttachments, + resetEditWorkspaceUploads, + ]); const isStreaming = hasStreamState || isActiveChatStatus(chatStatus); + // The workspace upload affordance requires an existing chat bound + // to a workspace whose agent is connected; the agent writes the + // bytes into its home directory. A freshly attached or rebound + // workspace has no bound agent until the next generation, and the + // upload handler then selects one itself, so any connected root + // agent qualifies in that case (mirrors the new-chat page). + const uploadAgentConnected = workspaceAgent + ? workspaceAgent.status === "connected" + : workspace !== undefined && + getWorkspaceAgents(workspace).some( + (agent) => !agent.parent_id && agent.status === "connected", + ); + const canUploadWorkspaceFiles = Boolean( + chatId && workspace && uploadAgentConnected, + ); + const modeWorkspaceUploads = isEditing + ? editWorkspaceUploads + : composeWorkspaceUploads; + const visibleWorkspaceUploads = isEditing + ? [...preservedWorkspaceUploads, ...editWorkspaceUploads.uploads] + : composeWorkspaceUploads.uploads; + const handleRemoveWorkspaceUpload = (id: string) => { + if ( + isEditing && + preservedWorkspaceUploads.some((upload) => upload.id === id) + ) { + setPreservedWorkspaceUploads((current) => + current.filter((upload) => upload.id !== id), + ); + return; + } + modeWorkspaceUploads.remove(id); + }; + const inputElement = ( { void (async () => { - const hasActiveUploads = attachments.some((file) => - isUploadInProgress(uploadStates.get(file)), - ); + const hasActiveUploads = + attachments.some((file) => + isUploadInProgress(uploadStates.get(file)), + ) || visibleWorkspaceUploads.some(isWorkspaceUploadInProgress); if (hasActiveUploads) { toast.warning("Wait for file uploads to finish before sending."); return; @@ -553,23 +743,56 @@ export const ChatPageInput: FC = ({ }); } } + const pendingWorkspaceUploads: PendingWorkspaceUpload[] = []; + let skippedWorkspaceErrors = 0; + for (const upload of visibleWorkspaceUploads) { + if (upload.status === "error") { + skippedWorkspaceErrors++; + continue; + } + if (upload.status === "uploaded" && upload.response) { + pendingWorkspaceUploads.push({ + path: upload.response.path, + name: upload.response.name, + size: upload.response.size, + mediaType: upload.response.media_type, + workspaceId: upload.response.workspace_id, + }); + } + } if (skippedErrors > 0) { toast.warning( `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, ); } - const attachmentArg = + if (skippedWorkspaceErrors > 0) { + toast.warning( + `${skippedWorkspaceErrors} workspace file${skippedWorkspaceErrors > 1 ? "s" : ""} could not be sent (upload failed)`, + ); + } + const attachmentsArg = pendingAttachments.length > 0 ? pendingAttachments : undefined; + const workspaceUploadsArg = + pendingWorkspaceUploads.length > 0 + ? pendingWorkspaceUploads + : undefined; try { - await onSend(message, attachmentArg); + await onSend({ + message, + attachments: attachmentsArg, + workspaceUploads: workspaceUploadsArg, + }); } catch { // Attachments preserved for retry on failure. return; } if (isEditing) { editAttachments.resetAttachments(); + resetEditWorkspaceUploads(); + setPreservedWorkspaceUploads([]); } else { composeAttachments.resetAttachments(); + composeWorkspaceUploads.reset(); } })(); }} @@ -579,6 +802,13 @@ export const ChatPageInput: FC = ({ uploadStates={uploadStates} previewUrls={previewUrls} textContents={textContents} + workspaceUploads={{ + uploads: visibleWorkspaceUploads, + onAttach: canUploadWorkspaceFiles + ? modeWorkspaceUploads.attach + : undefined, + onRemove: handleRemoveWorkspaceUpload, + }} inputRef={inputRef} initialValue={initialValue} initialEditorState={initialEditorState} diff --git a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx index 023f8541fe..459d9b9ff3 100644 --- a/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/components/QueuedMessagesList.tsx @@ -2,8 +2,8 @@ import { cn } from "cn"; import { ArrowUpIcon, CornerDownLeftIcon, - ImageIcon, InfoIcon, + PaperclipIcon, Trash2Icon, } from "lucide-react"; import { type FC, useEffect, useState } from "react"; @@ -36,7 +36,7 @@ export const getQueuedMessageInfo = ( const textParts: string[] = []; const hookNotices: string[] = []; for (const part of message.content) { - if (part.type === "file") { + if (part.type === "file" || part.type === "workspace-file-reference") { attachmentCount++; } else if (part.type === "text" && part.text?.trim()) { textParts.push(part.text); @@ -180,10 +180,10 @@ export const QueuedMessagesList: FC = ({ {item.attachmentCount > 0 && ( - )} diff --git a/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.stories.tsx b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.stories.tsx new file mode 100644 index 0000000000..6950cf1640 --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { createMockFile } from "#/testHelpers/files"; +import type { WorkspaceFileUpload } from "../hooks/useWorkspaceFileUploads"; +import { WorkspaceUploadPreview } from "./WorkspaceUploadPreview"; + +const uploadedEntry = (name: string, size = 4096): WorkspaceFileUpload => ({ + id: `uploaded-${name}`, + file: createMockFile(name, "application/zip"), + status: "uploaded", + response: { + path: `/home/coder/.coder/chats/chat-1/files/${name}`, + name, + size, + media_type: "application/zip", + workspace_id: "ws-1", + }, +}); + +const meta: Meta = { + title: "pages/AgentsPage/WorkspaceUploadPreview", + component: WorkspaceUploadPreview, + args: { + onRemove: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Uploaded: Story = { + args: { + uploads: [uploadedEntry("design-handoff.zip")], + }, +}; + +export const Uploading: Story = { + args: { + uploads: [ + { + id: "uploading-1", + file: createMockFile("dataset.tar.gz", "application/gzip"), + status: "uploading", + }, + ], + }, +}; + +export const UploadError: Story = { + args: { + uploads: [ + { + id: "error-1", + file: createMockFile("broken.zip", "application/zip"), + status: "error", + error: "Failed to upload file to workspace agent.", + }, + ], + }, +}; + +export const MixedStates: Story = { + args: { + uploads: [ + uploadedEntry("release.zip", 2 * 1024 * 1024), + { + id: "uploading-2", + file: createMockFile("video.mp4", "video/mp4"), + status: "uploading", + }, + { + id: "queued-2", + file: createMockFile("logs.tar", "application/x-tar"), + status: "queued", + }, + { + id: "error-2", + file: createMockFile("huge.iso", "application/x-iso9660-image"), + status: "error", + error: "Upload failed", + }, + ], + }, +}; diff --git a/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.test.tsx b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.test.tsx new file mode 100644 index 0000000000..1c92d45d50 --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.test.tsx @@ -0,0 +1,36 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { createMockFile } from "#/testHelpers/files"; +import { renderComponent } from "#/testHelpers/renderHelpers"; +import type { WorkspaceFileUpload } from "../hooks/useWorkspaceFileUploads"; +import { WorkspaceUploadPreview } from "./WorkspaceUploadPreview"; + +const uploadedEntry: WorkspaceFileUpload = { + id: "uploaded-design-handoff.zip", + file: createMockFile("design-handoff.zip", "application/zip"), + status: "uploaded", + response: { + path: "/home/coder/.coder/chats/chat-1/files/design-handoff.zip", + name: "design-handoff.zip", + size: 4096, + media_type: "application/zip", + workspace_id: "ws-1", + }, +}; + +describe("WorkspaceUploadPreview", () => { + it("reports the removed entry by id", async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + renderComponent( + , + ); + + await user.click( + screen.getByRole("button", { name: "Remove design-handoff.zip" }), + ); + + expect(onRemove).toHaveBeenCalledWith("uploaded-design-handoff.zip"); + }); +}); diff --git a/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.tsx b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.tsx new file mode 100644 index 0000000000..c3f9cdcf03 --- /dev/null +++ b/site/src/pages/AgentsPage/components/WorkspaceUploadPreview.tsx @@ -0,0 +1,103 @@ +import { cn } from "cn"; +import { HardDriveUploadIcon, XIcon } from "lucide-react"; +import prettyBytes from "pretty-bytes"; +import type { FC } from "react"; +import { Button } from "#/components/Button/Button"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import type { WorkspaceFileUpload } from "../hooks/useWorkspaceFileUploads"; + +const uploadStatusLabel = (upload: WorkspaceFileUpload): string => { + switch (upload.status) { + case "queued": + return "Waiting to upload..."; + case "uploading": + return "Uploading to workspace..."; + case "uploaded": + return upload.response + ? `${prettyBytes(upload.response.size)} in workspace` + : "In workspace"; + case "error": + return upload.error ?? "Upload failed"; + } +}; + +/** + * Composer chip strip for files streamed into the chat's workspace + * filesystem. Uploads are eager, so removing a chip only detaches the + * reference from the draft message; bytes already written stay in the + * workspace. + */ +export const WorkspaceUploadPreview: FC<{ + uploads: readonly WorkspaceFileUpload[]; + onRemove: (id: string) => void; +}> = ({ uploads, onRemove }) => { + if (uploads.length === 0) { + return null; + } + return ( +
+ {uploads.map((upload) => { + const name = upload.response?.name ?? upload.file.name; + return ( +
+ {upload.status === "uploading" ? ( + + ) : ( +
+ ); + })} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts b/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts index ae06783012..bbfb568a39 100644 --- a/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts +++ b/site/src/pages/AgentsPage/hooks/useConversationEditingState.test.ts @@ -250,7 +250,7 @@ describe("useConversationEditingState", () => { expect(result.current.remountKey).toBe(remountKeyBefore + 1); await act(async () => { - await result.current.handleSendFromInput("hello"); + await result.current.handleSendFromInput({ message: "hello" }); }); const remountKeyAfterSend = result.current.remountKey; @@ -263,7 +263,12 @@ describe("useConversationEditingState", () => { // the same text, so the editor is forced to reinitialize. expect(result.current.remountKey).toBe(remountKeyAfterSend + 1); expect(result.current.editorInitialValue).toBe("hello"); - expect(onSend).toHaveBeenCalledWith("hello", undefined, 7); + expect(onSend).toHaveBeenCalledWith({ + message: "hello", + attachments: undefined, + workspaceUploads: undefined, + editedMessageID: 7, + }); unmount(); }); @@ -278,10 +283,18 @@ describe("useConversationEditingState", () => { }); await act(async () => { - await result.current.handleSendFromInput("hello", attachments); + await result.current.handleSendFromInput({ + message: "hello", + attachments, + }); }); - expect(onSend).toHaveBeenCalledWith("hello", attachments, 7); + expect(onSend).toHaveBeenCalledWith({ + message: "hello", + attachments, + workspaceUploads: undefined, + editedMessageID: 7, + }); unmount(); }); @@ -312,7 +325,7 @@ describe("useConversationEditingState", () => { await act(async () => { await expect( - result.current.handleSendFromInput("edited message"), + result.current.handleSendFromInput({ message: "edited message" }), ).rejects.toThrow("boom"); }); @@ -336,9 +349,9 @@ describe("useConversationEditingState", () => { }); await act(async () => { - await expect(result.current.handleSendFromInput("hello")).rejects.toThrow( - "boom", - ); + await expect( + result.current.handleSendFromInput({ message: "hello" }), + ).rejects.toThrow("boom"); }); expect(mockInput.clear).not.toHaveBeenCalled(); @@ -355,10 +368,15 @@ describe("useConversationEditingState", () => { result.current.chatInputRef.current = mockInput.handle; await act(async () => { - await result.current.handleSendFromInput("hello"); + await result.current.handleSendFromInput({ message: "hello" }); }); - expect(onSend).toHaveBeenCalledWith("hello", undefined, undefined); + expect(onSend).toHaveBeenCalledWith({ + message: "hello", + attachments: undefined, + workspaceUploads: undefined, + editedMessageID: undefined, + }); expect(mockInput.clear).toHaveBeenCalled(); expect(mockInput.focus).toHaveBeenCalled(); expect(localStorage.getItem(expectedKey)).toBeNull(); @@ -399,9 +417,14 @@ describe("useConversationEditingState", () => { result.current.chatInputRef.current = mockInputRef; await act(async () => { - result.current.handleSendFromInput("hello"); + result.current.handleSendFromInput({ message: "hello" }); await vi.waitFor(() => { - expect(onSend).toHaveBeenCalledWith("hello", undefined, undefined); + expect(onSend).toHaveBeenCalledWith({ + message: "hello", + attachments: undefined, + workspaceUploads: undefined, + editedMessageID: undefined, + }); }); }); @@ -436,7 +459,7 @@ describe("useConversationEditingState", () => { expect(localStorage.getItem(expectedKey)).toBe("draft to clear"); await act(async () => { - result.current.handleSendFromInput("hello"); + result.current.handleSendFromInput({ message: "hello" }); await vi.waitFor(() => { expect(localStorage.getItem(expectedKey)).toBeNull(); }); diff --git a/site/src/pages/AgentsPage/hooks/useConversationEditingState.ts b/site/src/pages/AgentsPage/hooks/useConversationEditingState.ts index 02aa1bffe0..d453673e17 100644 --- a/site/src/pages/AgentsPage/hooks/useConversationEditingState.ts +++ b/site/src/pages/AgentsPage/hooks/useConversationEditingState.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import type { ChatMessagePart } from "#/api/typesGenerated"; import { isMobileViewport } from "#/utils/mobile"; import type { ChatMessageInputRef } from "../components/AgentChatInput"; -import type { PendingAttachment } from "../components/ChatPageContent"; +import type { SendChatMessageOptions } from "../components/ChatPageContent"; import { draftInputStorageKeyPrefix, type ParsedDraft, @@ -11,14 +11,14 @@ import { export class BuiltInCommandPendingError extends Error {} +export type SendChatTurnOptions = SendChatMessageOptions & { + editedMessageID?: number; +}; + /** @internal Exported for testing. */ export function useConversationEditingState(deps: { chatID: string | undefined; - onSend: ( - message: string, - attachments?: readonly PendingAttachment[], - editedMessageID?: number, - ) => Promise; + onSend: (options: SendChatTurnOptions) => Promise; chatInputRef: React.RefObject; inputValueRef: React.RefObject; }) { @@ -153,13 +153,19 @@ export function useConversationEditingState(deps: { }; // Wraps the parent onSend to clear local input/editing state. - const handleSendFromInput = async ( - message: string, - attachments?: readonly PendingAttachment[], - ) => { + const handleSendFromInput = async ({ + message, + attachments, + workspaceUploads, + }: SendChatMessageOptions) => { const editedMessageID = editingMessageId !== null ? editingMessageId : undefined; - const sendPromise = onSend(message, attachments, editedMessageID); + const sendPromise = onSend({ + message, + attachments, + workspaceUploads, + editedMessageID, + }); // For history edits, clear input immediately and prepare // a rollback in case the send fails. diff --git a/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.test.ts b/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.test.ts new file mode 100644 index 0000000000..b63c716734 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.test.ts @@ -0,0 +1,306 @@ +import { renderHook as renderHookBase, waitFor } from "@testing-library/react"; +import { act, createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mockApiError } from "#/testHelpers/entities"; +import { useWorkspaceFileUploads } from "./useWorkspaceFileUploads"; + +vi.mock("#/api/api", () => ({ + API: { + experimental: { + uploadChatWorkspaceFile: vi.fn(), + }, + }, +})); + +const { API } = await import("#/api/api"); +const uploadMock = vi.mocked(API.experimental.uploadChatWorkspaceFile); + +const makeFile = (name = "doc.bin", type = "application/x-tar"): File => + new File([new Uint8Array(16)], name, { type }); + +const okResponse = { + path: "/home/coder/.coder/chats/chat-1/files/doc.bin", + name: "doc.bin", + size: 16, + media_type: "application/x-tar", + workspace_id: "ws-1", +}; + +const renderHook: typeof renderHookBase = (callback, options) => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return renderHookBase(callback, { + ...options, + wrapper: ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children), + }); +}; + +describe("useWorkspaceFileUploads", () => { + beforeEach(() => { + uploadMock.mockReset(); + }); + + it("transitions an upload from uploading to uploaded", async () => { + uploadMock.mockResolvedValueOnce(okResponse); + const { result } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([makeFile()]); + }); + + expect(result.current.uploads).toHaveLength(1); + expect(result.current.uploads[0].status).toBe("uploading"); + + await waitFor(() => { + expect(result.current.uploads[0].status).toBe("uploaded"); + }); + expect(result.current.uploads[0].response).toEqual(okResponse); + expect(uploadMock).toHaveBeenCalledWith( + "chat-1", + expect.any(File), + expect.any(AbortSignal), + ); + }); + + it("records the server message and detail when the upload fails", async () => { + uploadMock.mockRejectedValueOnce( + Object.assign( + new Error("Request failed with status code 502"), + mockApiError({ + message: "Failed to upload file to workspace agent.", + detail: "The workspace agent could not be reached.", + }), + ), + ); + const { result } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([makeFile()]); + }); + + await waitFor(() => { + expect(result.current.uploads[0].status).toBe("error"); + }); + expect(result.current.uploads[0].error).toBe( + "Failed to upload file to workspace agent. The workspace agent could not be reached.", + ); + }); + + it("marks files beyond the concurrency limit as queued", () => { + uploadMock.mockImplementation(() => new Promise(() => {})); + const { result } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([ + makeFile("a.bin"), + makeFile("b.bin"), + makeFile("c.bin"), + makeFile("d.bin"), + ]); + }); + + expect(result.current.uploads.map((upload) => upload.status)).toEqual([ + "uploading", + "uploading", + "uploading", + "queued", + ]); + }); + + it("errors immediately without a chat id", () => { + const { result } = renderHook(() => + useWorkspaceFileUploads(undefined, "ws-1"), + ); + + act(() => { + result.current.attach([makeFile()]); + }); + + expect(result.current.uploads[0].status).toBe("error"); + expect(uploadMock).not.toHaveBeenCalled(); + }); + + it("remove aborts an in-flight upload and drops the entry", async () => { + let capturedSignal: AbortSignal | undefined; + uploadMock.mockImplementationOnce( + (_chatId: string, _file: File, signal?: AbortSignal) => { + capturedSignal = signal; + return new Promise(() => { + // Never resolves; the abort is what ends it. + }); + }, + ); + const { result } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([makeFile()]); + }); + const [entry] = result.current.uploads; + + act(() => { + result.current.remove(entry.id); + }); + + expect(result.current.uploads).toHaveLength(0); + await waitFor(() => { + expect(capturedSignal?.aborted).toBe(true); + }); + }); + + it("keeps uploaded entries when another entry is removed", async () => { + uploadMock.mockResolvedValue(okResponse); + const { result } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([makeFile("a.tar"), makeFile("b.tar")]); + }); + + await waitFor(() => { + expect(result.current.uploads.every((u) => u.status === "uploaded")).toBe( + true, + ); + }); + + act(() => { + result.current.remove(result.current.uploads[0].id); + }); + + expect(result.current.uploads).toHaveLength(1); + expect(result.current.uploads[0].file.name).toBe("b.tar"); + }); + + it("resets pending uploads when the chat changes", async () => { + uploadMock.mockResolvedValue(okResponse); + const { result, rerender } = renderHook( + ({ chatId }: { chatId: string }) => + useWorkspaceFileUploads(chatId, "ws-1"), + { initialProps: { chatId: "chat-1" } }, + ); + + act(() => { + result.current.attach([makeFile()]); + }); + await waitFor(() => { + expect(result.current.uploads[0].status).toBe("uploaded"); + }); + + rerender({ chatId: "chat-2" }); + + await waitFor(() => { + expect(result.current.uploads).toHaveLength(0); + }); + }); + + it("resets uploads when the bound workspace changes", async () => { + uploadMock.mockResolvedValue(okResponse); + const initialProps: { workspaceId: string | undefined } = { + workspaceId: "ws-1", + }; + const { result, rerender } = renderHook( + ({ workspaceId }: typeof initialProps) => + useWorkspaceFileUploads("chat-1", workspaceId), + { initialProps }, + ); + + act(() => { + result.current.attach([makeFile()]); + }); + await waitFor(() => { + expect(result.current.uploads[0].status).toBe("uploaded"); + }); + + rerender({ workspaceId: "ws-2" }); + + await waitFor(() => { + expect(result.current.uploads).toHaveLength(0); + }); + }); + + it("uploads to the new chat while stale uploads are still settling", async () => { + // Saturate all worker slots with chat-1 uploads that never + // settle, so stale workers still hold slots when chat-2 files + // arrive. Without generation-scoped slot accounting the chat-2 + // upload would never start (or a stale chat-1 worker would + // steal it). + uploadMock.mockImplementation((chatId: string) => { + if (chatId === "chat-1") { + return new Promise(() => { + // Never resolves; the abort is what ends it. + }); + } + return Promise.resolve(okResponse); + }); + const { result, rerender } = renderHook( + ({ chatId }: { chatId: string }) => + useWorkspaceFileUploads(chatId, "ws-1"), + { initialProps: { chatId: "chat-1" } }, + ); + + act(() => { + result.current.attach([ + makeFile("a.tar"), + makeFile("b.tar"), + makeFile("c.tar"), + ]); + }); + await waitFor(() => { + expect(uploadMock).toHaveBeenCalledTimes(3); + }); + + rerender({ chatId: "chat-2" }); + await waitFor(() => { + expect(result.current.uploads).toHaveLength(0); + }); + + act(() => { + result.current.attach([makeFile("new.tar")]); + }); + + await waitFor(() => { + expect(result.current.uploads[0]?.status).toBe("uploaded"); + }); + expect(uploadMock).toHaveBeenLastCalledWith( + "chat-2", + expect.any(File), + expect.any(AbortSignal), + ); + }); + + it("aborts in-flight uploads on unmount", async () => { + let capturedSignal: AbortSignal | undefined; + uploadMock.mockImplementationOnce( + (_chatId: string, _file: File, signal?: AbortSignal) => { + capturedSignal = signal; + return new Promise(() => { + // Never resolves; the abort is what ends it. + }); + }, + ); + const { result, unmount } = renderHook(() => + useWorkspaceFileUploads("chat-1", "ws-1"), + ); + + act(() => { + result.current.attach([makeFile()]); + }); + + unmount(); + + await waitFor(() => { + expect(capturedSignal?.aborted).toBe(true); + }); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.ts b/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.ts new file mode 100644 index 0000000000..77651f88c9 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useWorkspaceFileUploads.ts @@ -0,0 +1,216 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "react-query"; +import { API } from "#/api/api"; +import type { UploadChatWorkspaceFileResponse } from "#/api/typesGenerated"; +import { renameChatFileForUpload } from "../utils/chatAttachments"; +import { formatAgentAttachmentUploadError } from "../utils/fileAttachmentLimits"; + +type WorkspaceFileUploadStatus = "queued" | "uploading" | "uploaded" | "error"; + +export type WorkspaceFileUpload = { + id: string; + file: File; + status: WorkspaceFileUploadStatus; + error?: string; + // Set once status is "uploaded". Carries the final path, name, + // size, and media type reported by the workspace agent. + response?: UploadChatWorkspaceFileResponse; +}; + +export const isWorkspaceUploadInProgress = ( + upload: WorkspaceFileUpload, +): boolean => upload.status === "queued" || upload.status === "uploading"; + +type UseWorkspaceFileUploadsReturn = { + uploads: readonly WorkspaceFileUpload[]; + attach: (files: File[]) => void; + remove: (id: string) => void; + reset: () => void; +}; + +// Workspace uploads have no size cap, so bound the number of parallel +// streams instead of relying on the browser's per-host connection +// limits alone. +const maxConcurrentWorkspaceUploads = 3; + +const createUploadId = (): string => { + const cryptoObject = + typeof globalThis.crypto !== "undefined" ? globalThis.crypto : undefined; + if (cryptoObject?.randomUUID) { + return cryptoObject.randomUUID(); + } + return `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`; +}; + +/** + * Manages eager uploads of files into the chat's workspace filesystem + * via POST /api/v2/chats/{chat}/workspace-files. + * + * Uploads start as soon as files are attached, bounded to a small + * number of concurrent streams. Removing an entry aborts its in-flight + * upload, but bytes that already reached the workspace stay there; + * removal only drops the composer reference. + * + * Uploads are scoped to the chat and its bound workspace: switching + * either one resets the pending set, because already-uploaded bytes + * live in the previous workspace and their references would be + * unreadable for the new one. + */ +export function useWorkspaceFileUploads( + chatId: string | undefined, + workspaceId: string | undefined, +): UseWorkspaceFileUploadsReturn { + const [uploads, setUploads] = useState([]); + const abortControllersRef = useRef(new Map()); + const pendingQueueRef = useRef<{ id: string; file: File }[]>([]); + const activeCountRef = useRef(0); + // Incremented whenever pending uploads are aborted (reset or scope + // switch). Workers spawned under an older generation must not + // consume entries queued after the abort: they carry a stale chat + // ID and their slot accounting was already zeroed. + const generationRef = useRef(0); + const scopeKey = `${chatId ?? ""}/${workspaceId ?? ""}`; + const previousScopeKeyRef = useRef(scopeKey); + + const uploadMutation = useMutation({ + mutationFn: ({ + uploadChatId, + file, + signal, + }: { + uploadChatId: string; + file: File; + signal: AbortSignal; + }) => API.experimental.uploadChatWorkspaceFile(uploadChatId, file, signal), + }); + const { mutateAsync: uploadFile } = uploadMutation; + + const abortAllUploads = () => { + generationRef.current++; + for (const controller of abortControllersRef.current.values()) { + controller.abort(); + } + abortControllersRef.current.clear(); + pendingQueueRef.current = []; + activeCountRef.current = 0; + }; + + const reset = () => { + abortAllUploads(); + setUploads([]); + }; + + // Abort any in-flight uploads when the composer unmounts. + useEffect(() => abortAllUploads, [abortAllUploads]); + + // Uploads target a specific chat's directory in a specific + // workspace, so navigating to a different chat or rebinding the + // workspace invalidates the pending set. + useEffect(() => { + if (previousScopeKeyRef.current === scopeKey) { + return; + } + previousScopeKeyRef.current = scopeKey; + reset(); + }, [scopeKey, reset]); + + const setUploadResult = ( + id: string, + result: Partial, + ) => { + setUploads((prev) => + prev.map((upload) => + upload.id === id ? { ...upload, ...result } : upload, + ), + ); + }; + + // Each worker pulls queued files until the queue drains, so a + // completed upload immediately frees its slot for the next file. + const runUploadWorker = async (uploadChatId: string) => { + const generation = generationRef.current; + activeCountRef.current++; + let next = pendingQueueRef.current.shift(); + while (next) { + // A removed entry has no abort controller anymore; skip it. + const controller = abortControllersRef.current.get(next.id); + if (controller) { + setUploadResult(next.id, { status: "uploading" }); + try { + const response = await uploadFile({ + uploadChatId, + file: next.file, + signal: controller.signal, + }); + setUploadResult(next.id, { status: "uploaded", response }); + } catch (error: unknown) { + if (!controller.signal.aborted) { + setUploadResult(next.id, { + status: "error", + error: formatAgentAttachmentUploadError(error), + }); + } + } + abortControllersRef.current.delete(next.id); + } + if (generationRef.current !== generation) { + // An abort invalidated this worker mid-upload. Entries + // queued since belong to workers of the new generation, + // which also owns the slot accounting. + return; + } + next = pendingQueueRef.current.shift(); + } + activeCountRef.current--; + }; + + const pumpQueue = () => { + if (!chatId) { + return; + } + // Workers shift their first queue entry synchronously, so this + // loop spawns at most one worker per pending file. + while ( + activeCountRef.current < maxConcurrentWorkspaceUploads && + pendingQueueRef.current.length > 0 + ) { + void runUploadWorker(chatId); + } + }; + + const attach = (incoming: File[]) => { + const entries = incoming.map((file) => ({ + id: createUploadId(), + file: renameChatFileForUpload(file), + status: "queued" as const, + })); + if (!chatId) { + setUploads((prev) => [ + ...prev, + ...entries.map((entry) => ({ + ...entry, + status: "error" as const, + error: "Cannot upload: no active chat. Open or start a chat first.", + })), + ]); + return; + } + setUploads((prev) => [...prev, ...entries]); + for (const entry of entries) { + abortControllersRef.current.set(entry.id, new AbortController()); + pendingQueueRef.current.push({ id: entry.id, file: entry.file }); + } + pumpQueue(); + }; + + const remove = (id: string) => { + abortControllersRef.current.get(id)?.abort(); + abortControllersRef.current.delete(id); + pendingQueueRef.current = pendingQueueRef.current.filter( + (entry) => entry.id !== id, + ); + setUploads((prev) => prev.filter((upload) => upload.id !== id)); + }; + + return { uploads, attach, remove, reset }; +} diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts index 7fcef3b013..61fa68f785 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -13,6 +13,7 @@ import { isRasterImageMediaType, renameChatFileForUpload, sanitizeChatFileName, + shouldRouteFileToWorkspace, } from "./chatAttachments"; describe("handleAttachmentDownloadClick", () => { @@ -287,6 +288,36 @@ describe("isRasterImageMediaType", () => { }); }); +describe("shouldRouteFileToWorkspace", () => { + it("keeps allowlisted MIME types on the attachment path", () => { + const file = new File(["png"], "image.png", { type: "image/png" }); + + expect(shouldRouteFileToWorkspace(file)).toBe(false); + }); + + it("keeps files with an empty MIME type on the attachment path", () => { + const file = new File(["markdown"], "notes.md"); + + expect(shouldRouteFileToWorkspace(file)).toBe(false); + }); + + it("keeps application/octet-stream files on the attachment path", () => { + const file = new File(["unknown"], "attachment.bin", { + type: "application/octet-stream", + }); + + expect(shouldRouteFileToWorkspace(file)).toBe(false); + }); + + it("routes declared non-allowlisted MIME types to the workspace", () => { + const file = new File(["zip"], "archive.zip", { + type: "application/zip", + }); + + expect(shouldRouteFileToWorkspace(file)).toBe(true); + }); +}); + describe("sanitizeChatFileName", () => { it.each([ // Already safe. diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index 585e74a897..40ccfa0c4b 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,5 +1,6 @@ import { toast } from "sonner"; import { getErrorMessage, isApiErrorResponse } from "#/api/errors"; +import type * as TypesGen from "#/api/typesGenerated"; import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; import { decodeDataURL } from "./dataUrls"; @@ -225,6 +226,21 @@ export const isChatAttachmentFile = (file: File): boolean => { export const isRasterImageMediaType = (mediaType: string): boolean => mediaType.startsWith("image/") && mediaType !== "image/svg+xml"; +/** + * Returns true for files that should stream into the chat's workspace + * filesystem instead of the attachment pipeline: any file whose + * declared MIME type is not on the attachment allowlist. Files with an + * unknown type (empty or application/octet-stream) stay on the + * attachment path where the server classifies the bytes. + */ +export const shouldRouteFileToWorkspace = (file: File): boolean => + !isChatAttachmentFile(file); + +export const isWorkspaceFileReferencePart = ( + part: TypesGen.ChatMessagePart, +): part is TypesGen.ChatWorkspaceFileReferencePart => + part.type === "workspace-file-reference"; + // Matches characters that commonly cause trouble downstream: bracketing // punctuation, quotes, shell or URL or path metacharacters, path // separators, any whitespace, and control characters. ASCII alphanumerics, diff --git a/site/src/pages/AgentsPage/utils/chatInputContent.test.ts b/site/src/pages/AgentsPage/utils/chatInputContent.test.ts index c4eedb7663..1d03667441 100644 --- a/site/src/pages/AgentsPage/utils/chatInputContent.test.ts +++ b/site/src/pages/AgentsPage/utils/chatInputContent.test.ts @@ -117,4 +117,35 @@ describe("buildChatInputContent", () => { hasContent: true, }); }); + + it("appends workspace file references after attachments", () => { + expect( + buildChatInputContent({ + message: "", + attachments: [{ fileId: "file-1", mediaType: "image/png" }], + workspaceUploads: [ + { + path: "/home/coder/uploads/data.zip", + name: "data.zip", + size: 42, + mediaType: "", + workspaceId: "ws-1", + }, + ], + }), + ).toEqual({ + content: [ + { type: "file", file_id: "file-1" }, + { + type: "workspace-file-reference", + workspace_file_path: "/home/coder/uploads/data.zip", + workspace_file_name: "data.zip", + workspace_file_size: 42, + workspace_file_media_type: "application/octet-stream", + workspace_file_workspace_id: "ws-1", + }, + ], + hasContent: true, + }); + }); }); diff --git a/site/src/pages/AgentsPage/utils/chatInputContent.ts b/site/src/pages/AgentsPage/utils/chatInputContent.ts index e4c4af1d4f..db57c787b5 100644 --- a/site/src/pages/AgentsPage/utils/chatInputContent.ts +++ b/site/src/pages/AgentsPage/utils/chatInputContent.ts @@ -1,5 +1,8 @@ import type * as TypesGen from "#/api/typesGenerated"; -import type { PendingAttachment } from "../components/ChatPageContent"; +import type { + PendingAttachment, + PendingWorkspaceUpload, +} from "../components/ChatPageContent"; export type ChatComposerContentPart = | { readonly type: "text"; readonly text: string } @@ -32,10 +35,12 @@ export const buildAttachmentMediaTypes = ( export const buildChatInputContent = ({ message, attachments, + workspaceUploads, composerParts, }: { message: string; attachments?: readonly PendingAttachment[]; + workspaceUploads?: readonly PendingWorkspaceUpload[]; composerParts?: readonly ChatComposerContentPart[]; }): { content: TypesGen.ChatInputPart[]; hasContent: boolean } => { const content: TypesGen.ChatInputPart[] = []; @@ -71,5 +76,19 @@ export const buildChatInputContent = ({ } } + if (workspaceUploads && workspaceUploads.length > 0) { + for (const upload of workspaceUploads) { + content.push({ + type: "workspace-file-reference", + workspace_file_path: upload.path, + workspace_file_name: upload.name, + workspace_file_size: upload.size, + workspace_file_media_type: + upload.mediaType || "application/octet-stream", + workspace_file_workspace_id: upload.workspaceId, + }); + } + } + return { content, hasContent: content.length > 0 }; }; diff --git a/site/src/pages/AgentsPage/utils/fileAttachmentLimits.test.ts b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.test.ts new file mode 100644 index 0000000000..2139145e6b --- /dev/null +++ b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { mockApiError } from "#/testHelpers/entities"; +import { formatAgentAttachmentUploadError } from "./fileAttachmentLimits"; + +const axiosApiError = (message: string, detail?: string) => + Object.assign( + new Error("Request failed with status code 409"), + mockApiError({ message, detail }), + ); + +describe("formatAgentAttachmentUploadError", () => { + it("returns the server message alone when there is no detail", () => { + expect( + formatAgentAttachmentUploadError( + axiosApiError( + "Workspace is stopped. Start the workspace before uploading files.", + ), + ), + ).toBe("Workspace is stopped. Start the workspace before uploading files."); + }); + + it("does not double punctuation before the detail", () => { + expect( + formatAgentAttachmentUploadError( + axiosApiError( + "Failed to upload file to workspace agent.", + "The workspace agent could not be reached.", + ), + ), + ).toBe( + "Failed to upload file to workspace agent. The workspace agent could not be reached.", + ); + }); + + it("adds a period between an unpunctuated message and the detail", () => { + expect( + formatAgentAttachmentUploadError( + axiosApiError("Upload rejected", "File type not allowed."), + ), + ).toBe("Upload rejected. File type not allowed."); + }); + + it("omits the developer console hint for non-API errors", () => { + expect(formatAgentAttachmentUploadError(new Error("Network Error"))).toBe( + "Network Error", + ); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts index 85b0c50f6a..617f265d13 100644 --- a/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts +++ b/site/src/pages/AgentsPage/utils/fileAttachmentLimits.ts @@ -1,13 +1,29 @@ -import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { getErrorMessage, isApiError, isApiErrorResponse } from "#/api/errors"; import { MaxChatFileSizeBytes } from "#/api/typesGenerated"; export const formatAgentAttachmentTooLargeError = (fileSize: number): string => `File too large (${(fileSize / 1024 / 1024).toFixed(1)} MiB). Maximum is ${MaxChatFileSizeBytes / 1024 / 1024} MiB.`; +// Unlike getErrorDetail, never fall back to the developer console hint: +// upload errors render on compact chips next to the file. +const getUploadErrorDetail = (error: unknown): string | undefined => { + if (isApiError(error)) { + return error.response.data.detail; + } + if (isApiErrorResponse(error)) { + return error.detail; + } + return undefined; +}; + export const formatAgentAttachmentUploadError = (error: unknown): string => { - const message = getErrorMessage(error, "Upload failed"); - const detail = getErrorDetail(error); - return detail ? `${message}. ${detail}` : message; + const message = getErrorMessage(error, "Upload failed").trim(); + const detail = getUploadErrorDetail(error)?.trim(); + if (!detail) { + return message; + } + const separator = /[.!?]$/.test(message) ? " " : ". "; + return `${message}${separator}${detail}`; }; export const readAgentAttachmentText = (file: File): Promise => {