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/<chat-id>/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.
This commit is contained in:
Michael Suchacz
2026-09-27 12:25:25 +02:00
committed by GitHub
parent 2c1e888d6c
commit 87303d6f0d
30 changed files with 1742 additions and 177 deletions
+27 -8
View File
@@ -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<TypesGen.UploadChatWorkspaceFileResponse> => {
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;
+10
View File
@@ -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) ?? {
+11 -7
View File
@@ -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() ?? [],
});
@@ -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,
@@ -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<ChatMessageInputRef>();
renderInput(
<AgentChatInput
inputRef={inputRef}
onSend={vi.fn()}
onAttach={onAttach}
attachments={[]}
isDisabled={false}
isLoading={false}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
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(
<AgentChatInput
onSend={vi.fn()}
onAttach={onAttach}
attachments={[]}
workspaceUploads={{
uploads: [],
onAttach: onWorkspaceAttach,
onRemove: vi.fn(),
}}
isDisabled={false}
isLoading={false}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
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(
<AgentChatInput
onSend={vi.fn()}
onAttach={onAttach}
attachments={[]}
workspaceUploads={{
uploads: [],
onAttach: onWorkspaceAttach,
onRemove: vi.fn(),
}}
isDisabled
isLoading={false}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
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(
<AgentChatInput
onSend={vi.fn()}
onAttach={onAttach}
attachments={[]}
workspaceUploads={{
uploads: [],
onAttach: onWorkspaceAttach,
onRemove: vi.fn(),
}}
isDisabled={false}
isLoading
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
// 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(
<AgentChatInput
onSend={vi.fn()}
onAttach={onAttach}
attachments={[]}
workspaceUploads={{
uploads: [],
onAttach: undefined,
onRemove: vi.fn(),
}}
isDisabled={false}
isLoading={false}
selectedModel={modelOptions[0].id}
onModelChange={vi.fn()}
modelOptions={modelOptions}
modelSelectorPlaceholder="Select model"
hasModelOptions
canConfigureAgentSetup={false}
/>,
);
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.",
);
});
});
@@ -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<File, UploadState>;
previewUrls?: Map<File, string>;
textContents?: Map<File, string>;
workspaceUploads?: WorkspaceUploadsProps;
onTextPreview?: (
content: string,
fileName: string,
@@ -409,6 +433,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
uploadStates,
previewUrls,
textContents,
workspaceUploads,
onTextPreview,
mcpServers,
selectedMCPServerIds,
@@ -800,19 +825,75 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
};
}, [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<HTMLInputElement>) => {
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<AgentChatInputProps> = ({
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<AgentChatInputProps> = ({
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<AgentChatInputProps> = ({
onInlineText={handleInlineText}
/>
)}
{workspaceUploads && (
<WorkspaceUploadPreview
uploads={workspaceUploads.uploads}
onRemove={workspaceUploads.onRemove}
/>
)}
<ChatMessageInput
ref={internalRef}
onFilePaste={onAttach ? handleFilePaste : undefined}
@@ -1251,13 +1332,18 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
</Alert>
</div>
)}
{/* 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 && (
<input
ref={fileInputRef}
type="file"
multiple
accept={chatAttachmentAcceptAttribute}
accept={
onWorkspaceAttach ? undefined : chatAttachmentAcceptAttribute
}
onChange={handleFileSelect}
className="hidden"
/>
@@ -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);
}}
>
<PencilIcon />
@@ -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<{
</div>
)}
{displayState.hasWorkspaceFileReferences && (
<div className="rounded-md border border-border-default bg-surface-tertiary px-2.5 py-1.5 text-xs text-content-secondary">
{workspaceFilePlaceholderText(
displayState.workspaceFileReferenceCount,
<div
className={cn(
displayState.hasUserMessageBody && "mt-2",
"flex flex-wrap gap-2",
)}
>
{displayState.workspaceFileBlocks.map((block, index) => (
<WorkspaceFileChip
key={`workspace-file-${block.workspace_file_path}-${index}`}
name={block.workspace_file_name}
path={block.workspace_file_path}
size={block.workspace_file_size}
/>
))}
</div>
)}
</div>
@@ -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 }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex max-w-64 cursor-default items-center gap-1.5 rounded-md border border-solid border-border-default bg-surface-tertiary px-2.5 py-1.5 text-left text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-content-link"
>
<HardDriveIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-content-secondary"
/>
<span className="truncate font-medium text-content-primary">
{name}
</span>
{size > 0 && (
<span className="shrink-0 text-content-secondary">
{prettyBytes(size)}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="max-w-xs break-all font-mono text-xs">{path}</p>
</TooltipContent>
</Tooltip>
);
@@ -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,
@@ -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", () => {
@@ -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 ?? [])
@@ -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) {
@@ -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;
@@ -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<typeof createChatStore>,
onInterrupt: () => void,
overrides: Partial<ComponentProps<typeof ChatPageInput>> = {},
) =>
renderWithAuth(
<ChatPageInput
@@ -18,7 +20,7 @@ const renderChatPageInput = (
onSend={vi.fn()}
onDeleteQueuedMessage={vi.fn()}
onPromoteQueuedMessage={vi.fn()}
onInterrupt={onInterrupt}
onInterrupt={vi.fn()}
isInputDisabled={false}
isSendPending={false}
isInterruptPending={false}
@@ -37,9 +39,22 @@ const renderChatPageInput = (
canConfigureAgentSetup={false}
isEditing={false}
onCancelHistoryEdit={vi.fn()}
{...overrides}
/>,
);
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",
},
]);
});
});
@@ -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> | void;
onSend: (options: SendChatMessageOptions) => Promise<void> | void;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
onInterrupt: () => void;
@@ -432,6 +461,23 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
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<ChatPageInputProps> = ({
// 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<ChatPageInputProps> = ({
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<ChatPageInputProps> = ({
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 = (
<AgentChatInput
onSend={(message) => {
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<ChatPageInputProps> = ({
});
}
}
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<ChatPageInputProps> = ({
uploadStates={uploadStates}
previewUrls={previewUrls}
textContents={textContents}
workspaceUploads={{
uploads: visibleWorkspaceUploads,
onAttach: canUploadWorkspaceFiles
? modeWorkspaceUploads.attach
: undefined,
onRemove: handleRemoveWorkspaceUpload,
}}
inputRef={inputRef}
initialValue={initialValue}
initialEditorState={initialEditorState}
@@ -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<QueuedMessagesListProps> = ({
{item.attachmentCount > 0 && (
<span
role="img"
aria-label={`${item.attachmentCount} image attachment${item.attachmentCount !== 1 ? "s" : ""}`}
aria-label={`${item.attachmentCount} attachment${item.attachmentCount !== 1 ? "s" : ""}`}
className="flex shrink-0 items-center gap-1 text-xs text-content-secondary"
>
<ImageIcon className="size-3" aria-hidden="true" />
<PaperclipIcon className="size-3" aria-hidden="true" />
<span aria-hidden="true">{item.attachmentCount}</span>
</span>
)}
@@ -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<typeof WorkspaceUploadPreview> = {
title: "pages/AgentsPage/WorkspaceUploadPreview",
component: WorkspaceUploadPreview,
args: {
onRemove: fn(),
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceUploadPreview>;
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",
},
],
},
};
@@ -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(
<WorkspaceUploadPreview uploads={[uploadedEntry]} onRemove={onRemove} />,
);
await user.click(
screen.getByRole("button", { name: "Remove design-handoff.zip" }),
);
expect(onRemove).toHaveBeenCalledWith("uploaded-design-handoff.zip");
});
});
@@ -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 (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{uploads.map((upload) => {
const name = upload.response?.name ?? upload.file.name;
return (
<div
key={upload.id}
className="flex max-w-64 items-center gap-2 rounded-md border border-solid border-border-default bg-surface-tertiary px-2.5 py-1.5 text-xs"
>
{upload.status === "uploading" ? (
<Spinner
className="size-3.5 shrink-0 text-content-secondary"
loading
/>
) : (
<HardDriveUploadIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-content-secondary"
/>
)}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-content-primary">
{name}
</div>
<div
className={cn(
"truncate text-2xs",
upload.status === "error"
? "text-content-destructive"
: "text-content-secondary",
)}
>
{uploadStatusLabel(upload)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="subtle"
size="icon"
className="size-5 shrink-0 text-content-secondary hover:text-content-primary"
aria-label={`Remove ${name}`}
onClick={() => onRemove(upload.id)}
>
<XIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{upload.status === "uploaded"
? "Removes the reference. Uploaded bytes stay in the workspace."
: upload.status === "error"
? "Remove this file"
: "Cancel this upload"}
</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
);
};
@@ -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();
});
@@ -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<void>;
onSend: (options: SendChatTurnOptions) => Promise<void>;
chatInputRef: React.RefObject<ChatMessageInputRef | null>;
inputValueRef: React.RefObject<string>;
}) {
@@ -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.
@@ -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);
});
});
});
@@ -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<readonly WorkspaceFileUpload[]>([]);
const abortControllersRef = useRef(new Map<string, AbortController>());
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<WorkspaceFileUpload>,
) => {
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 };
}
@@ -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.
@@ -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,
@@ -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,
});
});
});
@@ -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 };
};
@@ -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",
);
});
});
@@ -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<string> => {