From f0a410fe33f0cce082b80bca05d1ff5814fb60ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:04:06 +0000 Subject: [PATCH 01/14] feat(chat): stage arbitrary files from the creation composer Creation composers (new chat on project/scratch pages) previously rejected non-provider files because no workspace exists to stage into. Hold such files in memory as a new pending-file attachment kind, then stage them via the existing stageAttachment IPC right after workspace creation and append the attached-files notice to the first message. If any staging call fails, fail closed: skip the initial send, transfer the draft (text + staged/pending attachments) into the new workspace's composer, and surface the error as a toast after navigation so the user can retry without creating a duplicate workspace. Workspace composers stage any pending-file drafts (from such transfers) at submit time. The file picker accept filter gate is removed since every composer can now take arbitrary files. --- .../features/ChatInput/AttachFileButton.tsx | 21 +- .../features/ChatInput/ChatAttachments.tsx | 21 +- .../ChatInput/draftAttachmentsStorage.test.ts | 26 +++ .../ChatInput/draftAttachmentsStorage.ts | 39 ++++ src/browser/features/ChatInput/index.tsx | 109 +++++++-- .../ChatInput/pendingFileAttachments.test.ts | 159 +++++++++++++ .../ChatInput/pendingFileAttachments.ts | 85 +++++++ .../ChatInput/useCreationWorkspace.test.tsx | 210 +++++++++++++++++- .../ChatInput/useCreationWorkspace.ts | 91 +++++++- src/browser/utils/attachmentsHandling.test.ts | 90 ++++++++ src/browser/utils/attachmentsHandling.ts | 62 ++++-- 11 files changed, 851 insertions(+), 62 deletions(-) create mode 100644 src/browser/features/ChatInput/pendingFileAttachments.test.ts create mode 100644 src/browser/features/ChatInput/pendingFileAttachments.ts diff --git a/src/browser/features/ChatInput/AttachFileButton.tsx b/src/browser/features/ChatInput/AttachFileButton.tsx index 2d65cb6df5f..cebe9ccf427 100644 --- a/src/browser/features/ChatInput/AttachFileButton.tsx +++ b/src/browser/features/ChatInput/AttachFileButton.tsx @@ -1,7 +1,7 @@ /** * Attach file button that opens a native picker. - * Images and PDFs attach natively. When staging is available (open workspace), - * any other file type is accepted and saved into the workspace. + * Images and PDFs attach natively; any other file type is saved into the + * workspace (immediately, or on first send for creation composers). */ import React, { useRef } from "react"; @@ -9,13 +9,9 @@ import { Paperclip } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; import { cn } from "@/common/lib/utils"; -/** Picker filter for composers without workspace staging (creation/scratch). */ -const PROVIDER_FILE_ACCEPT = "image/*,.svg,.pdf"; - interface AttachFileButtonProps { onFiles: (files: File[]) => void; disabled?: boolean; - canStageFiles: boolean; } export const AttachFileButton: React.FC = (props) => { @@ -55,23 +51,14 @@ export const AttachFileButton: React.FC = (props) => { - {props.canStageFiles ? ( - <> - Attach any file. Images and PDFs attach directly. Other files are - saved to the workspace. - - ) : ( - <> - Attach file: images, SVGs, PDFs - - )} + Attach any file. Images and PDFs attach directly. Other files are saved + to the workspace. {/* Kept outside Tooltip to avoid stray DOM children. */} = (props) => { const label = attachment.filename ?? (baseMediaType === "application/pdf" ? "PDF" : baseMediaType); const detail = - attachment.kind === "staged" + attachment.kind === "staged" || attachment.kind === "pending-file" ? `workspace file • ${formatBytes(attachment.sizeBytes)}` : null; diff --git a/src/browser/features/ChatInput/draftAttachmentsStorage.test.ts b/src/browser/features/ChatInput/draftAttachmentsStorage.test.ts index a870a217bf6..371ae2580c4 100644 --- a/src/browser/features/ChatInput/draftAttachmentsStorage.test.ts +++ b/src/browser/features/ChatInput/draftAttachmentsStorage.test.ts @@ -78,6 +78,32 @@ describe("draftAttachmentsStorage", () => { ).toEqual([]); }); + test("parsePersistedChatAttachments round-trips pending files with base64 bytes", () => { + const pendingFile = { + kind: "pending-file" as const, + id: "pending-1", + mediaType: "text/markdown", + filename: "notes.md", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }; + expect(parsePersistedChatAttachments([pendingFile])).toEqual([pendingFile]); + }); + + test("parsePersistedChatAttachments self-heals invalid pending-file records", () => { + expect( + parsePersistedChatAttachments([ + { + kind: "pending-file", + id: "pending-1", + mediaType: "text/markdown", + filename: "notes.md", + sizeBytes: 8, + }, + ]) + ).toEqual([]); + }); + test("estimatePersistedChatAttachmentsChars matches JSON length", () => { const attachments = [ { diff --git a/src/browser/features/ChatInput/draftAttachmentsStorage.ts b/src/browser/features/ChatInput/draftAttachmentsStorage.ts index 5ba6cb1e182..5dbe1d6c985 100644 --- a/src/browser/features/ChatInput/draftAttachmentsStorage.ts +++ b/src/browser/features/ChatInput/draftAttachmentsStorage.ts @@ -1,6 +1,12 @@ import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; +/** + * Attachment drafts above this JSON size are not persisted to localStorage + * (quota is typically 5-10 MB per origin); they stay memory-only. + */ +export const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -43,6 +49,27 @@ function isStagedChatAttachment(value: unknown): value is { ); } +function isPendingFileChatAttachment(value: unknown): value is { + kind: "pending-file"; + id: string; + mediaType: string; + filename: string; + sizeBytes: number; + dataBase64: string; +} { + if (!isRecord(value)) return false; + return ( + value.kind === "pending-file" && + typeof value.id === "string" && + typeof value.mediaType === "string" && + typeof value.filename === "string" && + typeof value.sizeBytes === "number" && + Number.isInteger(value.sizeBytes) && + value.sizeBytes >= 0 && + typeof value.dataBase64 === "string" + ); +} + export function parsePersistedChatAttachments(raw: unknown): ChatAttachment[] { if (!Array.isArray(raw)) { return []; @@ -73,6 +100,18 @@ export function parsePersistedChatAttachments(raw: unknown): ChatAttachment[] { continue; } + if (isPendingFileChatAttachment(item)) { + attachments.push({ + kind: "pending-file", + id: item.id, + mediaType: item.mediaType, + filename: item.filename, + sizeBytes: item.sizeBytes, + dataBase64: item.dataBase64, + }); + continue; + } + return []; } diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 5f39fa04190..89209256215 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -189,8 +189,15 @@ import { useVoiceInput } from "@/browser/hooks/useVoiceInput"; import { VoiceInputButton } from "./VoiceInputButton"; import { estimatePersistedChatAttachmentsChars, + MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS, readPersistedChatAttachments, } from "./draftAttachmentsStorage"; +import { + formatPendingFileStagingError, + getPendingFileAttachments, + replacePendingFilesWithStaged, + stagePendingFiles, +} from "./pendingFileAttachments"; import { RecordingOverlay } from "./RecordingOverlay"; import { AttachedReviewsPanel } from "./AttachedReviewsPanel"; import { @@ -239,7 +246,6 @@ function estimateBase64DataUrlBytes(dataUrl: string): number | null { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; } -const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000; // Shared so the three "blocked while editing a message" attachment guards surface identical copy // and can't drift if one is reworded. @@ -918,9 +924,18 @@ const ChatInputInner: React.FC = (props) => { variant === "creation" ? (() => { const parsedCreationCommand = parseCommand(input.trim()); - return parsedCreationCommand?.type === "goal-set" - ? parsedCreationCommand.objective - : input; + if (parsedCreationCommand?.type === "goal-set") { + return parsedCreationCommand.objective; + } + if (input.trim().length === 0 && attachments.length > 0) { + // File-only sends have no text to derive a workspace name from; use + // the attachment filenames instead. + const filenames = attachments + .map((attachment) => attachment.filename) + .filter((filename): filename is string => typeof filename === "string"); + return filenames.length > 0 ? `Attached files: ${filenames.join(", ")}` : ""; + } + return input; })() : ""; const creationState = useCreationWorkspace( @@ -2050,6 +2065,9 @@ const ChatInputInner: React.FC = (props) => { return result.data; } : undefined, + // Creation composers have no workspace yet; hold non-provider files in + // memory and stage them right after the workspace is created. + holdNonProviderFiles: variant === "creation", }).finally(() => { setProcessingAttachmentCount((count) => Math.max(0, count - 1)); }); @@ -2172,7 +2190,12 @@ const ChatInputInner: React.FC = (props) => { return true; } - if (getStagedAttachments(attachments).length > 0 && parsed.type !== "compact") { + // Pending files block every command (even /compact) because command sends + // never stage them, so they'd be silently dropped. + if ( + getPendingFileAttachments(attachments).length > 0 || + (getStagedAttachments(attachments).length > 0 && parsed.type !== "compact") + ) { setToast({ id: Date.now().toString(), type: "error", @@ -2448,8 +2471,18 @@ const ChatInputInner: React.FC = (props) => { // Route to creation handler for creation variant if (variant === "creation") { - const initialSlashCommand = parsed?.type === "goal-set" ? parsed : undefined; - if (!initialSlashCommand && parsed?.type !== "workflow-run") { + // The initial /goal path sets a goal without sending a user message, so + // attachments would be silently dropped. With attachments present, skip + // command processing and send the raw text as a normal message instead. + const goalCommandBypassedForAttachments = + parsed?.type === "goal-set" && attachments.length > 0; + const initialSlashCommand = + parsed?.type === "goal-set" && !goalCommandBypassedForAttachments ? parsed : undefined; + if ( + !initialSlashCommand && + !goalCommandBypassedForAttachments && + parsed?.type !== "workflow-run" + ) { const commandHandled = await executeParsedCommand(parsed, input); if (commandHandled) { return; @@ -2504,11 +2537,13 @@ const ChatInputInner: React.FC = (props) => { // Creation variant: simple message send + workspace creation const creationFileParts = chatAttachmentsToFileParts(attachments); + const creationPendingFiles = getPendingFileAttachments(attachments); const creationResult = await creationState.handleSend( creationMessageTextForSend, creationFileParts.length > 0 ? creationFileParts : undefined, creationOptionsOverride, - initialSlashCommand + initialSlashCommand, + creationPendingFiles.length > 0 ? creationPendingFiles : undefined ); if (creationResult.success) { @@ -2565,13 +2600,6 @@ const ChatInputInner: React.FC = (props) => { // Regular message (or / one-shot override) - send directly via API const messageTextForSend = modelOneShot?.message ?? skillInvocation?.userText ?? messageText; - const skillMuxMetadata = skillInvocation - ? buildSkillInvocationMetadata( - appendStagedAttachmentNotice(messageText, attachments), - skillInvocation.descriptor, - skillInvocation.argumentText - ) - : undefined; if (!api) { pushToast({ type: "error", message: "Not connected to server" }); @@ -2579,6 +2607,38 @@ const ChatInputInner: React.FC = (props) => { } setSendingCount((c) => c + 1); + // Stage pending files (drafts transferred from a failed creation send) so + // the attached-files notice references real workspace paths. + let sendAttachments = attachments; + const pendingFilesForSend = getPendingFileAttachments(attachments); + if (pendingFilesForSend.length > 0) { + const stagingOutcome = await stagePendingFiles(api, props.workspaceId, pendingFilesForSend); + if (stagingOutcome.staged.length > 0) { + sendAttachments = replacePendingFilesWithStaged(attachments, stagingOutcome.staged); + // Swap staged chips into the composer so a later failure or retry + // can't re-stage duplicate copies. + setAttachments(sendAttachments); + } + if (stagingOutcome.failures.length > 0) { + setToast( + createErrorToast({ + type: "unknown", + raw: formatPendingFileStagingError(stagingOutcome.failures), + }) + ); + setSendingCount((c) => c - 1); + return; + } + } + + const skillMuxMetadata = skillInvocation + ? buildSkillInvocationMetadata( + appendStagedAttachmentNotice(messageText, sendAttachments), + skillInvocation.descriptor, + skillInvocation.argumentText + ) + : undefined; + const policyModel = modelOverride ?? baseModel; // Preflight: if the message includes PDFs, ensure the selected model can accept them. @@ -2627,14 +2687,15 @@ const ChatInputInner: React.FC = (props) => { } } } - // Save current draft state for restoration on error - const preSendDraft = getDraft(); + // Save current draft state for restoration on error (with pending chips + // already swapped for their staged results). + const preSendDraft = { ...getDraft(), attachments: sendAttachments }; const preSendReviews = draftReviews; const editMessageForSend = editingMessageForUi; try { // Prepare file parts if any - const fileParts = chatAttachmentsToFileParts(attachments, { validate: true }); + const fileParts = chatAttachmentsToFileParts(sendAttachments, { validate: true }); const sendFileParts = editMessageForSend ? fileParts : fileParts.length > 0 @@ -2670,9 +2731,12 @@ const ChatInputInner: React.FC = (props) => { parsed.continueMessage || sendFileParts?.length || reviewsData?.length || - getStagedAttachments(attachments).length + getStagedAttachments(sendAttachments).length ? { - text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", attachments), + text: appendStagedAttachmentNotice( + parsed.continueMessage ?? "", + sendAttachments + ), fileParts: sendFileParts, reviews: reviewsData, } @@ -2688,7 +2752,7 @@ const ChatInputInner: React.FC = (props) => { } const userMessageText = appendStagedNoticeToUserMessage - ? appendStagedAttachmentNotice(actualMessageText, attachments) + ? appendStagedAttachmentNotice(actualMessageText, sendAttachments) : actualMessageText; const { finalText: finalMessageText, metadata: reviewMetadata } = prepareUserMessageForSend( { text: userMessageText, reviews: reviewsData }, @@ -2712,7 +2776,7 @@ const ChatInputInner: React.FC = (props) => { .trimEnd() : undefined; const oneshotRawCommand = oneshotCommandPrefix - ? appendStagedAttachmentNotice(messageText.trim(), attachments) + ? appendStagedAttachmentNotice(messageText.trim(), sendAttachments) : undefined; muxMetadata = muxMetadata ? { @@ -3365,7 +3429,6 @@ const ChatInputInner: React.FC = (props) => { { + test("stages files in order and maps results onto the pending ids", async () => { + const calls: StageAttachmentInput[] = []; + const api = apiWithStageAttachment((input) => { + calls.push(input); + return Promise.resolve({ + success: true, + data: { + filename: input.filename, + mediaType: "text/markdown", + sizeBytes: input.sizeBytes, + stagedPath: `.mux/user-attachments/uuid/${input.filename}`, + }, + }); + }); + + const outcome = await stagePendingFiles(api, "ws-1", [ + pendingFile("a", "one.md"), + pendingFile("b", "two.md"), + ]); + + expect(calls.map((call) => call.filename)).toEqual(["one.md", "two.md"]); + expect(calls.every((call) => call.workspaceId === "ws-1")).toBe(true); + expect(outcome.failures).toEqual([]); + expect(outcome.staged).toEqual([ + { + kind: "staged", + id: "a", + filename: "one.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/one.md", + }, + { + kind: "staged", + id: "b", + filename: "two.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/two.md", + }, + ]); + }); + + test("collects per-file failures without throwing and keeps successes", async () => { + const api = apiWithStageAttachment((input) => { + if (input.filename === "bad.md") { + return Promise.resolve({ success: false, error: "disk full" }); + } + if (input.filename === "boom.md") { + return Promise.reject(new Error("connection lost")); + } + return Promise.resolve({ + success: true, + data: { + filename: input.filename, + mediaType: "text/markdown", + sizeBytes: input.sizeBytes, + stagedPath: `.mux/user-attachments/uuid/${input.filename}`, + }, + }); + }); + + const good = pendingFile("a", "good.md"); + const bad = pendingFile("b", "bad.md"); + const boom = pendingFile("c", "boom.md"); + const outcome = await stagePendingFiles(api, "ws-1", [good, bad, boom]); + + expect(outcome.staged.map((attachment) => attachment.id)).toEqual(["a"]); + expect(outcome.failures).toEqual([ + { attachment: bad, error: "disk full" }, + { attachment: boom, error: "connection lost" }, + ]); + expect(formatPendingFileStagingError(outcome.failures)).toContain("bad.md: disk full"); + expect(formatPendingFileStagingError(outcome.failures)).toContain("boom.md: connection lost"); + }); +}); + +describe("replacePendingFilesWithStaged", () => { + test("swaps pending files for staged results by id, preserving order", () => { + const provider = { + kind: "provider" as const, + id: "img", + url: "data:image/png;base64,AAA", + mediaType: "image/png", + }; + const stagedResult: StagedChatAttachment = { + kind: "staged", + id: "a", + filename: "one.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/one.md", + }; + const unstaged = pendingFile("b", "two.md"); + + expect( + replacePendingFilesWithStaged( + [provider, pendingFile("a", "one.md"), unstaged], + [stagedResult] + ) + ).toEqual([provider, stagedResult, unstaged]); + }); +}); + +describe("getPendingFileAttachments", () => { + test("filters pending files out of a mixed attachment list", () => { + const pending = pendingFile("a", "one.md"); + expect( + getPendingFileAttachments([ + { kind: "provider", id: "img", url: "data:", mediaType: "image/png" }, + pending, + ]) + ).toEqual([pending]); + }); +}); diff --git a/src/browser/features/ChatInput/pendingFileAttachments.ts b/src/browser/features/ChatInput/pendingFileAttachments.ts new file mode 100644 index 00000000000..267e352ce4b --- /dev/null +++ b/src/browser/features/ChatInput/pendingFileAttachments.ts @@ -0,0 +1,85 @@ +import type { APIClient } from "@/browser/contexts/API"; +import { getErrorMessage } from "@/common/utils/errors"; + +import type { + ChatAttachment, + PendingFileChatAttachment, + StagedChatAttachment, +} from "./ChatAttachments"; + +export interface PendingFileStagingFailure { + attachment: PendingFileChatAttachment; + error: string; +} + +export interface StagePendingFilesOutcome { + staged: StagedChatAttachment[]; + failures: PendingFileStagingFailure[]; +} + +export function getPendingFileAttachments( + attachments: ChatAttachment[] +): PendingFileChatAttachment[] { + return attachments.filter((attachment) => attachment.kind === "pending-file"); +} + +/** + * Stage in-memory pending files into a workspace via the staging IPC. + * Never throws: per-file errors are collected into `failures` so callers can + * fail closed while keeping the successfully staged results. + */ +export async function stagePendingFiles( + api: { workspace: Pick }, + workspaceId: string, + pendingFiles: PendingFileChatAttachment[] +): Promise { + const staged: StagedChatAttachment[] = []; + const failures: PendingFileStagingFailure[] = []; + + for (const attachment of pendingFiles) { + try { + const result = await api.workspace.stageAttachment({ + workspaceId, + filename: attachment.filename, + mediaType: attachment.mediaType.length > 0 ? attachment.mediaType : null, + sizeBytes: attachment.sizeBytes, + dataBase64: attachment.dataBase64, + }); + if (result.success) { + // Keep the pending attachment's id so composer chips swap in place. + staged.push({ + kind: "staged", + id: attachment.id, + filename: result.data.filename, + mediaType: result.data.mediaType, + sizeBytes: result.data.sizeBytes, + stagedPath: result.data.stagedPath, + }); + } else { + failures.push({ attachment, error: result.error }); + } + } catch (error) { + failures.push({ attachment, error: getErrorMessage(error) }); + } + } + + return { staged, failures }; +} + +/** Swap pending-file attachments for their staged results by id, preserving order. */ +export function replacePendingFilesWithStaged( + attachments: ChatAttachment[], + staged: StagedChatAttachment[] +): ChatAttachment[] { + const stagedById = new Map(staged.map((attachment) => [attachment.id, attachment])); + return attachments.map((attachment) => + attachment.kind === "pending-file" ? (stagedById.get(attachment.id) ?? attachment) : attachment + ); +} + +export function formatPendingFileStagingError(failures: PendingFileStagingFailure[]): string { + const details = failures + .map((failure) => `${failure.attachment.filename}: ${failure.error}`) + .join("; "); + return `Failed to save attached file(s) into the workspace. ${details}`; +} diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index b34e62dfd59..be50e24133a 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -32,6 +32,7 @@ import type { import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; +import type { PendingFileChatAttachment } from "./ChatAttachments"; import type { WorkspaceCreatedOptions } from "./types"; import { useCreationWorkspace, type CreationSendResult } from "./useCreationWorkspace"; @@ -256,13 +257,23 @@ type WorkspaceCreateScratchResult = Awaited>; type NameGenerationArgs = Parameters[0]; type NameGenerationResult = Awaited>; +type WorkspaceStageAttachmentArgs = Parameters[0]; +type WorkspaceStageAttachmentResult = Awaited< + ReturnType +>; type MockOrpcProjectsClient = Pick< APIClient["projects"], "list" | "listBranches" | "runtimeAvailability" | "setTrust" >; type MockOrpcWorkspaceClient = Pick< APIClient["workspace"], - "sendMessage" | "create" | "createScratch" | "updateAgentAISettings" | "getGoal" | "setGoal" + | "sendMessage" + | "create" + | "createScratch" + | "updateAgentAISettings" + | "getGoal" + | "setGoal" + | "stageAttachment" >; type MockOrpcWorkflowsClient = Pick; type MockOrpcNameGenerationClient = Pick; @@ -317,6 +328,9 @@ interface SetupWindowOptions { nameGeneration?: ReturnType< typeof mock<(args: NameGenerationArgs) => Promise> >; + stageAttachment?: ReturnType< + typeof mock<(args: WorkspaceStageAttachmentArgs) => Promise> + >; } const setupWindow = ({ @@ -331,6 +345,7 @@ const setupWindow = ({ workflowStart, workflowGetRun, nameGeneration, + stageAttachment, }: SetupWindowOptions = {}) => { // Sync the useProjectContext mock with the default trusted config. // Tests that need untrusted projects override mockProjectConfigMap directly. @@ -446,6 +461,22 @@ const setupWindow = ({ } as NameGenerationResult); }); + const stageAttachmentMock = + stageAttachment ?? + mock<(args: WorkspaceStageAttachmentArgs) => Promise>( + (args) => { + return Promise.resolve({ + success: true, + data: { + filename: args.filename, + mediaType: args.mediaType ?? "application/octet-stream", + sizeBytes: args.sizeBytes, + stagedPath: `.mux/user-attachments/uuid/${args.filename}`, + }, + } as WorkspaceStageAttachmentResult); + } + ); + currentORPCClient = { projects: { list: () => listProjectsMock(), @@ -468,6 +499,7 @@ const setupWindow = ({ updateAgentAISettingsMock(input), getGoal: (input: WorkspaceGetGoalArgs) => getGoalMock(input), setGoal: (input: WorkspaceSetGoalArgs) => setGoalMock(input), + stageAttachment: (input: WorkspaceStageAttachmentArgs) => stageAttachmentMock(input), }, workflows: { start: (input: WorkflowStartArgs) => workflowStartMock(input), @@ -583,6 +615,7 @@ const setupWindow = ({ updateAgentAISettings: updateAgentAISettingsMock, getGoal: getGoalMock, setGoal: setGoalMock, + stageAttachment: stageAttachmentMock, }, workflowsApi: { start: workflowStartMock, getRun: workflowGetRunMock }, nameGenerationApi: { generate: nameGenerationMock }, @@ -880,6 +913,181 @@ describe("useCreationWorkspace", () => { expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]); }); + test("handleSend stages pending files after create and appends the attached-files notice", async () => { + const callOrder: string[] = []; + const createMock = mock((_args: WorkspaceCreateArgs): Promise => { + callOrder.push("create"); + return Promise.resolve({ success: true, metadata: TEST_METADATA } as WorkspaceCreateResult); + }); + const stageAttachmentMock = mock( + (args: WorkspaceStageAttachmentArgs): Promise => { + callOrder.push(`stage:${args.filename}`); + return Promise.resolve({ + success: true, + data: { + filename: args.filename, + mediaType: args.mediaType ?? "application/octet-stream", + sizeBytes: args.sizeBytes, + stagedPath: `.mux/user-attachments/uuid/${args.filename}`, + }, + } as WorkspaceStageAttachmentResult); + } + ); + const sendMessageMock = mock( + (_args: WorkspaceSendMessageArgs): Promise => { + callOrder.push("send"); + return Promise.resolve({ success: true as const, data: {} }); + } + ); + const { workspaceApi } = setupWindow({ + create: createMock, + sendMessage: sendMessageMock, + stageAttachment: stageAttachmentMock, + }); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + message: "check these files", + }); + + await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH])); + + const pendingFiles: PendingFileChatAttachment[] = [ + { + kind: "pending-file", + id: "p1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }, + { + kind: "pending-file", + id: "p2", + filename: "data.bin", + mediaType: "application/octet-stream", + sizeBytes: 3, + dataBase64: "Ymlu", + }, + ]; + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend( + "check these files", + undefined, + undefined, + undefined, + pendingFiles + ); + }); + + expect(result).toEqual({ success: true }); + expect(callOrder).toEqual(["create", "stage:notes.md", "stage:data.bin", "send"]); + expect(workspaceApi.stageAttachment.mock.calls[0]?.[0]?.workspaceId).toBe(TEST_WORKSPACE_ID); + + const sendRequest = workspaceApi.sendMessage.mock.calls[0]?.[0]; + expect(sendRequest?.message).toContain("check these files"); + expect(sendRequest?.message).toContain(".mux/user-attachments/uuid/notes.md"); + expect(sendRequest?.message).toContain(".mux/user-attachments/uuid/data.bin"); + }); + + test("handleSend fails closed when staging fails and transfers the draft to the workspace", async () => { + const stageAttachmentMock = mock( + (args: WorkspaceStageAttachmentArgs): Promise => { + if (args.filename === "bad.bin") { + return Promise.resolve({ + success: false, + error: "disk full", + } as WorkspaceStageAttachmentResult); + } + return Promise.resolve({ + success: true, + data: { + filename: args.filename, + mediaType: args.mediaType ?? "application/octet-stream", + sizeBytes: args.sizeBytes, + stagedPath: `.mux/user-attachments/uuid/${args.filename}`, + }, + } as WorkspaceStageAttachmentResult); + } + ); + const onWorkspaceCreated = mock( + (metadata: FrontendWorkspaceMetadata, _options?: WorkspaceCreatedOptions) => metadata + ); + const { workspaceApi } = setupWindow({ stageAttachment: stageAttachmentMock }); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated, + message: "review my files", + }); + + await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH])); + + const good: PendingFileChatAttachment = { + kind: "pending-file", + id: "p1", + filename: "good.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }; + const bad: PendingFileChatAttachment = { + kind: "pending-file", + id: "p2", + filename: "bad.bin", + mediaType: "application/octet-stream", + sizeBytes: 3, + dataBase64: "Ymlu", + }; + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend("review my files", undefined, undefined, undefined, [ + good, + bad, + ]); + }); + + expect(result).toEqual({ success: false }); + expect(workspaceApi.sendMessage.mock.calls.length).toBe(0); + + // Draft transferred to the new workspace: text, staged + still-pending files. + expect(updatePersistedStateCalls).toContainEqual([ + getInputKey(TEST_WORKSPACE_ID), + "review my files", + ]); + const attachmentsWrite = updatePersistedStateCalls.find( + ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) + ); + expect(attachmentsWrite?.[1]).toEqual([ + { + kind: "staged", + id: "p1", + filename: "good.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/good.md", + }, + bad, + ]); + const errorWrite = updatePersistedStateCalls.find( + ([key]) => key === getPendingWorkspaceSendErrorKey(TEST_WORKSPACE_ID) + ); + expect(errorWrite?.[1]).toMatchObject({ type: "unknown" }); + expect((errorWrite?.[1] as { raw: string }).raw).toContain("bad.bin: disk full"); + + // The workspace still exists and is navigated to, but no initial send is pending. + expect(onWorkspaceCreated.mock.calls.length).toBe(1); + expect(onWorkspaceCreated.mock.calls[0][1]).toMatchObject({ markPendingInitialSend: false }); + + // The creation-scope draft is cleared after the transfer. + const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH); + expect(updatePersistedStateCalls).toContainEqual([getInputKey(pendingScopeId), ""]); + }); + test("handleSend creates workspace and applies initial goal command without sending chat text", async () => { const setGoalMock = mock( (_args: WorkspaceSetGoalArgs): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index afa24bfa338..8d22d912447 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -44,6 +44,20 @@ import { ConfirmationModal } from "@/browser/components/ConfirmationModal/Confir import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import type { WorkspaceCreatedOptions } from "@/browser/features/ChatInput/types"; +import type { + ChatAttachment, + PendingFileChatAttachment, +} from "@/browser/features/ChatInput/ChatAttachments"; +import { + formatPendingFileStagingError, + replacePendingFilesWithStaged, + stagePendingFiles, +} from "@/browser/features/ChatInput/pendingFileAttachments"; +import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; +import { + estimatePersistedChatAttachmentsChars, + MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS, +} from "@/browser/features/ChatInput/draftAttachmentsStorage"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { processSlashCommand, type SlashCommandContext } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; @@ -206,7 +220,8 @@ interface UseCreationWorkspaceReturn { message: string, fileParts?: FilePart[], optionsOverride?: Partial, - initialSlashCommand?: CreationInitialSlashCommand + initialSlashCommand?: CreationInitialSlashCommand, + pendingFiles?: PendingFileChatAttachment[] ) => Promise; /** Workspace name/title generation state and actions (for CreationControls) */ nameState: WorkspaceNameState; @@ -228,6 +243,26 @@ export type RuntimeAvailabilityState = | { status: "failed" } | { status: "loaded"; data: RuntimeAvailabilityMap }; +// Move a failed creation send's draft into the new workspace's composer so the +// user can retry from there without creating a duplicate workspace. +function transferDraftToWorkspace( + workspaceId: string, + text: string, + attachments: ChatAttachment[] +): void { + updatePersistedState(getInputKey(workspaceId), text); + // Oversized pending payloads would blow the localStorage quota; drop them (they + // were memory-only in the creation composer too) and keep the rest. + const persistable = + estimatePersistedChatAttachmentsChars(attachments) > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS + ? attachments.filter((attachment) => attachment.kind !== "pending-file") + : attachments; + updatePersistedState( + getInputAttachmentsKey(workspaceId), + persistable.length > 0 ? persistable : undefined + ); +} + /** * Hook for managing workspace creation state and logic * Handles: @@ -386,9 +421,14 @@ export function useCreationWorkspace({ messageText: string, fileParts?: FilePart[], optionsOverride?: Partial, - initialSlashCommand?: CreationInitialSlashCommand + initialSlashCommand?: CreationInitialSlashCommand, + pendingFiles?: PendingFileChatAttachment[] ): Promise => { - if (!messageText.trim() || isSending || !api) { + const pendingFilesToStage = pendingFiles ?? []; + // File-only sends are valid: the attached-files notice (or provider file + // parts) becomes the message content, matching workspace composer behavior. + const hasSendableAttachments = pendingFilesToStage.length > 0 || (fileParts?.length ?? 0) > 0; + if ((!messageText.trim() && !hasSendableAttachments) || isSending || !api) { return { success: false }; } @@ -597,6 +637,40 @@ export function useCreationWorkspace({ updatePersistedState(getInputAttachmentsKey(pendingScopeId), undefined); }; + // Stage pending files now that the worktree exists on disk. This must + // finish before the first send so the attached-files notice can + // reference real staged paths. + const stagingOutcome = + pendingFilesToStage.length > 0 + ? await stagePendingFiles(api, metadata.id, pendingFilesToStage) + : { staged: [], failures: [] }; + const stagingFailed = stagingOutcome.failures.length > 0; + + if (stagingFailed) { + // Fail closed: a partial notice would misrepresent the workspace + // contents. Transfer the draft (staged results kept, failed files + // still pending) into the new workspace's composer before navigation + // so the user can retry from there. + const providerDraftAttachments: ChatAttachment[] = (fileParts ?? []).map( + (part, index) => ({ + kind: "provider", + id: `${Date.now()}-transferred-${index}`, + url: part.url, + mediaType: part.mediaType, + filename: part.filename, + }) + ); + transferDraftToWorkspace(metadata.id, messageText, [ + ...providerDraftAttachments, + ...replacePendingFilesWithStaged(pendingFilesToStage, stagingOutcome.staged), + ]); + // Surface the failure as a toast after navigation, like initial-send errors. + updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), { + type: "unknown", + raw: formatPendingFileStagingError(stagingOutcome.failures), + } satisfies SendMessageError); + } + // Sync preferences before switching (keeps workspace settings consistent). syncCreationPreferences(projectPath, metadata.id); @@ -613,8 +687,8 @@ export function useCreationWorkspace({ onWorkspaceCreated(metadata, { autoNavigate: shouldAutoNavigate, - pendingStreamModel: shouldAutoNavigate ? baseModel : null, - markPendingInitialSend: initialSlashCommand == null, + pendingStreamModel: shouldAutoNavigate && !stagingFailed ? baseModel : null, + markPendingInitialSend: initialSlashCommand == null && !stagingFailed, }); if (typeof draftId === "string" && draftId.trim().length > 0 && promoteWorkspaceDraft) { @@ -626,6 +700,11 @@ export function useCreationWorkspace({ // during the initial send can't resurrect the draft entry in the sidebar. clearPendingDraft(); + if (stagingFailed) { + setIsSending(false); + return { success: false }; + } + if (initialSlashCommand) { await initialAiSettingsPersisted; const commandContext: SlashCommandContext = { @@ -679,7 +758,7 @@ export function useCreationWorkspace({ const sendResult = await api.workspace.sendMessage({ workspaceId: metadata.id, - message: messageText, + message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), options: { ...sendMessageOptions, ...optionsOverride, diff --git a/src/browser/utils/attachmentsHandling.test.ts b/src/browser/utils/attachmentsHandling.test.ts index d9271d9d717..fef2d442f15 100644 --- a/src/browser/utils/attachmentsHandling.test.ts +++ b/src/browser/utils/attachmentsHandling.test.ts @@ -207,6 +207,96 @@ describe("attachmentsHandling", () => { ]) ).toEqual([]); }); + + test("does not convert pending files to provider file parts", () => { + expect( + chatAttachmentsToFileParts([ + { + kind: "pending-file", + id: "file-1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 1, + dataBase64: "bQ==", + }, + ]) + ).toEqual([]); + }); + }); + + describe("pending files", () => { + test("holds non-provider files in memory with base64 bytes and resolved media type", async () => { + const markdown = new File(["markdown"], "notes.md", { type: "text/markdown" }); + const binary = new File(["bin"], "data.bin", { type: "" }); + + const attachments = await processAttachmentFiles([markdown, binary], { + holdNonProviderFiles: true, + }); + + expect(attachments).toEqual([ + { + kind: "pending-file", + id: expect.stringMatching(/^\d+-[a-z0-9]+$/), + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }, + { + kind: "pending-file", + id: expect.stringMatching(/^\d+-[a-z0-9]+$/), + filename: "data.bin", + mediaType: "application/octet-stream", + sizeBytes: 3, + dataBase64: "Ymlu", + }, + ]); + }); + + test("routes provider-native files before the pending-file hold", async () => { + const image = new File(["image"], "photo.png", { type: "image/png" }); + + const attachments = await processAttachmentFiles([image], { + holdNonProviderFiles: true, + }); + + expect(attachments.map((attachment) => attachment.kind)).toEqual(["provider"]); + }); + + test("prefers staging over the pending-file hold when both are configured", async () => { + const markdown = new File(["markdown"], "notes.md", { type: "text/markdown" }); + + const attachments = await processAttachmentFiles([markdown], { + stageAttachment: (file) => + Promise.resolve({ + filename: file.name, + mediaType: file.type, + sizeBytes: file.size, + stagedPath: `.mux/user-attachments/id/${file.name}`, + }), + holdNonProviderFiles: true, + }); + + expect(attachments.map((attachment) => attachment.kind)).toEqual(["staged"]); + }); + + test("rejects oversized files before holding them", async () => { + let read = false; + const file = { + name: "large.bin", + type: "", + size: MAX_STAGED_ATTACHMENT_SIZE_BYTES + 1, + arrayBuffer: () => { + read = true; + return Promise.reject(new Error("should not read")); + }, + } as unknown as File; + + await expect(processAttachmentFiles([file], { holdNonProviderFiles: true })).rejects.toThrow( + "cannot be staged" + ); + expect(read).toBe(false); + }); }); describe("processAttachmentFiles", () => { diff --git a/src/browser/utils/attachmentsHandling.ts b/src/browser/utils/attachmentsHandling.ts index 68acac58485..1a04a563d6c 100644 --- a/src/browser/utils/attachmentsHandling.ts +++ b/src/browser/utils/attachmentsHandling.ts @@ -1,8 +1,14 @@ import type { FilePart } from "@/common/orpc/types"; import { MAX_SVG_TEXT_CHARS, SVG_MEDIA_TYPE } from "@/common/constants/imageAttachments"; import { MAX_STAGED_ATTACHMENT_SIZE_BYTES } from "@/common/constants/stagedAttachments"; -import { getSupportedAttachmentMediaType } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; -import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; +import { + getSupportedAttachmentMediaType, + getSupportedStagedAttachmentMediaType, +} from "@/common/utils/attachments/supportedAttachmentMediaTypes"; +import type { + ChatAttachment, + PendingFileChatAttachment, +} from "@/browser/features/ChatInput/ChatAttachments"; import { resizeImageIfNeeded } from "@/browser/utils/imageResize"; /** @@ -21,6 +27,11 @@ export interface StageAttachmentResult { export interface ProcessAttachmentOptions { stageAttachment?: (file: File, dataBase64: string) => Promise; + /** + * Hold non-provider files in memory as pending-file attachments. Used by creation + * composers, which have no workspace to stage into until the first send. + */ + holdNonProviderFiles?: boolean; } function getSupportedMediaType(file: File): string | null { @@ -40,8 +51,9 @@ export function chatAttachmentsToFileParts( const validate = options?.validate ?? false; return attachments.flatMap((attachment, index) => { - if (attachment.kind === "staged") { - // Staged files live in the workspace filesystem and must never be sent as provider file parts. + if (attachment.kind === "staged" || attachment.kind === "pending-file") { + // Staged and pending files belong in the workspace filesystem and must never + // be sent as provider file parts. return []; } @@ -84,17 +96,21 @@ function fileBytesToBase64(bytes: Uint8Array): string { return btoa(binary); } -async function fileToStagedChatAttachment( - file: File, - stageAttachment: (file: File, dataBase64: string) => Promise -): Promise { +async function readFileBase64ForStaging(file: File): Promise { if (file.size > MAX_STAGED_ATTACHMENT_SIZE_BYTES) { throw new Error( `Attachments larger than ${MAX_STAGED_ATTACHMENT_SIZE_BYTES.toLocaleString()} bytes cannot be staged.` ); } - const dataBase64 = fileBytesToBase64(new Uint8Array(await file.arrayBuffer())); + return fileBytesToBase64(new Uint8Array(await file.arrayBuffer())); +} + +async function fileToStagedChatAttachment( + file: File, + stageAttachment: (file: File, dataBase64: string) => Promise +): Promise { + const dataBase64 = await readFileBase64ForStaging(file); const staged = await stageAttachment(file, dataBase64); return { kind: "staged", @@ -198,20 +214,40 @@ export function extractAttachmentsFromDrop(dataTransfer: DataTransfer): File[] { return Array.from(dataTransfer.files); } +async function fileToPendingFileChatAttachment(file: File): Promise { + const dataBase64 = await readFileBase64ForStaging(file); + return { + kind: "pending-file", + id: generateAttachmentId(), + filename: file.name, + // Resolve the media type the same way the backend does at staging time, so the + // chip shows what the staged file will report. + mediaType: getSupportedStagedAttachmentMediaType({ + mediaType: file.type !== "" ? file.type : null, + filename: file.name, + }), + sizeBytes: file.size, + dataBase64, + }; +} + export async function processAttachmentFiles( files: File[], options: ProcessAttachmentOptions = {} ): Promise { return await Promise.all( files.map((file) => { - // Prefer provider-native formats before the optional workspace staging fallback. + // Prefer provider-native formats before the workspace staging fallbacks. if (getSupportedMediaType(file) != null) { return fileToChatAttachment(file); } - if (!options.stageAttachment) { - throw new Error("Files can be staged after opening a workspace."); + if (options.stageAttachment) { + return fileToStagedChatAttachment(file, options.stageAttachment); + } + if (options.holdNonProviderFiles) { + return fileToPendingFileChatAttachment(file); } - return fileToStagedChatAttachment(file, options.stageAttachment); + throw new Error("Files can be staged after opening a workspace."); }) ); } From 6291eb71e08e4e1e9709afae734197fd76453742 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:18:44 +0000 Subject: [PATCH 02/14] refactor(chat): apply simplify/deslop review to pending-file staging Reuse filePartsToChatAttachments for the failed-transfer draft, derive the staging mock types from stagePendingFiles instead of duplicating the IPC shapes, and trim comments to the non-obvious invariants. --- .../features/ChatInput/AttachFileButton.tsx | 6 ---- .../features/ChatInput/ChatAttachments.tsx | 5 ++- .../ChatInput/draftAttachmentsStorage.ts | 5 +-- src/browser/features/ChatInput/index.tsx | 11 ++----- .../ChatInput/pendingFileAttachments.test.ts | 24 +++----------- .../ChatInput/pendingFileAttachments.ts | 6 ++-- .../ChatInput/useCreationWorkspace.test.tsx | 3 -- .../ChatInput/useCreationWorkspace.ts | 33 +++++++------------ src/browser/utils/attachmentsHandling.ts | 4 +-- 9 files changed, 26 insertions(+), 71 deletions(-) diff --git a/src/browser/features/ChatInput/AttachFileButton.tsx b/src/browser/features/ChatInput/AttachFileButton.tsx index cebe9ccf427..9f3f8fb3513 100644 --- a/src/browser/features/ChatInput/AttachFileButton.tsx +++ b/src/browser/features/ChatInput/AttachFileButton.tsx @@ -1,9 +1,3 @@ -/** - * Attach file button that opens a native picker. - * Images and PDFs attach natively; any other file type is saved into the - * workspace (immediately, or on first send for creation composers). - */ - import React, { useRef } from "react"; import { Paperclip } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; diff --git a/src/browser/features/ChatInput/ChatAttachments.tsx b/src/browser/features/ChatInput/ChatAttachments.tsx index bfbd3000a12..8cb20635c43 100644 --- a/src/browser/features/ChatInput/ChatAttachments.tsx +++ b/src/browser/features/ChatInput/ChatAttachments.tsx @@ -28,9 +28,8 @@ export interface StagedChatAttachment { } /** - * A non-provider file held in memory by a creation composer. No workspace exists - * yet, so the bytes stay in the attachment until the workspace is created and the - * file can be staged into its filesystem. + * Non-provider file held in memory (bytes included) by a creation composer + * until a workspace exists to stage it into. */ export interface PendingFileChatAttachment { kind: "pending-file"; diff --git a/src/browser/features/ChatInput/draftAttachmentsStorage.ts b/src/browser/features/ChatInput/draftAttachmentsStorage.ts index 5dbe1d6c985..4a137d4a5ed 100644 --- a/src/browser/features/ChatInput/draftAttachmentsStorage.ts +++ b/src/browser/features/ChatInput/draftAttachmentsStorage.ts @@ -1,10 +1,7 @@ import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; -/** - * Attachment drafts above this JSON size are not persisted to localStorage - * (quota is typically 5-10 MB per origin); they stay memory-only. - */ +/** Attachment drafts above this JSON size stay memory-only (localStorage quota). */ export const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000; function isRecord(value: unknown): value is Record { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 89209256215..7545000a9ad 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -928,8 +928,6 @@ const ChatInputInner: React.FC = (props) => { return parsedCreationCommand.objective; } if (input.trim().length === 0 && attachments.length > 0) { - // File-only sends have no text to derive a workspace name from; use - // the attachment filenames instead. const filenames = attachments .map((attachment) => attachment.filename) .filter((filename): filename is string => typeof filename === "string"); @@ -2065,8 +2063,6 @@ const ChatInputInner: React.FC = (props) => { return result.data; } : undefined, - // Creation composers have no workspace yet; hold non-provider files in - // memory and stage them right after the workspace is created. holdNonProviderFiles: variant === "creation", }).finally(() => { setProcessingAttachmentCount((count) => Math.max(0, count - 1)); @@ -2607,8 +2603,8 @@ const ChatInputInner: React.FC = (props) => { } setSendingCount((c) => c + 1); - // Stage pending files (drafts transferred from a failed creation send) so - // the attached-files notice references real workspace paths. + // Pending files only reach workspace composers via transferred creation + // drafts; stage them before the notice is built. let sendAttachments = attachments; const pendingFilesForSend = getPendingFileAttachments(attachments); if (pendingFilesForSend.length > 0) { @@ -2687,8 +2683,7 @@ const ChatInputInner: React.FC = (props) => { } } } - // Save current draft state for restoration on error (with pending chips - // already swapped for their staged results). + // Save current draft state for restoration on error const preSendDraft = { ...getDraft(), attachments: sendAttachments }; const preSendReviews = draftReviews; const editMessageForSend = editingMessageForUi; diff --git a/src/browser/features/ChatInput/pendingFileAttachments.test.ts b/src/browser/features/ChatInput/pendingFileAttachments.test.ts index a072230c6e8..557cf5ee21e 100644 --- a/src/browser/features/ChatInput/pendingFileAttachments.test.ts +++ b/src/browser/features/ChatInput/pendingFileAttachments.test.ts @@ -19,26 +19,12 @@ function pendingFile(id: string, filename: string): PendingFileChatAttachment { }; } -interface StageAttachmentInput { - workspaceId: string; - filename: string; - mediaType?: string | null; - sizeBytes: number; - dataBase64: string; -} +type StagePendingFilesApi = Parameters[0]; +type StageAttachmentFn = StagePendingFilesApi["workspace"]["stageAttachment"]; +type StageAttachmentInput = Parameters[0]; -function apiWithStageAttachment( - impl: (input: StageAttachmentInput) => Promise< - | { - success: true; - data: { filename: string; mediaType: string; sizeBytes: number; stagedPath: string }; - } - | { success: false; error: string } - > -) { - return { workspace: { stageAttachment: impl } } as unknown as Parameters< - typeof stagePendingFiles - >[0]; +function apiWithStageAttachment(impl: StageAttachmentFn): StagePendingFilesApi { + return { workspace: { stageAttachment: impl } }; } describe("stagePendingFiles", () => { diff --git a/src/browser/features/ChatInput/pendingFileAttachments.ts b/src/browser/features/ChatInput/pendingFileAttachments.ts index 267e352ce4b..9a8839d8cc1 100644 --- a/src/browser/features/ChatInput/pendingFileAttachments.ts +++ b/src/browser/features/ChatInput/pendingFileAttachments.ts @@ -24,9 +24,8 @@ export function getPendingFileAttachments( } /** - * Stage in-memory pending files into a workspace via the staging IPC. - * Never throws: per-file errors are collected into `failures` so callers can - * fail closed while keeping the successfully staged results. + * Per-file errors are collected into `failures` so callers can fail closed + * while keeping the successfully staged results. */ export async function stagePendingFiles( api: { workspace: Pick }, @@ -66,7 +65,6 @@ export async function stagePendingFiles( return { staged, failures }; } -/** Swap pending-file attachments for their staged results by id, preserving order. */ export function replacePendingFilesWithStaged( attachments: ChatAttachment[], staged: StagedChatAttachment[] diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index be50e24133a..8c094284957 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1054,7 +1054,6 @@ describe("useCreationWorkspace", () => { expect(result).toEqual({ success: false }); expect(workspaceApi.sendMessage.mock.calls.length).toBe(0); - // Draft transferred to the new workspace: text, staged + still-pending files. expect(updatePersistedStateCalls).toContainEqual([ getInputKey(TEST_WORKSPACE_ID), "review my files", @@ -1079,11 +1078,9 @@ describe("useCreationWorkspace", () => { expect(errorWrite?.[1]).toMatchObject({ type: "unknown" }); expect((errorWrite?.[1] as { raw: string }).raw).toContain("bad.bin: disk full"); - // The workspace still exists and is navigated to, but no initial send is pending. expect(onWorkspaceCreated.mock.calls.length).toBe(1); expect(onWorkspaceCreated.mock.calls[0][1]).toMatchObject({ markPendingInitialSend: false }); - // The creation-scope draft is cleared after the transfer. const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH); expect(updatePersistedStateCalls).toContainEqual([getInputKey(pendingScopeId), ""]); }); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 8d22d912447..4ef005ff675 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -53,6 +53,7 @@ import { replacePendingFilesWithStaged, stagePendingFiles, } from "@/browser/features/ChatInput/pendingFileAttachments"; +import { filePartsToChatAttachments } from "@/browser/features/ChatInput/utils"; import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { estimatePersistedChatAttachmentsChars, @@ -243,16 +244,16 @@ export type RuntimeAvailabilityState = | { status: "failed" } | { status: "loaded"; data: RuntimeAvailabilityMap }; -// Move a failed creation send's draft into the new workspace's composer so the -// user can retry from there without creating a duplicate workspace. +// Persist a failed creation send's draft under the new workspace's keys so the +// retry happens there instead of creating a duplicate workspace. function transferDraftToWorkspace( workspaceId: string, text: string, attachments: ChatAttachment[] ): void { updatePersistedState(getInputKey(workspaceId), text); - // Oversized pending payloads would blow the localStorage quota; drop them (they - // were memory-only in the creation composer too) and keep the rest. + // Pending files carry base64 bytes; drop them when the draft exceeds the + // persistence cap (they were memory-only before the transfer too). const persistable = estimatePersistedChatAttachmentsChars(attachments) > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS ? attachments.filter((attachment) => attachment.kind !== "pending-file") @@ -425,8 +426,8 @@ export function useCreationWorkspace({ pendingFiles?: PendingFileChatAttachment[] ): Promise => { const pendingFilesToStage = pendingFiles ?? []; - // File-only sends are valid: the attached-files notice (or provider file - // parts) becomes the message content, matching workspace composer behavior. + // File-only sends are valid; the attached-files notice or provider file + // parts carry the content. const hasSendableAttachments = pendingFilesToStage.length > 0 || (fileParts?.length ?? 0) > 0; if ((!messageText.trim() && !hasSendableAttachments) || isSending || !api) { return { success: false }; @@ -637,9 +638,8 @@ export function useCreationWorkspace({ updatePersistedState(getInputAttachmentsKey(pendingScopeId), undefined); }; - // Stage pending files now that the worktree exists on disk. This must - // finish before the first send so the attached-files notice can - // reference real staged paths. + // Stage pending files now that the worktree exists on disk, before the + // first send so the attached-files notice can reference real staged paths. const stagingOutcome = pendingFilesToStage.length > 0 ? await stagePendingFiles(api, metadata.id, pendingFilesToStage) @@ -649,22 +649,11 @@ export function useCreationWorkspace({ if (stagingFailed) { // Fail closed: a partial notice would misrepresent the workspace // contents. Transfer the draft (staged results kept, failed files - // still pending) into the new workspace's composer before navigation - // so the user can retry from there. - const providerDraftAttachments: ChatAttachment[] = (fileParts ?? []).map( - (part, index) => ({ - kind: "provider", - id: `${Date.now()}-transferred-${index}`, - url: part.url, - mediaType: part.mediaType, - filename: part.filename, - }) - ); + // still pending) so the user can retry from the workspace composer. transferDraftToWorkspace(metadata.id, messageText, [ - ...providerDraftAttachments, + ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), ...replacePendingFilesWithStaged(pendingFilesToStage, stagingOutcome.staged), ]); - // Surface the failure as a toast after navigation, like initial-send errors. updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), { type: "unknown", raw: formatPendingFileStagingError(stagingOutcome.failures), diff --git a/src/browser/utils/attachmentsHandling.ts b/src/browser/utils/attachmentsHandling.ts index 1a04a563d6c..2b35cfe5f64 100644 --- a/src/browser/utils/attachmentsHandling.ts +++ b/src/browser/utils/attachmentsHandling.ts @@ -28,8 +28,8 @@ export interface StageAttachmentResult { export interface ProcessAttachmentOptions { stageAttachment?: (file: File, dataBase64: string) => Promise; /** - * Hold non-provider files in memory as pending-file attachments. Used by creation - * composers, which have no workspace to stage into until the first send. + * Creation composers have no workspace to stage into until the first send; + * hold non-provider files in memory as pending-file attachments instead. */ holdNonProviderFiles?: boolean; } From b10a50c7e289f0b06e8475596372fefb775392e5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:51:27 +0000 Subject: [PATCH 03/14] fix(chat): wait for workspace init before staging attachments Deferred runtimes (Coder/SSH/devcontainer) return from create before provisioning finishes, so staging right after creation could write into a not-yet-ready workspace. Await the same InitStateManager barrier executeBash uses. --- src/node/services/workspaceService.test.ts | 57 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 5 ++ 2 files changed, 62 insertions(+) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e44e9cd5b08..55d2772984c 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -220,6 +220,63 @@ function createFrontendWorkspaceMetadata( }; } +describe("WorkspaceService.stageAttachment", () => { + test("waits for workspace init before writing into the workspace", async () => { + const { config, historyService, cleanup } = await createTestHistoryService(); + const workspaceId = "stage-attachment-init"; + // Local runtime resolves the execution path to the project dir itself. + const projectPath = path.join(config.rootDir, "project"); + const workspacePath = projectPath; + try { + await fsPromises.mkdir(workspacePath, { recursive: true }); + await config.addWorkspace(projectPath, { + id: workspaceId, + name: "stage-attachment-init", + projectName: "project", + projectPath, + runtimeConfig: { type: "local" }, + namedWorkspacePath: workspacePath, + }); + + let releaseInit: () => void = () => undefined; + const initGate = new Promise((resolve) => { + releaseInit = resolve; + }); + const waitForInit = mock(() => initGate); + const workspaceService = createWorkspaceServiceForTest({ + config, + historyService, + initStateManager: { + ...mockInitStateManager, + waitForInit, + } as unknown as InitStateManager, + }); + + const stagePromise = workspaceService.stageAttachment({ + workspaceId, + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: Buffer.from("markdown").toString("base64"), + }); + + // Staging must block on the init barrier before any workspace write. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(waitForInit).toHaveBeenCalledWith(workspaceId); + const entriesBeforeInit = await fsPromises.readdir(workspacePath); + expect(entriesBeforeInit).toEqual([]); + + releaseInit(); + const result = await stagePromise; + expect(result.success).toBe(true); + if (!result.success) throw new Error(result.error); + await fsPromises.access(path.join(workspacePath, result.data.stagedPath)); + } finally { + await cleanup(); + } + }); +}); + describe("WorkspaceService.setActiveTurnThinkingLevel", () => { test("returns accepted:false when the workspace has no session", () => { const workspaceService = createWorkspaceServiceForTest({ config: {} }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index e8a1e352489..93e78c9f550 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8090,6 +8090,11 @@ export class WorkspaceService extends EventEmitter { return Err("Workspace not found"); } + // Deferred runtimes (Coder/SSH/devcontainer) return from create before + // provisioning finishes; wait like executeBash so staging right after + // creation does not write into a not-yet-ready workspace. + await this.initStateManager.waitForInit(input.workspaceId); + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); return stageWorkspaceAttachment({ runtime, From fbcb26215d5164fca3793f67d5bfd830a79a316d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:02:15 +0000 Subject: [PATCH 04/14] fix(chat): keep staged-file notice in skill rawCommand; deterministic init test Creation skill sends built muxMetadata.rawCommand before staging, so the transcript (which prefers rawCommand for display) lost the attachment chips; patch rawCommand with the notice after staging. Replace the fixed test sleep with an explicit barrier-reached gate. --- .../ChatInput/useCreationWorkspace.ts | 23 +++++++++++++++++++ src/node/services/workspaceService.test.ts | 11 +++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 4ef005ff675..0c18921be04 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -59,6 +59,7 @@ import { estimatePersistedChatAttachmentsChars, MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS, } from "@/browser/features/ChatInput/draftAttachmentsStorage"; +import type { MuxMessageMetadata } from "@/common/types/message"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { processSlashCommand, type SlashCommandContext } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; @@ -745,12 +746,34 @@ export function useCreationWorkspace({ .filter((part) => typeof part === "string" && part.trim().length > 0) .join("\n\n"); + // Skill metadata was built before staging, so its rawCommand (preferred + // over message text for transcript display) lacks the notice; patch it + // so the displayed message keeps the staged attachment chips. + // SendMessageOptions.muxMetadata is a black box (z.any); the creation + // caller only ever passes MuxMessageMetadata built in ChatInput. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const overrideMuxMetadata: MuxMessageMetadata | undefined = optionsOverride?.muxMetadata; + const muxMetadataWithNotice = + stagingOutcome.staged.length > 0 && + overrideMuxMetadata && + "rawCommand" in overrideMuxMetadata && + typeof overrideMuxMetadata.rawCommand === "string" + ? { + ...overrideMuxMetadata, + rawCommand: appendStagedAttachmentNotice( + overrideMuxMetadata.rawCommand, + stagingOutcome.staged + ), + } + : overrideMuxMetadata; + const sendResult = await api.workspace.sendMessage({ workspaceId: metadata.id, message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), options: { ...sendMessageOptions, ...optionsOverride, + ...(muxMetadataWithNotice ? { muxMetadata: muxMetadataWithNotice } : {}), additionalSystemInstructions: additionalSystemInstructions.length ? additionalSystemInstructions : undefined, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 55d2772984c..fd24f482f17 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -242,7 +242,14 @@ describe("WorkspaceService.stageAttachment", () => { const initGate = new Promise((resolve) => { releaseInit = resolve; }); - const waitForInit = mock(() => initGate); + let barrierReached: () => void = () => undefined; + const barrierReachedGate = new Promise((resolve) => { + barrierReached = resolve; + }); + const waitForInit = mock(() => { + barrierReached(); + return initGate; + }); const workspaceService = createWorkspaceServiceForTest({ config, historyService, @@ -261,7 +268,7 @@ describe("WorkspaceService.stageAttachment", () => { }); // Staging must block on the init barrier before any workspace write. - await new Promise((resolve) => setTimeout(resolve, 20)); + await barrierReachedGate; expect(waitForInit).toHaveBeenCalledWith(workspaceId); const entriesBeforeInit = await fsPromises.readdir(workspacePath); expect(entriesBeforeInit).toEqual([]); From ff55a31f5acd2f3d0c3ecb4b92a3cecc267bdf9c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:14:20 +0000 Subject: [PATCH 05/14] fix(chat): restore original slash command in failed-staging draft transfer For slash-skill creation sends, the transferred draft contained the rewritten skill text, so a retry lost the skill invocation. Prefer muxMetadata.rawCommand (the original typed command) for the draft text. --- .../ChatInput/useCreationWorkspace.test.tsx | 25 ++++++++++++---- .../ChatInput/useCreationWorkspace.ts | 30 +++++++++++-------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 8c094284957..5de9013bb43 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1045,18 +1045,33 @@ describe("useCreationWorkspace", () => { let result: CreationSendResult | undefined; await act(async () => { - result = await getHook().handleSend("review my files", undefined, undefined, undefined, [ - good, - bad, - ]); + // Simulates a slash-skill send: messageText is the rewritten skill text + // while muxMetadata.rawCommand preserves the original typed command. + result = await getHook().handleSend( + "review my files", + undefined, + { + muxMetadata: { + type: "agent-skill", + rawCommand: "/review my files", + commandPrefix: "/review", + skillName: "review", + scope: "project", + }, + }, + undefined, + [good, bad] + ); }); expect(result).toEqual({ success: false }); expect(workspaceApi.sendMessage.mock.calls.length).toBe(0); + // The transferred draft restores the original slash command so a retry + // re-invokes the skill. expect(updatePersistedStateCalls).toContainEqual([ getInputKey(TEST_WORKSPACE_ID), - "review my files", + "/review my files", ]); const attachmentsWrite = updatePersistedStateCalls.find( ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 0c18921be04..7b9e24a35e8 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -647,11 +647,25 @@ export function useCreationWorkspace({ : { staged: [], failures: [] }; const stagingFailed = stagingOutcome.failures.length > 0; + // SendMessageOptions.muxMetadata is a black box (z.any); the creation + // caller only ever passes MuxMessageMetadata built in ChatInput. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const overrideMuxMetadata: MuxMessageMetadata | undefined = optionsOverride?.muxMetadata; + const overrideRawCommand = + overrideMuxMetadata && + "rawCommand" in overrideMuxMetadata && + typeof overrideMuxMetadata.rawCommand === "string" + ? overrideMuxMetadata.rawCommand + : null; + if (stagingFailed) { // Fail closed: a partial notice would misrepresent the workspace // contents. Transfer the draft (staged results kept, failed files // still pending) so the user can retry from the workspace composer. - transferDraftToWorkspace(metadata.id, messageText, [ + // For slash-skill sends messageText is the rewritten skill text; + // restore the original typed command from rawCommand so the retry + // re-invokes the skill. + transferDraftToWorkspace(metadata.id, overrideRawCommand ?? messageText, [ ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), ...replacePendingFilesWithStaged(pendingFilesToStage, stagingOutcome.staged), ]); @@ -749,21 +763,11 @@ export function useCreationWorkspace({ // Skill metadata was built before staging, so its rawCommand (preferred // over message text for transcript display) lacks the notice; patch it // so the displayed message keeps the staged attachment chips. - // SendMessageOptions.muxMetadata is a black box (z.any); the creation - // caller only ever passes MuxMessageMetadata built in ChatInput. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const overrideMuxMetadata: MuxMessageMetadata | undefined = optionsOverride?.muxMetadata; const muxMetadataWithNotice = - stagingOutcome.staged.length > 0 && - overrideMuxMetadata && - "rawCommand" in overrideMuxMetadata && - typeof overrideMuxMetadata.rawCommand === "string" + stagingOutcome.staged.length > 0 && overrideMuxMetadata && overrideRawCommand !== null ? { ...overrideMuxMetadata, - rawCommand: appendStagedAttachmentNotice( - overrideMuxMetadata.rawCommand, - stagingOutcome.staged - ), + rawCommand: appendStagedAttachmentNotice(overrideRawCommand, stagingOutcome.staged), } : overrideMuxMetadata; From f8d7782c16c1c04a565e407dfa24194067edc82e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:23:45 +0000 Subject: [PATCH 06/14] fix(chat): transfer staged draft when the first send fails after staging Staging can succeed and the initial sendMessage still fail; the creation draft was already cleared, so the workspace opened without the text or staged chips needed to retry. Reuse the staging-failure draft transfer. --- .../ChatInput/useCreationWorkspace.test.tsx | 62 +++++++++++++++++++ .../ChatInput/useCreationWorkspace.ts | 9 +++ 2 files changed, 71 insertions(+) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 5de9013bb43..1e04350597f 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1100,6 +1100,68 @@ describe("useCreationWorkspace", () => { expect(updatePersistedStateCalls).toContainEqual([getInputKey(pendingScopeId), ""]); }); + test("handleSend transfers the staged draft when the first send fails after staging", async () => { + const sendMessageMock = mock( + (_args: WorkspaceSendMessageArgs): Promise => + Promise.resolve({ + success: false as const, + error: { type: "unknown", raw: "provider exploded" }, + }) + ); + const { workspaceApi } = setupWindow({ sendMessage: sendMessageMock }); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + message: "send my files", + }); + + await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH])); + + const pendingFile: PendingFileChatAttachment = { + kind: "pending-file", + id: "p1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }; + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend("send my files", undefined, undefined, undefined, [ + pendingFile, + ]); + }); + + expect(result).toMatchObject({ success: false }); + expect(workspaceApi.sendMessage.mock.calls.length).toBe(1); + + // Staged files live in the new workspace; the draft must be transferred so + // the user can retry the send with the chips/notice intact. + expect(updatePersistedStateCalls).toContainEqual([ + getInputKey(TEST_WORKSPACE_ID), + "send my files", + ]); + const attachmentsWrite = updatePersistedStateCalls.find( + ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) + ); + expect(attachmentsWrite?.[1]).toEqual([ + { + kind: "staged", + id: "p1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/notes.md", + }, + ]); + const errorWrite = updatePersistedStateCalls.find( + ([key]) => key === getPendingWorkspaceSendErrorKey(TEST_WORKSPACE_ID) + ); + expect(errorWrite?.[1]).toMatchObject({ type: "unknown", raw: "provider exploded" }); + }); + test("handleSend creates workspace and applies initial goal command without sending chat text", async () => { const setGoalMock = mock( (_args: WorkspaceSetGoalArgs): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 7b9e24a35e8..aafc276d8c5 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -789,6 +789,15 @@ export function useCreationWorkspace({ if (createdWorkspaceId) { workspaceStore.clearPendingInitialSendState(createdWorkspaceId); } + if (stagingOutcome.staged.length > 0) { + // The creation draft was already cleared; without a transferred + // draft the staged files would sit in the workspace with no + // chips/notice to retry the send with. + transferDraftToWorkspace(metadata.id, overrideRawCommand ?? messageText, [ + ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), + ...stagingOutcome.staged, + ]); + } if (sendResult.error) { // Persist the failure so the workspace view can surface a toast after navigation. updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), sendResult.error); From 0673fa850a30ca2a442814a55e9600a24dd07358 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:38:29 +0000 Subject: [PATCH 07/14] fix(chat): carry forced project skill discovery to transferred draft retries Creation sends with project-scoped skills force project-path discovery; a transferred-draft retry resolved the slash command against the new worktree instead, so it could miss or run a different skill. Persist an ephemeral per-workspace flag with the transfer, honor it in the workspace composer's skill resolution and send options, and clear it after the next successful send. --- src/browser/features/ChatInput/index.tsx | 22 +++++++++++- .../ChatInput/useCreationWorkspace.test.tsx | 8 ++++- .../ChatInput/useCreationWorkspace.ts | 36 ++++++++++++++----- src/common/constants/storage.ts | 12 +++++++ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 7545000a9ad..d8f544451cc 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -63,6 +63,7 @@ import { getProjectScopeId, getPendingScopeId, getDraftScopeId, + getPendingDraftSkillDiscoveryKey, getPendingWorkspaceSendErrorKey, getWorkspaceLastReadKey, } from "@/common/constants/storage"; @@ -2439,6 +2440,12 @@ const ChatInputInner: React.FC = (props) => { closeSendModeMenu(); const messageText = input.trim(); + // A draft transferred from a failed creation send may have resolved its + // slash skill against the project path; honor that choice on the retry. + const transferredDraftProjectDiscovery = + variant === "workspace" && workspaceId + ? readPersistedState(getPendingDraftSkillDiscoveryKey(workspaceId), false) + : false; const skillDiscovery: SkillResolutionTarget | null = variant === "creation" ? atMentionProjectPath @@ -2448,7 +2455,9 @@ const ChatInputInner: React.FC = (props) => { ? { kind: "workspace", workspaceId, - disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + disableWorkspaceAgents: + sendMessageOptions.disableWorkspaceAgents === true || + transferredDraftProjectDiscovery, } : null; const { parsed, skillInvocation } = await parseCommandWithSkillInvocation({ @@ -2820,6 +2829,11 @@ const ChatInputInner: React.FC = (props) => { const sendOptions = { ...sendMessageOptions, ...compactionOptions, + // Match the original creation send: project-scoped skill refs must + // resolve from the project path, not the new worktree. + ...(transferredDraftProjectDiscovery && hasProjectScopedSkillRef(combinedSkillRefs) + ? { disableWorkspaceAgents: true } + : {}), ...(modelOverride ? { model: modelOverride } : {}), ...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}), ...(modelOneShot ? { skipAiSettingsPersistence: true } : {}), @@ -2881,6 +2895,12 @@ const ChatInputInner: React.FC = (props) => { // but since they initiated it, they've "read" the workspace). updatePersistedState(getWorkspaceLastReadKey(props.workspaceId), Date.now()); + if (transferredDraftProjectDiscovery) { + // The transferred creation draft has been sent; later sends use + // normal workspace skill discovery again. + updatePersistedState(getPendingDraftSkillDiscoveryKey(props.workspaceId), undefined); + } + // Mark attached reviews as completed (checked) if (sentReviewIds.length > 0) { props.onCheckReviews?.(sentReviewIds); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 1e04350597f..e7512f248c2 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -13,6 +13,7 @@ import { getInputAttachmentsKey, getModelKey, getPendingScopeId, + getPendingDraftSkillDiscoveryKey, getPendingWorkspaceSendErrorKey, getProjectScopeId, getThinkingLevelKey, @@ -1058,6 +1059,7 @@ describe("useCreationWorkspace", () => { skillName: "review", scope: "project", }, + disableWorkspaceAgents: true, }, undefined, [good, bad] @@ -1068,11 +1070,15 @@ describe("useCreationWorkspace", () => { expect(workspaceApi.sendMessage.mock.calls.length).toBe(0); // The transferred draft restores the original slash command so a retry - // re-invokes the skill. + // re-invokes the skill, and preserves the forced project-path discovery. expect(updatePersistedStateCalls).toContainEqual([ getInputKey(TEST_WORKSPACE_ID), "/review my files", ]); + expect(updatePersistedStateCalls).toContainEqual([ + getPendingDraftSkillDiscoveryKey(TEST_WORKSPACE_ID), + true, + ]); const attachmentsWrite = updatePersistedStateCalls.find( ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) ); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index aafc276d8c5..4c1c046ce0d 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -30,6 +30,7 @@ import { getWorkspaceAISettingsByAgentKey, getPendingScopeId, getDraftScopeId, + getPendingDraftSkillDiscoveryKey, getPendingWorkspaceSendErrorKey, getProjectScopeId, GLOBAL_SCOPE_ID, @@ -250,8 +251,15 @@ export type RuntimeAvailabilityState = function transferDraftToWorkspace( workspaceId: string, text: string, - attachments: ChatAttachment[] + attachments: ChatAttachment[], + forceProjectSkillDiscovery: boolean ): void { + if (forceProjectSkillDiscovery) { + // The original send resolved its slash skill against the project path; + // carry that choice so the retry cannot resolve a different skill from + // the new worktree. + updatePersistedState(getPendingDraftSkillDiscoveryKey(workspaceId), true); + } updatePersistedState(getInputKey(workspaceId), text); // Pending files carry base64 bytes; drop them when the draft exceeds the // persistence cap (they were memory-only before the transfer too). @@ -665,10 +673,15 @@ export function useCreationWorkspace({ // For slash-skill sends messageText is the rewritten skill text; // restore the original typed command from rawCommand so the retry // re-invokes the skill. - transferDraftToWorkspace(metadata.id, overrideRawCommand ?? messageText, [ - ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), - ...replacePendingFilesWithStaged(pendingFilesToStage, stagingOutcome.staged), - ]); + transferDraftToWorkspace( + metadata.id, + overrideRawCommand ?? messageText, + [ + ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), + ...replacePendingFilesWithStaged(pendingFilesToStage, stagingOutcome.staged), + ], + optionsOverride?.disableWorkspaceAgents === true + ); updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), { type: "unknown", raw: formatPendingFileStagingError(stagingOutcome.failures), @@ -793,10 +806,15 @@ export function useCreationWorkspace({ // The creation draft was already cleared; without a transferred // draft the staged files would sit in the workspace with no // chips/notice to retry the send with. - transferDraftToWorkspace(metadata.id, overrideRawCommand ?? messageText, [ - ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), - ...stagingOutcome.staged, - ]); + transferDraftToWorkspace( + metadata.id, + overrideRawCommand ?? messageText, + [ + ...filePartsToChatAttachments(fileParts ?? [], `${Date.now()}-transferred`), + ...stagingOutcome.staged, + ], + optionsOverride?.disableWorkspaceAgents === true + ); } if (sendResult.error) { // Persist the failure so the workspace view can surface a toast after navigation. diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 2cc8440359d..da6cde5207b 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -274,6 +274,17 @@ export function getPendingWorkspaceSendErrorKey(workspaceId: string): string { return `pendingSendError:${workspaceId}`; } +/** + * Get the localStorage key marking that a creation draft transferred into this + * workspace was sent with forced project-path skill discovery. The retry from + * the workspace composer reads it so the slash skill resolves against the same + * source as the original send. Cleared after the next successful send. + * Format: "pendingDraftProjectSkillDiscovery:{workspaceId}" + */ +export function getPendingDraftSkillDiscoveryKey(workspaceId: string): string { + return `pendingDraftProjectSkillDiscovery:${workspaceId}`; +} + /** * LEGACY: Get the localStorage key for pre-backend auto-retry preference. * @@ -796,6 +807,7 @@ export function getPostCompactionStateKey(workspaceId: string): string { */ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ getPendingWorkspaceSendErrorKey, + getPendingDraftSkillDiscoveryKey, getNotifyOnResponseKey, getPlanContentKey, // Cache only, no need to preserve on fork getPostCompactionStateKey, // Cache only, no need to preserve on fork From 9a2021797167a266fffda5c0fbe7cca727ef1c0a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:50:28 +0000 Subject: [PATCH 08/14] fix(chat): transfer staged draft when the initial send rejects --- .../ChatInput/useCreationWorkspace.test.tsx | 57 +++++++++++++++++++ .../ChatInput/useCreationWorkspace.ts | 34 ++++++----- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index e7512f248c2..1b96703fd9f 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1168,6 +1168,63 @@ describe("useCreationWorkspace", () => { expect(errorWrite?.[1]).toMatchObject({ type: "unknown", raw: "provider exploded" }); }); + test("handleSend transfers the staged draft when the first send rejects after staging", async () => { + const sendMessageMock = mock( + (_args: WorkspaceSendMessageArgs): Promise => + Promise.reject(new Error("orpc disconnected")) + ); + const { workspaceApi } = setupWindow({ sendMessage: sendMessageMock }); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + message: "send my files", + }); + + await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH])); + + const pendingFile: PendingFileChatAttachment = { + kind: "pending-file", + id: "p1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }; + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend("send my files", undefined, undefined, undefined, [ + pendingFile, + ]); + }); + + expect(result).toMatchObject({ success: false }); + expect(workspaceApi.sendMessage.mock.calls.length).toBe(1); + + expect(updatePersistedStateCalls).toContainEqual([ + getInputKey(TEST_WORKSPACE_ID), + "send my files", + ]); + const attachmentsWrite = updatePersistedStateCalls.find( + ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) + ); + expect(attachmentsWrite?.[1]).toEqual([ + { + kind: "staged", + id: "p1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/notes.md", + }, + ]); + const errorWrite = updatePersistedStateCalls.find( + ([key]) => key === getPendingWorkspaceSendErrorKey(TEST_WORKSPACE_ID) + ); + expect(errorWrite?.[1]).toMatchObject({ type: "unknown", raw: "orpc disconnected" }); + }); + test("handleSend creates workspace and applies initial goal command without sending chat text", async () => { const setGoalMock = mock( (_args: WorkspaceSetGoalArgs): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 4c1c046ce0d..2946afddc64 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -784,19 +784,27 @@ export function useCreationWorkspace({ } : overrideMuxMetadata; - const sendResult = await api.workspace.sendMessage({ - workspaceId: metadata.id, - message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), - options: { - ...sendMessageOptions, - ...optionsOverride, - ...(muxMetadataWithNotice ? { muxMetadata: muxMetadataWithNotice } : {}), - additionalSystemInstructions: additionalSystemInstructions.length - ? additionalSystemInstructions - : undefined, - fileParts: fileParts && fileParts.length > 0 ? fileParts : undefined, - }, - }); + // A transport-level rejection (e.g. oRPC disconnect) must flow through + // the same failure branch as success:false: the outer catch would skip + // the staged-draft transfer and the creation draft is already cleared. + const sendResult = await api.workspace + .sendMessage({ + workspaceId: metadata.id, + message: appendStagedAttachmentNotice(messageText, stagingOutcome.staged), + options: { + ...sendMessageOptions, + ...optionsOverride, + ...(muxMetadataWithNotice ? { muxMetadata: muxMetadataWithNotice } : {}), + additionalSystemInstructions: additionalSystemInstructions.length + ? additionalSystemInstructions + : undefined, + fileParts: fileParts && fileParts.length > 0 ? fileParts : undefined, + }, + }) + .catch((sendErr: unknown): { success: false; error: SendMessageError } => ({ + success: false, + error: { type: "unknown", raw: getErrorMessage(sendErr) }, + })); if (!sendResult.success) { if (createdWorkspaceId) { From 23ec38eb5b6e75f4c0c17a98bfe6881221952c1d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:02:25 +0000 Subject: [PATCH 09/14] fix(chat): navigate to the new workspace before staging pending files --- .../ChatInput/useCreationWorkspace.test.tsx | 17 ++++- .../ChatInput/useCreationWorkspace.ts | 65 ++++++++++--------- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 1b96703fd9f..beac53e26e9 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -32,6 +32,7 @@ import type { } from "@/common/types/workspace"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { workspaceStore } from "@/browser/stores/WorkspaceStore"; import { GlobalWindow } from "happy-dom"; import type { PendingFileChatAttachment } from "./ChatAttachments"; import type { WorkspaceCreatedOptions } from "./types"; @@ -948,7 +949,10 @@ describe("useCreationWorkspace", () => { const getHook = renderUseCreationWorkspace({ projectPath: TEST_PROJECT_PATH, - onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => { + callOrder.push("navigate"); + return metadata; + }), message: "check these files", }); @@ -985,7 +989,9 @@ describe("useCreationWorkspace", () => { }); expect(result).toEqual({ success: true }); - expect(callOrder).toEqual(["create", "stage:notes.md", "stage:data.bin", "send"]); + // Navigation must not wait on staging: stageAttachment blocks on runtime + // init, which can take minutes on deferred runtimes. + expect(callOrder).toEqual(["create", "navigate", "stage:notes.md", "stage:data.bin", "send"]); expect(workspaceApi.stageAttachment.mock.calls[0]?.[0]?.workspaceId).toBe(TEST_WORKSPACE_ID); const sendRequest = workspaceApi.sendMessage.mock.calls[0]?.[0]; @@ -1017,6 +1023,7 @@ describe("useCreationWorkspace", () => { const onWorkspaceCreated = mock( (metadata: FrontendWorkspaceMetadata, _options?: WorkspaceCreatedOptions) => metadata ); + const clearPendingInitialSendSpy = spyOn(workspaceStore, "clearPendingInitialSendState"); const { workspaceApi } = setupWindow({ stageAttachment: stageAttachmentMock }); const getHook = renderUseCreationWorkspace({ @@ -1099,8 +1106,12 @@ describe("useCreationWorkspace", () => { expect(errorWrite?.[1]).toMatchObject({ type: "unknown" }); expect((errorWrite?.[1] as { raw: string }).raw).toContain("bad.bin: disk full"); + // Navigation happens optimistically before staging; the pending-send + // barrier is cleared once staging fails. expect(onWorkspaceCreated.mock.calls.length).toBe(1); - expect(onWorkspaceCreated.mock.calls[0][1]).toMatchObject({ markPendingInitialSend: false }); + expect(onWorkspaceCreated.mock.calls[0][1]).toMatchObject({ markPendingInitialSend: true }); + expect(clearPendingInitialSendSpy.mock.calls).toContainEqual([TEST_WORKSPACE_ID]); + clearPendingInitialSendSpy.mockRestore(); const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH); expect(updatePersistedStateCalls).toContainEqual([getInputKey(pendingScopeId), ""]); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 2946afddc64..ef41903254e 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -647,6 +647,38 @@ export function useCreationWorkspace({ updatePersistedState(getInputAttachmentsKey(pendingScopeId), undefined); }; + // Sync preferences before switching (keeps workspace settings consistent). + syncCreationPreferences(projectPath, metadata.id); + + // Switch to the workspace immediately after creation unless the user navigated away + // from the draft that initiated the creation (avoid yanking focus to the new workspace). + const shouldAutoNavigate = + !isDraftScope || + (() => { + if (!isMountedRef.current) return false; + const latestRoute = latestRouteRef.current; + if (latestRoute.currentWorkspaceId) return false; + return latestRoute.pendingDraftId === draftId; + })(); + + // Navigate before staging: stageAttachment waits for runtime init, which + // can take minutes on deferred runtimes (Coder/SSH/devcontainer). The + // optimistic pending-send state is cleared below if staging fails. + onWorkspaceCreated(metadata, { + autoNavigate: shouldAutoNavigate, + pendingStreamModel: shouldAutoNavigate ? baseModel : null, + markPendingInitialSend: initialSlashCommand == null, + }); + + if (typeof draftId === "string" && draftId.trim().length > 0 && promoteWorkspaceDraft) { + // UI-only: show the created workspace in-place where the draft was rendered. + promoteWorkspaceDraft(projectPath, draftId, metadata); + } + + // Persistently clear the draft as soon as the workspace exists so a refresh + // during the initial send can't resurrect the draft entry in the sidebar. + clearPendingDraft(); + // Stage pending files now that the worktree exists on disk, before the // first send so the attached-files notice can reference real staged paths. const stagingOutcome = @@ -667,6 +699,7 @@ export function useCreationWorkspace({ : null; if (stagingFailed) { + workspaceStore.clearPendingInitialSendState(metadata.id); // Fail closed: a partial notice would misrepresent the workspace // contents. Transfer the draft (staged results kept, failed files // still pending) so the user can retry from the workspace composer. @@ -686,38 +719,6 @@ export function useCreationWorkspace({ type: "unknown", raw: formatPendingFileStagingError(stagingOutcome.failures), } satisfies SendMessageError); - } - - // Sync preferences before switching (keeps workspace settings consistent). - syncCreationPreferences(projectPath, metadata.id); - - // Switch to the workspace immediately after creation unless the user navigated away - // from the draft that initiated the creation (avoid yanking focus to the new workspace). - const shouldAutoNavigate = - !isDraftScope || - (() => { - if (!isMountedRef.current) return false; - const latestRoute = latestRouteRef.current; - if (latestRoute.currentWorkspaceId) return false; - return latestRoute.pendingDraftId === draftId; - })(); - - onWorkspaceCreated(metadata, { - autoNavigate: shouldAutoNavigate, - pendingStreamModel: shouldAutoNavigate && !stagingFailed ? baseModel : null, - markPendingInitialSend: initialSlashCommand == null && !stagingFailed, - }); - - if (typeof draftId === "string" && draftId.trim().length > 0 && promoteWorkspaceDraft) { - // UI-only: show the created workspace in-place where the draft was rendered. - promoteWorkspaceDraft(projectPath, draftId, metadata); - } - - // Persistently clear the draft as soon as the workspace exists so a refresh - // during the initial send can't resurrect the draft entry in the sidebar. - clearPendingDraft(); - - if (stagingFailed) { setIsSending(false); return { success: false }; } From b1df87256b44c61204ca231ddffc23d9ad5c214d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:15:28 +0000 Subject: [PATCH 10/14] fix(chat): honor the /goal attachment bypass on workspace retries --- src/browser/features/ChatInput/index.tsx | 19 +++++++---- tests/ui/chat/goalSlashCommand.test.ts | 41 ++++++++++++++++++++++++ tests/ui/harness/createAppHarness.ts | 7 ++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d8f544451cc..820b7638d37 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2585,12 +2585,19 @@ const ChatInputInner: React.FC = (props) => { try { const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null; - const commandHandled = modelOneShot - ? false - : await executeParsedCommand(parsed, input, { - goalInterventionPolicy: overrides?.goalInterventionPolicy, - queueDispatchMode: overrides?.queueDispatchMode, - }); + // Mirror the creation-composer /goal bypass: with attachments present, + // send the raw text as a normal message instead of processing the + // command, which would drop the files. Transferred staging-failure + // drafts (raw /goal text + staged/pending chips) retry through here. + const goalCommandBypassedForAttachments = + parsed?.type === "goal-set" && attachments.length > 0; + const commandHandled = + modelOneShot || goalCommandBypassedForAttachments + ? false + : await executeParsedCommand(parsed, input, { + goalInterventionPolicy: overrides?.goalInterventionPolicy, + queueDispatchMode: overrides?.queueDispatchMode, + }); if (commandHandled) { return; } diff --git a/tests/ui/chat/goalSlashCommand.test.ts b/tests/ui/chat/goalSlashCommand.test.ts index 68586d6522f..33b9e0ccf81 100644 --- a/tests/ui/chat/goalSlashCommand.test.ts +++ b/tests/ui/chat/goalSlashCommand.test.ts @@ -7,6 +7,8 @@ jest.mock("lottie-react", () => ({ import { fireEvent, waitFor } from "@testing-library/react"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getInputAttachmentsKey } from "@/common/constants/storage"; import { preloadTestModules } from "../../ipc/setup"; import { createAppHarness } from "../harness"; @@ -52,4 +54,43 @@ describe("Goal slash command", () => { await app.dispose(); } }, 60_000); + + test("sends /goal with staged attachments as a normal message instead of a command", async () => { + // Simulates a transferred staging-failure draft: raw /goal text plus a + // staged attachment chip persisted under the workspace draft keys. + // Seeded before render because attachments load once at ChatInput mount. + const app = await createAppHarness({ + branchPrefix: "goal-staged", + beforeRender: (workspaceId) => { + updatePersistedState(getInputAttachmentsKey(workspaceId), [ + { + kind: "staged", + id: "s1", + filename: "notes.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/notes.md", + }, + ]); + }, + }); + + try { + await waitFor(() => { + expect(app.view.container.textContent).toContain("notes.md"); + }); + + await app.chat.send("/goal review the attached files"); + + // The bypass sends the raw text as a normal message; the command guard + // would otherwise block staged files with an error toast and no send. + await app.chat.expectTranscriptContains("Mock response:"); + await app.chat.expectTranscriptContains("/goal review the attached files"); + + const { goal } = await app.env.orpc.workspace.getGoal({ workspaceId: app.workspaceId }); + expect(goal).toBeNull(); + } finally { + await app.dispose(); + } + }, 60_000); }); diff --git a/tests/ui/harness/createAppHarness.ts b/tests/ui/harness/createAppHarness.ts index e1b1c12e846..64f5433baf2 100644 --- a/tests/ui/harness/createAppHarness.ts +++ b/tests/ui/harness/createAppHarness.ts @@ -48,9 +48,10 @@ export async function createAppHarness(options?: { runtimeConfig?: RuntimeConfig; /** * Optional hook to set up DOM-dependent globals (e.g. localStorage) before - * the App is rendered. + * the App is rendered. Receives the created workspace id so tests can seed + * workspace-scoped persisted state (e.g. draft attachments). */ - beforeRender?: () => void; + beforeRender?: (workspaceId: string) => void; }): Promise { const repoPath = await createTempGitRepo(); const env = await createTestEnvironment(); @@ -86,7 +87,7 @@ export async function createAppHarness(options?: { metadata = createResult.metadata; cleanupDom = installDom(); - options?.beforeRender?.(); + options?.beforeRender?.(workspaceId); view = renderApp({ apiClient: env.orpc, metadata }); await setupWorkspaceView(view, metadata, workspaceId); From f11c15d2c5a6b66c4dc409920c0907f3107425da Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:24:48 +0000 Subject: [PATCH 11/14] fix(chat): sync external attachment draft writes into the mounted composer --- src/browser/features/ChatInput/index.tsx | 73 ++++++++++++++++-------- tests/ui/chat/goalSlashCommand.test.ts | 32 ++++++++++- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 820b7638d37..8847349852b 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -24,6 +24,7 @@ import { readPersistedState, usePersistedState, updatePersistedState, + subscribePersistedStateWrites, } from "@/browser/hooks/usePersistedState"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; @@ -460,36 +461,44 @@ const ChatInputInner: React.FC = (props) => { const [processingAttachmentCount, setProcessingAttachmentCount] = useState(0); // Reviews restored from edits/queued drafts override attached review state while active. const [draftReviews, setDraftReviews] = useState(null); + // Distinguishes this component's own persisted writes from external ones + // (e.g. a creation-flow draft transfer landing after this composer mounted). + const selfAttachmentWriteRef = useRef(false); const persistAttachments = useCallback( (nextAttachments: ChatAttachment[]) => { - if (nextAttachments.length === 0) { - attachmentDraftTooLargeToastKeyRef.current = null; - updatePersistedState(storageKeys.attachmentsKey, undefined); - return; - } - - const estimatedChars = estimatePersistedChatAttachmentsChars(nextAttachments); - if (estimatedChars > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS) { - // Clear persisted value to avoid restoring stale attachments on restart. - updatePersistedState(storageKeys.attachmentsKey, undefined); + selfAttachmentWriteRef.current = true; + try { + if (nextAttachments.length === 0) { + attachmentDraftTooLargeToastKeyRef.current = null; + updatePersistedState(storageKeys.attachmentsKey, undefined); + return; + } - if (attachmentDraftTooLargeToastKeyRef.current !== storageKeys.attachmentsKey) { - attachmentDraftTooLargeToastKeyRef.current = storageKeys.attachmentsKey; - pushToast({ - type: "error", - message: - "This draft attachment is too large to save. It will be lost when you switch workspaces or restart.", - duration: 5000, - }); + const estimatedChars = estimatePersistedChatAttachmentsChars(nextAttachments); + if (estimatedChars > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS) { + // Clear persisted value to avoid restoring stale attachments on restart. + updatePersistedState(storageKeys.attachmentsKey, undefined); + + if (attachmentDraftTooLargeToastKeyRef.current !== storageKeys.attachmentsKey) { + attachmentDraftTooLargeToastKeyRef.current = storageKeys.attachmentsKey; + pushToast({ + type: "error", + message: + "This draft attachment is too large to save. It will be lost when you switch workspaces or restart.", + duration: 5000, + }); + } + return; } - return; - } - attachmentDraftTooLargeToastKeyRef.current = null; - updatePersistedState( - storageKeys.attachmentsKey, - nextAttachments - ); + attachmentDraftTooLargeToastKeyRef.current = null; + updatePersistedState( + storageKeys.attachmentsKey, + nextAttachments + ); + } finally { + selfAttachmentWriteRef.current = false; + } }, [storageKeys.attachmentsKey, pushToast] ); @@ -499,6 +508,20 @@ const ChatInputInner: React.FC = (props) => { attachmentDraftTooLargeToastKeyRef.current = null; setAttachmentsState(readPersistedChatAttachments(storageKeys.attachmentsKey)); }, [storageKeys.attachmentsKey]); + + // Attachments live in local state (a too-large draft stays in memory after + // its persisted copy is cleared), so external writes to the draft key, such + // as a creation-flow transfer that lands after this composer mounted, must + // be synced in explicitly. Self-writes are skipped to keep that in-memory + // exception intact. + useEffect(() => { + return subscribePersistedStateWrites((event) => { + if (event.key !== storageKeys.attachmentsKey || selfAttachmentWriteRef.current) { + return; + } + setAttachmentsState(readPersistedChatAttachments(storageKeys.attachmentsKey)); + }); + }, [storageKeys.attachmentsKey]); const setAttachments = useCallback( (value: ChatAttachment[] | ((prev: ChatAttachment[]) => ChatAttachment[])) => { setAttachmentsState((prev) => { diff --git a/tests/ui/chat/goalSlashCommand.test.ts b/tests/ui/chat/goalSlashCommand.test.ts index 33b9e0ccf81..9148cc78141 100644 --- a/tests/ui/chat/goalSlashCommand.test.ts +++ b/tests/ui/chat/goalSlashCommand.test.ts @@ -5,7 +5,7 @@ jest.mock("lottie-react", () => ({ default: () => null, })); -import { fireEvent, waitFor } from "@testing-library/react"; +import { act, fireEvent, waitFor } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getInputAttachmentsKey } from "@/common/constants/storage"; @@ -93,4 +93,34 @@ describe("Goal slash command", () => { await app.dispose(); } }, 60_000); + + test("shows attachment chips written to the draft key after the composer mounted", async () => { + const app = await createAppHarness({ + branchPrefix: "late-attach", + }); + + try { + // Simulates a creation-flow draft transfer that lands after navigation: + // staging on deferred runtimes can finish minutes after the workspace + // composer mounted, so the write must sync into the mounted composer. + act(() => { + updatePersistedState(getInputAttachmentsKey(app.workspaceId), [ + { + kind: "staged", + id: "s1", + filename: "late-transfer.md", + mediaType: "text/markdown", + sizeBytes: 8, + stagedPath: ".mux/user-attachments/uuid/late-transfer.md", + }, + ]); + }); + + await waitFor(() => { + expect(app.view.container.textContent).toContain("late-transfer.md"); + }); + } finally { + await app.dispose(); + } + }, 60_000); }); From 4268432f14cb561fa1a2470bbde4affa3d2b05e2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:38:46 +0000 Subject: [PATCH 12/14] fix(chat): lock the workspace composer while creation staging is in flight --- src/browser/features/ChatInput/index.tsx | 18 ++++++++- .../features/ChatInput/initialStagingLock.ts | 35 ++++++++++++++++ .../ChatInput/useCreationWorkspace.test.tsx | 6 +++ .../ChatInput/useCreationWorkspace.ts | 13 ++++++ tests/ui/chat/goalSlashCommand.test.ts | 40 +++++++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/browser/features/ChatInput/initialStagingLock.ts diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 8847349852b..39e49eaf845 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -6,7 +6,12 @@ import React, { useId, useMemo, useLayoutEffect, + useSyncExternalStore, } from "react"; +import { + isInitialStagingLocked, + subscribeInitialStagingLock, +} from "@/browser/features/ChatInput/initialStagingLock"; import { CommandSuggestions, COMMAND_SUGGESTION_KEYS, @@ -310,8 +315,16 @@ const ChatInputInner: React.FC = (props) => { const workspaceSidebarState = useOptionalWorkspaceSidebarState(workspaceId); const workspaceGoal = workspaceSidebarState?.goal ?? null; + // Lock the composer while a creation-flow staging + initial send is still in + // flight for this workspace (deferred runtimes can hold it for minutes), so a + // user send cannot leapfrog the initial message and a failure transfer cannot + // overwrite a freshly typed draft. + const initialStagingLocked = useSyncExternalStore(subscribeInitialStagingLock, () => + variant === "workspace" ? isInitialStagingLocked(props.workspaceId) : false + ); + // Extract workspace-specific props with defaults - const disabled = props.disabled ?? false; + const disabled = (props.disabled ?? false) || initialStagingLocked; const editingMessage = variant === "workspace" ? props.editingMessage : undefined; const [pendingBoundaryEditConfirmation, setPendingBoundaryEditConfirmation] = useState(null); @@ -3139,6 +3152,9 @@ const ChatInputInner: React.FC = (props) => { return `Edit your message... (${cancelHint}, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (disabled) { + if (initialStagingLocked) { + return "Staging attached files..."; + } const disabledReason = props.disabledReason; if (typeof disabledReason === "string" && disabledReason.trim().length > 0) { return disabledReason; diff --git a/src/browser/features/ChatInput/initialStagingLock.ts b/src/browser/features/ChatInput/initialStagingLock.ts new file mode 100644 index 00000000000..83d3156ecf7 --- /dev/null +++ b/src/browser/features/ChatInput/initialStagingLock.ts @@ -0,0 +1,35 @@ +// Memory-only lock marking workspaces whose creation-flow staging + initial +// send is still in flight. The mounted workspace composer disables itself +// while locked so a user send cannot leapfrog the initial message and a +// failure transfer cannot overwrite a freshly typed draft. Deliberately not +// persisted: a reload kills the creation flow, so the lock must die with it. +const lockedWorkspaceIds = new Set(); +const listeners = new Set<() => void>(); + +function notify(): void { + for (const listener of listeners) { + listener(); + } +} + +export function lockInitialStaging(workspaceId: string): void { + lockedWorkspaceIds.add(workspaceId); + notify(); +} + +export function unlockInitialStaging(workspaceId: string): void { + if (lockedWorkspaceIds.delete(workspaceId)) { + notify(); + } +} + +export function isInitialStagingLocked(workspaceId: string): boolean { + return lockedWorkspaceIds.has(workspaceId); +} + +export function subscribeInitialStagingLock(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index beac53e26e9..d349a5daa34 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -33,6 +33,7 @@ import type { import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { workspaceStore } from "@/browser/stores/WorkspaceStore"; +import { isInitialStagingLocked } from "@/browser/features/ChatInput/initialStagingLock"; import { GlobalWindow } from "happy-dom"; import type { PendingFileChatAttachment } from "./ChatAttachments"; import type { WorkspaceCreatedOptions } from "./types"; @@ -921,9 +922,11 @@ describe("useCreationWorkspace", () => { callOrder.push("create"); return Promise.resolve({ success: true, metadata: TEST_METADATA } as WorkspaceCreateResult); }); + const lockObservedDuringStaging: boolean[] = []; const stageAttachmentMock = mock( (args: WorkspaceStageAttachmentArgs): Promise => { callOrder.push(`stage:${args.filename}`); + lockObservedDuringStaging.push(isInitialStagingLocked(TEST_WORKSPACE_ID)); return Promise.resolve({ success: true, data: { @@ -992,6 +995,9 @@ describe("useCreationWorkspace", () => { // Navigation must not wait on staging: stageAttachment blocks on runtime // init, which can take minutes on deferred runtimes. expect(callOrder).toEqual(["create", "navigate", "stage:notes.md", "stage:data.bin", "send"]); + // The composer lock is held during staging and released once the send is done. + expect(lockObservedDuringStaging).toEqual([true, true]); + expect(isInitialStagingLocked(TEST_WORKSPACE_ID)).toBe(false); expect(workspaceApi.stageAttachment.mock.calls[0]?.[0]?.workspaceId).toBe(TEST_WORKSPACE_ID); const sendRequest = workspaceApi.sendMessage.mock.calls[0]?.[0]; diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index ef41903254e..08373a1e064 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -55,6 +55,10 @@ import { stagePendingFiles, } from "@/browser/features/ChatInput/pendingFileAttachments"; import { filePartsToChatAttachments } from "@/browser/features/ChatInput/utils"; +import { + lockInitialStaging, + unlockInitialStaging, +} from "@/browser/features/ChatInput/initialStagingLock"; import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; import { estimatePersistedChatAttachmentsChars, @@ -664,6 +668,11 @@ export function useCreationWorkspace({ // Navigate before staging: stageAttachment waits for runtime init, which // can take minutes on deferred runtimes (Coder/SSH/devcontainer). The // optimistic pending-send state is cleared below if staging fails. + // Lock the mounted composer for that window so a user send cannot + // leapfrog the initial message; the finally below unlocks on all paths. + if (pendingFilesToStage.length > 0) { + lockInitialStaging(metadata.id); + } onWorkspaceCreated(metadata, { autoNavigate: shouldAutoNavigate, pendingStreamModel: shouldAutoNavigate ? baseModel : null, @@ -845,6 +854,10 @@ export function useCreationWorkspace({ }); setIsSending(false); return { success: false }; + } finally { + if (createdWorkspaceId) { + unlockInitialStaging(createdWorkspaceId); + } } }, [ diff --git a/tests/ui/chat/goalSlashCommand.test.ts b/tests/ui/chat/goalSlashCommand.test.ts index 9148cc78141..b171be0e7dd 100644 --- a/tests/ui/chat/goalSlashCommand.test.ts +++ b/tests/ui/chat/goalSlashCommand.test.ts @@ -8,6 +8,10 @@ jest.mock("lottie-react", () => ({ import { act, fireEvent, waitFor } from "@testing-library/react"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + lockInitialStaging, + unlockInitialStaging, +} from "@/browser/features/ChatInput/initialStagingLock"; import { getInputAttachmentsKey } from "@/common/constants/storage"; import { preloadTestModules } from "../../ipc/setup"; import { createAppHarness } from "../harness"; @@ -123,4 +127,40 @@ describe("Goal slash command", () => { await app.dispose(); } }, 60_000); + + test("disables the composer while the initial staging lock is held", async () => { + const app = await createAppHarness({ + branchPrefix: "staging-lock", + }); + + try { + const getTextarea = () => + app.view.container.querySelector( + 'textarea[aria-label="Message Claude"]' + ) as HTMLTextAreaElement | null; + + await waitFor(() => { + const textarea = getTextarea(); + expect(textarea).not.toBeNull(); + expect(textarea!.disabled).toBe(false); + }); + + act(() => { + lockInitialStaging(app.workspaceId); + }); + await waitFor(() => { + expect(getTextarea()!.disabled).toBe(true); + }); + + act(() => { + unlockInitialStaging(app.workspaceId); + }); + await waitFor(() => { + expect(getTextarea()!.disabled).toBe(false); + }); + } finally { + unlockInitialStaging(app.workspaceId); + await app.dispose(); + } + }, 60_000); }); From 39aca0dfeb569d16a57a29d404e631b967856c66 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:48:18 +0000 Subject: [PATCH 13/14] fix(chat): reload skill descriptors under forced project discovery for transferred drafts --- src/browser/features/ChatInput/index.tsx | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 39e49eaf845..90181142cb3 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -447,6 +447,19 @@ const ChatInputInner: React.FC = (props) => { }, [setToast] ); + // A draft transferred from a failed creation send may have resolved its + // slash skill against the project path; honor that choice on the retry. + // listener: true keeps this in sync when the transfer lands after mount and + // when a successful send clears the key, so the skill descriptor list below + // reloads under the matching discovery target. + const [transferredDraftProjectDiscovery] = usePersistedState( + variant === "workspace" && workspaceId + ? getPendingDraftSkillDiscoveryKey(workspaceId) + : "__unused__", + false, + { listener: true } + ); + // Subscribe to pending send errors from creation flow. Uses listener: true so // late failures (e.g., slow devcontainer startup) still surface a toast. const pendingErrorKey = @@ -1709,7 +1722,9 @@ const ChatInputInner: React.FC = (props) => { variant === "workspace" && workspaceId ? { workspaceId, - disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + disableWorkspaceAgents: + sendMessageOptions.disableWorkspaceAgents === true || + transferredDraftProjectDiscovery, } : variant === "creation" && atMentionProjectPath ? { projectPath: atMentionProjectPath } @@ -1744,7 +1759,14 @@ const ChatInputInner: React.FC = (props) => { return () => { isMounted = false; }; - }, [api, variant, workspaceId, atMentionProjectPath, sendMessageOptions.disableWorkspaceAgents]); + }, [ + api, + variant, + workspaceId, + atMentionProjectPath, + sendMessageOptions.disableWorkspaceAgents, + transferredDraftProjectDiscovery, + ]); // Voice input: track transcription provider availability (subscribe to provider config changes) useEffect(() => { @@ -2476,12 +2498,6 @@ const ChatInputInner: React.FC = (props) => { closeSendModeMenu(); const messageText = input.trim(); - // A draft transferred from a failed creation send may have resolved its - // slash skill against the project path; honor that choice on the retry. - const transferredDraftProjectDiscovery = - variant === "workspace" && workspaceId - ? readPersistedState(getPendingDraftSkillDiscoveryKey(workspaceId), false) - : false; const skillDiscovery: SkillResolutionTarget | null = variant === "creation" ? atMentionProjectPath From 92ee77170a4bdf56ff9a0e9c22417cd13bdfb232 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:46:20 +0000 Subject: [PATCH 14/14] fix(chat): trim only oversized attachments from transferred drafts --- .../ChatInput/useCreationWorkspace.test.tsx | 52 +++++++++++++++++++ .../ChatInput/useCreationWorkspace.ts | 19 ++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index d349a5daa34..5400c28a92c 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -1242,6 +1242,58 @@ describe("useCreationWorkspace", () => { expect(errorWrite?.[1]).toMatchObject({ type: "unknown", raw: "orpc disconnected" }); }); + test("handleSend keeps small retryable files when trimming an over-cap transfer", async () => { + const stageAttachmentMock = mock( + (_args: WorkspaceStageAttachmentArgs): Promise => + Promise.resolve({ success: false, error: "disk full" } as WorkspaceStageAttachmentResult) + ); + setupWindow({ stageAttachment: stageAttachmentMock }); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated: mock((metadata: FrontendWorkspaceMetadata) => metadata), + message: "send my files", + }); + + await waitFor(() => expect(getHook().branches).toEqual([FALLBACK_BRANCH])); + + const failedPendingFile: PendingFileChatAttachment = { + kind: "pending-file", + id: "p1", + filename: "small.bin", + mediaType: "application/octet-stream", + sizeBytes: 3, + dataBase64: "Ymlu", + }; + // A provider attachment whose data URL alone exceeds the persistence cap. + const oversizedFilePart = { + type: "file" as const, + url: `data:application/pdf;base64,${"a".repeat(4_000_001)}`, + mediaType: "application/pdf", + filename: "big.pdf", + }; + + let result: CreationSendResult | undefined; + await act(async () => { + result = await getHook().handleSend( + "send my files", + [oversizedFilePart], + undefined, + undefined, + [failedPendingFile] + ); + }); + + expect(result).toEqual({ success: false }); + + // The trim drops only the oversized attachment; the small failed pending + // file survives so the user can retry staging it. + const attachmentsWrite = updatePersistedStateCalls.find( + ([key]) => key === getInputAttachmentsKey(TEST_WORKSPACE_ID) + ); + expect(attachmentsWrite?.[1]).toEqual([failedPendingFile]); + }); + test("handleSend creates workspace and applies initial goal command without sending chat text", async () => { const setGoalMock = mock( (_args: WorkspaceSetGoalArgs): Promise => diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 08373a1e064..c233d00720b 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -265,12 +265,19 @@ function transferDraftToWorkspace( updatePersistedState(getPendingDraftSkillDiscoveryKey(workspaceId), true); } updatePersistedState(getInputKey(workspaceId), text); - // Pending files carry base64 bytes; drop them when the draft exceeds the - // persistence cap (they were memory-only before the transfer too). - const persistable = - estimatePersistedChatAttachmentsChars(attachments) > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS - ? attachments.filter((attachment) => attachment.kind !== "pending-file") - : attachments; + // Base64-bearing attachments can exceed the persistence cap. Drop the + // largest ones first so small retryable chips (e.g. a pending file whose + // staging failed) survive the transfer. + let persistable = attachments; + while ( + persistable.length > 0 && + estimatePersistedChatAttachmentsChars(persistable) > MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS + ) { + const largest = persistable.reduce((a, b) => + JSON.stringify(b).length > JSON.stringify(a).length ? b : a + ); + persistable = persistable.filter((attachment) => attachment !== largest); + } updatePersistedState( getInputAttachmentsKey(workspaceId), persistable.length > 0 ? persistable : undefined