diff --git a/src/browser/features/ChatInput/AttachFileButton.tsx b/src/browser/features/ChatInput/AttachFileButton.tsx index 2d65cb6df5..9f3f8fb351 100644 --- a/src/browser/features/ChatInput/AttachFileButton.tsx +++ b/src/browser/features/ChatInput/AttachFileButton.tsx @@ -1,21 +1,11 @@ -/** - * 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. - */ - import React, { useRef } from "react"; 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 +45,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 a870a217bf..371ae2580c 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 5ba6cb1e18..4a137d4a5e 100644 --- a/src/browser/features/ChatInput/draftAttachmentsStorage.ts +++ b/src/browser/features/ChatInput/draftAttachmentsStorage.ts @@ -1,6 +1,9 @@ import type { ChatAttachment } from "@/browser/features/ChatInput/ChatAttachments"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; +/** 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 { return typeof value === "object" && value !== null; } @@ -43,6 +46,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 +97,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 5f39fa0419..90181142cb 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, @@ -24,6 +29,7 @@ import { readPersistedState, usePersistedState, updatePersistedState, + subscribePersistedStateWrites, } from "@/browser/hooks/usePersistedState"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; @@ -63,6 +69,7 @@ import { getProjectScopeId, getPendingScopeId, getDraftScopeId, + getPendingDraftSkillDiscoveryKey, getPendingWorkspaceSendErrorKey, getWorkspaceLastReadKey, } from "@/common/constants/storage"; @@ -189,8 +196,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 +253,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. @@ -302,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); @@ -426,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 = @@ -453,36 +487,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] ); @@ -492,6 +534,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) => { @@ -918,9 +974,16 @@ 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) { + 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( @@ -1659,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 } @@ -1694,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(() => { @@ -2050,6 +2122,7 @@ const ChatInputInner: React.FC = (props) => { return result.data; } : undefined, + holdNonProviderFiles: variant === "creation", }).finally(() => { setProcessingAttachmentCount((count) => Math.max(0, count - 1)); }); @@ -2172,7 +2245,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", @@ -2429,7 +2507,9 @@ const ChatInputInner: React.FC = (props) => { ? { kind: "workspace", workspaceId, - disableWorkspaceAgents: sendMessageOptions.disableWorkspaceAgents, + disableWorkspaceAgents: + sendMessageOptions.disableWorkspaceAgents === true || + transferredDraftProjectDiscovery, } : null; const { parsed, skillInvocation } = await parseCommandWithSkillInvocation({ @@ -2448,8 +2528,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 +2594,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) { @@ -2545,12 +2637,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; } @@ -2565,13 +2664,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 +2671,38 @@ const ChatInputInner: React.FC = (props) => { } setSendingCount((c) => c + 1); + // 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) { + 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. @@ -2628,13 +2752,13 @@ const ChatInputInner: React.FC = (props) => { } } // Save current draft state for restoration on error - const preSendDraft = getDraft(); + 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 +2794,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 +2815,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 +2839,7 @@ const ChatInputInner: React.FC = (props) => { .trimEnd() : undefined; const oneshotRawCommand = oneshotCommandPrefix - ? appendStagedAttachmentNotice(messageText.trim(), attachments) + ? appendStagedAttachmentNotice(messageText.trim(), sendAttachments) : undefined; muxMetadata = muxMetadata ? { @@ -2761,6 +2888,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 } : {}), @@ -2822,6 +2954,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); @@ -3030,6 +3168,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; @@ -3365,7 +3506,6 @@ const ChatInputInner: React.FC = (props) => { (); +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/pendingFileAttachments.test.ts b/src/browser/features/ChatInput/pendingFileAttachments.test.ts new file mode 100644 index 0000000000..557cf5ee21 --- /dev/null +++ b/src/browser/features/ChatInput/pendingFileAttachments.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; + +import type { PendingFileChatAttachment, StagedChatAttachment } from "./ChatAttachments"; +import { + formatPendingFileStagingError, + getPendingFileAttachments, + replacePendingFilesWithStaged, + stagePendingFiles, +} from "./pendingFileAttachments"; + +function pendingFile(id: string, filename: string): PendingFileChatAttachment { + return { + kind: "pending-file", + id, + filename, + mediaType: "text/markdown", + sizeBytes: 8, + dataBase64: "bWFya2Rvd24=", + }; +} + +type StagePendingFilesApi = Parameters[0]; +type StageAttachmentFn = StagePendingFilesApi["workspace"]["stageAttachment"]; +type StageAttachmentInput = Parameters[0]; + +function apiWithStageAttachment(impl: StageAttachmentFn): StagePendingFilesApi { + return { workspace: { stageAttachment: impl } }; +} + +describe("stagePendingFiles", () => { + 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 0000000000..9a8839d8cc --- /dev/null +++ b/src/browser/features/ChatInput/pendingFileAttachments.ts @@ -0,0 +1,83 @@ +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"); +} + +/** + * 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 }; +} + +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 b34e62dfd5..5400c28a92 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, @@ -31,7 +32,10 @@ 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 { isInitialStagingLocked } from "@/browser/features/ChatInput/initialStagingLock"; import { GlobalWindow } from "happy-dom"; +import type { PendingFileChatAttachment } from "./ChatAttachments"; import type { WorkspaceCreatedOptions } from "./types"; import { useCreationWorkspace, type CreationSendResult } from "./useCreationWorkspace"; @@ -256,13 +260,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 +331,9 @@ interface SetupWindowOptions { nameGeneration?: ReturnType< typeof mock<(args: NameGenerationArgs) => Promise> >; + stageAttachment?: ReturnType< + typeof mock<(args: WorkspaceStageAttachmentArgs) => Promise> + >; } const setupWindow = ({ @@ -331,6 +348,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 +464,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 +502,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 +618,7 @@ const setupWindow = ({ updateAgentAISettings: updateAgentAISettingsMock, getGoal: getGoalMock, setGoal: setGoalMock, + stageAttachment: stageAttachmentMock, }, workflowsApi: { start: workflowStartMock, getRun: workflowGetRunMock }, nameGenerationApi: { generate: nameGenerationMock }, @@ -880,6 +916,384 @@ 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 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: { + 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) => { + callOrder.push("navigate"); + return 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 }); + // 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]; + 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 clearPendingInitialSendSpy = spyOn(workspaceStore, "clearPendingInitialSendState"); + 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 () => { + // 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", + }, + disableWorkspaceAgents: true, + }, + 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, 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) + ); + 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"); + + // 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: true }); + expect(clearPendingInitialSendSpy.mock.calls).toContainEqual([TEST_WORKSPACE_ID]); + clearPendingInitialSendSpy.mockRestore(); + + const pendingScopeId = getPendingScopeId(TEST_PROJECT_PATH); + 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 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 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 afa24bfa33..c233d00720 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, @@ -44,6 +45,26 @@ 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 { filePartsToChatAttachments } from "@/browser/features/ChatInput/utils"; +import { + lockInitialStaging, + unlockInitialStaging, +} from "@/browser/features/ChatInput/initialStagingLock"; +import { appendStagedAttachmentNotice } from "@/browser/features/ChatInput/stagedAttachments"; +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"; @@ -206,7 +227,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 +250,40 @@ export type RuntimeAvailabilityState = | { status: "failed" } | { status: "loaded"; data: RuntimeAvailabilityMap }; +// 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[], + 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); + // 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 + ); +} + /** * Hook for managing workspace creation state and logic * Handles: @@ -386,9 +442,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 carry the content. + const hasSendableAttachments = pendingFilesToStage.length > 0 || (fileParts?.length ?? 0) > 0; + if ((!messageText.trim() && !hasSendableAttachments) || isSending || !api) { return { success: false }; } @@ -611,6 +672,14 @@ export function useCreationWorkspace({ 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. + // 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, @@ -626,6 +695,50 @@ export function useCreationWorkspace({ // 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 = + pendingFilesToStage.length > 0 + ? await stagePendingFiles(api, metadata.id, pendingFilesToStage) + : { 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) { + 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. + // 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), + ], + optionsOverride?.disableWorkspaceAgents === true + ); + updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), { + type: "unknown", + raw: formatPendingFileStagingError(stagingOutcome.failures), + } satisfies SendMessageError); + setIsSending(false); + return { success: false }; + } + if (initialSlashCommand) { await initialAiSettingsPersisted; const commandContext: SlashCommandContext = { @@ -677,23 +790,57 @@ export function useCreationWorkspace({ .filter((part) => typeof part === "string" && part.trim().length > 0) .join("\n\n"); - const sendResult = await api.workspace.sendMessage({ - workspaceId: metadata.id, - message: messageText, - options: { - ...sendMessageOptions, - ...optionsOverride, - additionalSystemInstructions: additionalSystemInstructions.length - ? additionalSystemInstructions - : undefined, - fileParts: fileParts && fileParts.length > 0 ? fileParts : undefined, - }, - }); + // 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. + const muxMetadataWithNotice = + stagingOutcome.staged.length > 0 && overrideMuxMetadata && overrideRawCommand !== null + ? { + ...overrideMuxMetadata, + rawCommand: appendStagedAttachmentNotice(overrideRawCommand, stagingOutcome.staged), + } + : overrideMuxMetadata; + + // 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) { 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, + ], + optionsOverride?.disableWorkspaceAgents === true + ); + } if (sendResult.error) { // Persist the failure so the workspace view can surface a toast after navigation. updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), sendResult.error); @@ -714,6 +861,10 @@ export function useCreationWorkspace({ }); setIsSending(false); return { success: false }; + } finally { + if (createdWorkspaceId) { + unlockInitialStaging(createdWorkspaceId); + } } }, [ diff --git a/src/browser/utils/attachmentsHandling.test.ts b/src/browser/utils/attachmentsHandling.test.ts index d9271d9d71..fef2d442f1 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 68acac5848..2b35cfe5f6 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; + /** + * 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; } 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."); }) ); } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 2cc8440359..da6cde5207 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 diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e44e9cd5b0..fd24f482f1 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -220,6 +220,70 @@ 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; + }); + let barrierReached: () => void = () => undefined; + const barrierReachedGate = new Promise((resolve) => { + barrierReached = resolve; + }); + const waitForInit = mock(() => { + barrierReached(); + return 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 barrierReachedGate; + 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 e8a1e35248..93e78c9f55 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, diff --git a/tests/ui/chat/goalSlashCommand.test.ts b/tests/ui/chat/goalSlashCommand.test.ts index 68586d6522..b171be0e7d 100644 --- a/tests/ui/chat/goalSlashCommand.test.ts +++ b/tests/ui/chat/goalSlashCommand.test.ts @@ -5,8 +5,14 @@ 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 { + lockInitialStaging, + unlockInitialStaging, +} from "@/browser/features/ChatInput/initialStagingLock"; +import { getInputAttachmentsKey } from "@/common/constants/storage"; import { preloadTestModules } from "../../ipc/setup"; import { createAppHarness } from "../harness"; @@ -52,4 +58,109 @@ 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); + + 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); + + 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); }); diff --git a/tests/ui/harness/createAppHarness.ts b/tests/ui/harness/createAppHarness.ts index e1b1c12e84..64f5433baf 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);