From 5e70014da8ee6aa6635fe4f031baa7dbaca17231 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:41:51 -0700 Subject: [PATCH] feat(native-chat): support file drag and drop (#20494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing #15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim --- src/preload/preload-runtime-support.ts | 16 +- .../combined-diff/CombinedDiffViewer.tsx | 1 + .../combined-diff-file-tree-row-drag.test.tsx | 87 +++ .../combined-diff-file-tree-row.tsx | 9 + .../combined-diff-file-tree-rows.tsx | 3 + .../browse-files/combined-diff-file-tree.tsx | 3 + .../native-chat/NativeChatComposer.tsx | 9 + .../native-chat/NativeChatComposerField.tsx | 13 +- .../native-chat-attachment-upload.test.ts | 12 + .../native-chat-attachment-upload.ts | 30 +- .../native-chat-composer-drop-scope.test.tsx | 101 +++- ...chat-composer-workspace-file-drop.test.tsx | 506 ++++++++++++++++++ ...ative-chat-resolved-path-ownership.test.ts | 41 ++ .../native-chat-resolved-path-ownership.ts | 35 ++ ...-native-chat-composer-attachments.test.tsx | 138 ++++- .../use-native-chat-composer-attachments.ts | 164 ++---- ...-native-chat-external-attachments.test.tsx | 175 +++++- .../use-native-chat-external-attachments.ts | 74 ++- ...e-native-chat-resolved-path-attachments.ts | 205 +++++++ .../use-native-chat-workspace-file-drop.ts | 167 ++++++ .../FileExplorerFilesTreePane.tsx | 2 + .../right-sidebar/FileExplorerRow.tsx | 14 +- .../right-sidebar/FileExplorerVirtualRows.tsx | 54 +- .../file-explorer-drag-scroll-marker.test.tsx | 155 +++++- .../file-explorer-operation-owner.ts | 6 + .../listing/branch-entry-row.tsx | 2 + .../listing/uncommitted-entry-row.tsx | 2 + .../useFileExplorerTree.stale-dirs.test.tsx | 22 + .../right-sidebar/useFileExplorerTree.ts | 7 + .../src/hooks/useGlobalFileDrop.test.ts | 14 + src/renderer/src/hooks/useGlobalFileDrop.ts | 13 + src/renderer/src/i18n/locales/en.json | 9 +- .../src/lib/workspace-file-drag-source.ts | 16 + .../src/lib/workspace-file-drag.test.ts | 30 +- src/renderer/src/lib/workspace-file-drag.ts | 80 +++ src/shared/native-file-drop.ts | 15 +- 36 files changed, 2053 insertions(+), 177 deletions(-) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row-drag.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-resolved-path-ownership.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-resolved-path-attachments.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-workspace-file-drop.ts create mode 100644 src/renderer/src/lib/workspace-file-drag-source.ts diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts index 27ce7d77bc2..6a9462473c1 100644 --- a/src/preload/preload-runtime-support.ts +++ b/src/preload/preload-runtime-support.ts @@ -13,7 +13,8 @@ import { resolveNativeFileDropPath, type NativeDropResolution, type NativeFileDropPayload, - type NativeFileDropPathEntry + type NativeFileDropPathEntry, + type NativeFileDropRejectedPayload } from '../shared/native-file-drop' /** Joins the synchronous unload checkpoint with its durable renderer write. */ @@ -133,7 +134,18 @@ export function installNativeFileDropHandlers(): void { paths.push(filePath) } } - if (paths.length === 0 || resolution?.target === 'rejected') { + if (resolution?.target === 'rejected') { + return + } + if (paths.length === 0) { + // The OS offered file items we could read no path from (promised or + // virtual files). Report it — silence here is #15782. + ipcRenderer.send('terminal:file-dropped-from-preload', { + byteLength: 0, + pathCount: files.length, + reason: 'unresolved-paths', + target: 'rejected' + } satisfies NativeFileDropRejectedPayload) return } const payload = createNativeFileDropPayload(resolution, paths) diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 82e7ece6cfb..449f89cb70f 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -348,6 +348,7 @@ export default function CombinedDiffViewer({ ({ + executionHostId: 'local' +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => testState.executionHostId +})) + +const { CombinedDiffFileTreeRow } = await import('./combined-diff-file-tree-row') +const { readWorkspaceFileDragSource } = await import('@/lib/workspace-file-drag') + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const roots: Root[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + testState.executionHostId = 'local' +}) + +function renderRow(sourceWorkspaceId?: string): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render( + {}} + onNavigate={() => {}} + /> + ) + }) + return container +} + +function dragRow(container: HTMLDivElement): DataTransfer { + const transfer = new DataTransfer() + const event = new Event('dragstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { value: transfer }) + act(() => { + container.querySelector('[draggable="true"]')?.dispatchEvent(event) + }) + return transfer +} + +describe('combined diff rows stamp their drag source', () => { + // The tab's entry list is a snapshot, but the paths it drags belong to the + // workspace as it is owned now — the same answer the source-control rows give. + it('stamps the live owner of the workspace the diff belongs to', () => { + testState.executionHostId = 'runtime:env-1' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toEqual({ + executionHostId: 'runtime:env-1', + workspaceId: 'wt-1' + }) + }) + + it('leaves the drag unstamped when the owner or the workspace is unknown', () => { + expect(readWorkspaceFileDragSource(dragRow(renderRow(undefined)))).toBeNull() + testState.executionHostId = 'runtime:unresolved-owner' + expect(readWorkspaceFileDragSource(dragRow(renderRow('wt-1')))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index dfb417e2518..fe6d5644db1 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -6,6 +6,7 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' +import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitFileStatus, @@ -35,6 +36,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, isCollapsed, @@ -45,6 +47,7 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ node: CombinedDiffTreeNode mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap isCollapsed: boolean @@ -62,6 +65,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ draggable onDragStart={(event) => { event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.path)) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} > @@ -117,6 +123,9 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ WORKSPACE_FILE_PATH_MIME, joinPath(worktreePath, node.entry.path) ) + if (sourceWorkspaceId) { + writeWorkspaceFileDragSourceForWorkspace(event.dataTransfer, sourceWorkspaceId) + } event.dataTransfer.effectAllowed = 'copy' }} onClick={() => onNavigate(node.entry)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx index 229f9561ac0..3b5abc6113c 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-rows.tsx @@ -19,6 +19,7 @@ export function CombinedDiffFileTreeRows({ rows, mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, @@ -30,6 +31,7 @@ export function CombinedDiffFileTreeRows({ rows: readonly CombinedDiffTreeNode[] mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string activeSectionKey: string | null sectionIndexByKey: ReadonlyMap collapsedDirectoryKeys: ReadonlySet @@ -50,6 +52,7 @@ export function CombinedDiffFileTreeRows({ node={node} mode={mode} worktreePath={worktreePath} + sourceWorkspaceId={sourceWorkspaceId} activeSectionKey={activeSectionKey} sectionIndexByKey={sectionIndexByKey} isCollapsed={collapsedDirectoryKeys.has(node.key)} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 304aca3d19c..6c19b55d527 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -36,6 +36,7 @@ const EMPTY_TREE_ROWS: CombinedDiffTreeNode[] = [] export function CombinedDiffFileTree({ mode, worktreePath, + sourceWorkspaceId, entries, sectionIndexByKey, activeSectionKey, @@ -46,6 +47,7 @@ export function CombinedDiffFileTree({ }: { mode: CombinedDiffFileTreeMode worktreePath: string + sourceWorkspaceId?: string entries: readonly CombinedDiffFileTreeEntry[] sectionIndexByKey: ReadonlyMap activeSectionKey: string | null @@ -200,6 +202,7 @@ export function CombinedDiffFileTree({ const sharedRowProps = { mode, worktreePath, + sourceWorkspaceId, activeSectionKey, sectionIndexByKey, collapsedDirectoryKeys, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 4833f89fc94..2f0688dfede 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -33,6 +33,7 @@ import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-sen import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection' +import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop' export type { NativeChatComposerHandle, @@ -173,6 +174,13 @@ const NativeChatComposerPane = forwardRef attachment.pending) @@ -413,6 +421,7 @@ const NativeChatComposerPane = forwardRef removeImageAttachment(id)} onAttach={pickAttachment} + workspaceFileDropHandlers={workspaceFileDropHandlers} onDictationToggle={toggleDictation} onDictationHoldStart={startHoldDictation} onDictationHoldEnd={stopHoldDictation} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..5ea20e3d0cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,6 +1,11 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor' import type { NativeChatComposerInput } from './native-chat-composer-input' -import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import type { + ClipboardEventHandler, + DragEventHandler, + KeyboardEventHandler, + RefObject +} from 'react' import { useLayoutEffect, useRef } from 'react' import { ImageOff } from 'lucide-react' import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' @@ -48,6 +53,10 @@ export type NativeChatComposerFieldProps = { onAcceptMention: () => void onRemoveImageAttachment: (id: string) => void onAttach: () => void + workspaceFileDropHandlers?: { + onDragOverCapture: DragEventHandler + onDropCapture: DragEventHandler + } onDictationToggle: () => void onDictationHoldStart: () => void onDictationHoldEnd: () => void @@ -120,6 +129,7 @@ export function NativeChatComposerField({ onAcceptMention, onRemoveImageAttachment, onAttach, + workspaceFileDropHandlers, onDictationToggle, onDictationHoldStart, onDictationHoldEnd, @@ -185,6 +195,7 @@ export function NativeChatComposerField({ ) : null}
{ }) }) + it('reports not-ready instead of throwing when the SSH generation is gone', () => { + expect( + resolveNativeChatAttachmentOwner( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map() + }), + 'tab-1' + ) + ).toEqual({ kind: 'not-ready' }) + }) + it('reports not-ready when an SSH worktree has no known path yet', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 8157a5190ff..b7564ba7d9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -82,11 +82,17 @@ export function resolveNativeChatAttachmentOwnerForWorktree( if (!worktreePath) { return { kind: 'not-ready' } } - return { - kind: 'ssh', - connectionId, - worktreePath, - ...captureDirectSshMutationExpectation(state, connectionId) + try { + return { + kind: 'ssh', + connectionId, + worktreePath, + ...captureDirectSshMutationExpectation(state, connectionId) + } + } catch { + // The connection's generation is gone (disconnect mid-attach). That is an + // unknown owner, not a reason to throw out of the drop/IME handler. + return { kind: 'not-ready' } } } @@ -97,6 +103,20 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatAttachmentOwnerChangedNotice(): string { + return translate( + 'components.native-chat.composer.attachmentOwnerChanged', + 'This workspace changed hosts while attaching — drop the files again.' + ) +} + +export function nativeChatAttachmentUnreadableNotice(): string { + return translate( + 'components.native-chat.composer.attachmentUnreadable', + "Couldn't read the dropped files." + ) +} + export function nativeChatLocalAttachmentUnsupportedNotice(): string { return translate( 'components.native-chat.composer.localAttachmentUnsupported', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index e535a59fad0..c7c7dec050c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, screen } from '@testing-library/react' -import { useRef } from 'react' +import { useRef, useState } from 'react' +import type * as AttachmentUploadModule from './native-chat-attachment-upload' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import { resetLocalImageSrcStateForTests } from '../editor/useLocalImageSrc' @@ -30,7 +33,9 @@ const intake = vi.hoisted(() => ({ upload: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) -vi.mock('./native-chat-attachment-upload', () => ({ +// Keeps the real notice strings so the silent-failure guards assert what users see. +vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ + ...(await importOriginal()), resolveNativeChatAttachmentOwner: () => intake.owner, uploadNativeChatAttachmentPaths: intake.upload })) @@ -49,7 +54,8 @@ import { // Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { - const textareaRef = useRef(null) + const textareaRef = useRef(null) + const [notice, setNotice] = useState(null) const attachments = useNativeChatComposerAttachments({ attachmentScopeKey: pane, allowWithoutTarget: true, @@ -60,22 +66,28 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea textareaRef, setCaret: () => {}, setDraft: () => {}, - setNotice: () => {} + setNotice }) const { attachExternalPaths } = useNativeChatExternalAttachments({ terminalTabId: pane, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, - setNotice: () => {} + setNotice }) useNativeChatFileAttachmentActions(pane, attachExternalPaths) return (