mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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:
+27
-8
@@ -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;
|
||||
|
||||
@@ -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) ?? {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user