fix(desktop): support pasted file attachments #1086

This commit is contained in:
程序员阿江(Relakkes) 2026-07-24 05:30:24 +08:00
parent 8e9c104514
commit c2774fc170
11 changed files with 249 additions and 70 deletions

View File

@ -1,9 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { createElectronHost } from '../src/lib/desktopHost/electronHost'
import type { DesktopHostUnlisten } from '../src/lib/desktopHost/types'
import type { ElectronEventChannel, ElectronIpcChannel } from './ipc/channels'
const electronHost = createElectronHost({
getPathForFile(file) {
return webUtils.getPathForFile(file)
},
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
return ipcRenderer.invoke(channel, payload) as Promise<T>
},

View File

@ -1292,7 +1292,53 @@ describe('ChatInput file mentions', () => {
})
})
it('ignores pasted images that finish loading after the prompt was sent', async () => {
it('pastes copied desktop files into the active session as path-only attachments', async () => {
installElectronFileHost()
const copiedFile = new File(['# Project notes'], 'ignored-name.md', { type: 'text/markdown' })
Object.defineProperty(copiedFile, 'path', {
configurable: true,
value: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
})
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.paste(input, {
clipboardData: {
files: [],
items: [{
kind: 'file',
type: 'text/markdown',
getAsFile: () => copiedFile,
}],
},
})
expect(await screen.findByText('project-notes.md')).toBeInTheDocument()
fireEvent.change(input, {
target: {
value: 'review this document',
selectionStart: 'review this document'.length,
},
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'review this document',
attachments: [
expect.objectContaining({
type: 'file',
name: 'project-notes.md',
path: 'C:\\Users\\Nanmi\\Desktop\\project-notes.md',
data: undefined,
}),
],
})
})
it('ignores pasted files that finish loading after the prompt was sent', async () => {
class DeferredFileReader {
result: string | ArrayBuffer | null = null
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
@ -1311,7 +1357,9 @@ describe('ChatInput file mentions', () => {
fireEvent.paste(input, {
clipboardData: {
files: [],
items: [{
kind: 'file',
type: 'image/png',
getAsFile: () => file,
}],

View File

@ -38,6 +38,7 @@ import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import {
filesToComposerAttachments,
getDataTransferFiles,
selectNativeFileAttachments,
type ComposerAttachment,
} from '../../lib/composerAttachments'
@ -835,42 +836,22 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const handlePaste = (event: React.ClipboardEvent) => {
if (isMemberSession) return
const items = event.clipboardData?.items
if (!items) return
const files = getDataTransferFiles(event.clipboardData)
if (files.length === 0) return
let hasImage = false
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || !item.type.startsWith('image/')) continue
hasImage = true
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const pasteGeneration = pasteGenerationRef.current
const pastedSessionId = activeTabId
const reader = new FileReader()
reader.onload = () => {
event.preventDefault()
const pasteGeneration = pasteGenerationRef.current
const pastedSessionId = activeTabId
void filesToComposerAttachments(files)
.then((nextAttachments) => {
if (pasteGeneration !== pasteGenerationRef.current) return
if (pastedSessionId !== useTabStore.getState().activeTabId) return
setComposerAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || 'image/png',
previewUrl: reader.result as string,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
}
if (!hasImage) return
if (nextAttachments.length === 0) return
setComposerAttachments((prev) => [...prev, ...nextAttachments])
})
.catch((error) => {
console.warn('[attachments] Failed to read pasted files', error)
})
}
const appendFiles = useCallback((files: FileList | File[]) => {

View File

@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { browserHost } from './desktopHost/browserHost'
import { pathToComposerAttachment, selectNativeFileAttachments } from './composerAttachments'
import {
filesToComposerAttachments,
getDataTransferFiles,
pathToComposerAttachment,
selectNativeFileAttachments,
} from './composerAttachments'
describe('composer attachment payloads', () => {
afterEach(() => {
@ -61,4 +66,61 @@ describe('composer attachment payloads', () => {
'/workspace/b.log',
])
})
it('keeps pasted desktop files as native path attachments across common file types', async () => {
const nativePaths = new Map<File, string>()
const files = [
new File(['# Notes'], 'notes.md', { type: 'text/markdown' }),
new File(['pdf'], 'brief.pdf', { type: 'application/pdf' }),
new File(['docx'], 'proposal.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}),
]
nativePaths.set(files[0]!, 'C:\\Users\\Nanmi\\Desktop\\notes.md')
nativePaths.set(files[1]!, 'C:\\Users\\Nanmi\\Desktop\\brief.pdf')
nativePaths.set(files[2]!, 'C:\\Users\\Nanmi\\Desktop\\proposal.docx')
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
files: {
getPathForFile: file => nativePaths.get(file) ?? '',
},
}
const attachments = await filesToComposerAttachments(files)
expect(attachments.map(({ name, path, data }) => ({ name, path, data }))).toEqual([
{ name: 'notes.md', path: 'C:\\Users\\Nanmi\\Desktop\\notes.md', data: undefined },
{ name: 'brief.pdf', path: 'C:\\Users\\Nanmi\\Desktop\\brief.pdf', data: undefined },
{ name: 'proposal.docx', path: 'C:\\Users\\Nanmi\\Desktop\\proposal.docx', data: undefined },
])
})
it('reads clipboard files from DataTransfer items when the files list is empty', () => {
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
const dataTransfer = {
files: [],
items: [
{ kind: 'string', getAsFile: () => null },
{ kind: 'file', getAsFile: () => markdown },
],
} as unknown as DataTransfer
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown])
})
it('keeps every clipboard item when the browser files list is incomplete', () => {
const markdown = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
const spreadsheet = new File(['name,total'], 'budget.csv', { type: 'text/csv' })
const dataTransfer = {
files: [markdown],
items: [
{ kind: 'file', getAsFile: () => markdown },
{ kind: 'file', getAsFile: () => spreadsheet },
],
} as unknown as DataTransfer
expect(getDataTransferFiles(dataTransfer)).toEqual([markdown, spreadsheet])
})
})

View File

@ -44,11 +44,26 @@ export function pathsToComposerAttachments(filePaths: string[]): ComposerAttachm
export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean {
const types = Array.from(dataTransfer.types ?? [])
return types.includes('Files') || dataTransfer.files.length > 0
const items = Array.from(dataTransfer.items ?? [])
return (
types.includes('Files') ||
dataTransfer.files.length > 0 ||
items.some((item) => item.kind === 'file')
)
}
export function getDataTransferFiles(dataTransfer: DataTransfer): File[] {
const files = Array.from(dataTransfer.files ?? [])
const itemFiles = Array.from(dataTransfer.items ?? []).flatMap((item) => {
if (item.kind !== 'file') return []
const file = item.getAsFile()
return file ? [file] : []
})
return itemFiles.length > files.length ? itemFiles : files
}
export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise<ComposerAttachment[]> {
return filesToComposerAttachments(dataTransfer.files)
return filesToComposerAttachments(getDataTransferFiles(dataTransfer))
}
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
@ -81,8 +96,8 @@ function normalizeDialogSelection(selected: string | string[] | null): string[]
}
function getNativeFilePath(file: File): string | undefined {
const path = (file as File & { path?: unknown }).path
return typeof path === 'string' && path.length > 0 ? path : undefined
const path = getDesktopHost().files.getPathForFile(file)
return path.length > 0 ? path : undefined
}
async function fileToComposerAttachment(file: File): Promise<ComposerAttachment | null> {

View File

@ -72,6 +72,12 @@ export const browserHost: DesktopHost = {
unsupported('Writing clipboard text')
},
},
files: {
getPathForFile(file) {
const legacyPath = (file as File & { path?: unknown }).path
return typeof legacyPath === 'string' ? legacyPath : ''
},
},
events: {
async listen() {
return noopUnlisten

View File

@ -37,6 +37,19 @@ describe('electron desktop host', () => {
expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard')
})
it('resolves native paths for renderer File objects through the preload bridge', () => {
const file = new File(['# Notes'], 'notes.md', { type: 'text/markdown' })
const getPathForFile = vi.fn().mockReturnValue('C:\\Users\\Nanmi\\Desktop\\notes.md')
const host = createElectronHost({
getPathForFile,
invoke: vi.fn(),
subscribe: vi.fn(),
})
expect(host.files.getPathForFile(file)).toBe('C:\\Users\\Nanmi\\Desktop\\notes.md')
expect(getPathForFile).toHaveBeenCalledWith(file)
})
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
const invoke = vi.fn()
const host = createElectronHost({

View File

@ -14,6 +14,7 @@ import { validateElectronIpcPayload } from '../../../electron/ipc/capabilities'
export type ElectronHostBridge = {
invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T>
getPathForFile?(file: File): string
subscribe<T>(
channel: ElectronEventChannel,
handler: (payload: T) => void,
@ -87,6 +88,14 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText),
writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text),
},
files: {
getPathForFile(file) {
const nativePath = bridge.getPathForFile?.(file)
if (nativePath) return nativePath
const legacyPath = (file as File & { path?: unknown }).path
return typeof legacyPath === 'string' ? legacyPath : ''
},
},
events: {
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
},

View File

@ -212,6 +212,9 @@ export type DesktopHost = {
readText(): Promise<string>
writeText(text: string): Promise<void>
}
files: {
getPathForFile(file: File): string
}
events: {
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
}

View File

@ -149,6 +149,7 @@ import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { usePluginStore } from '../stores/pluginStore'
import type { RepositoryContextResult } from '../api/sessions'
import { browserHost } from '../lib/desktopHost/browserHost'
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
return {
@ -725,6 +726,62 @@ describe('EmptySession', () => {
})
})
it('pastes copied desktop files into a new-session draft as path attachments', async () => {
mocks.isTauriRuntime = true
const copiedFile = new File(['{\"name\":\"cc-haha\"}'], 'ignored-name.json', {
type: 'application/json',
})
Object.defineProperty(copiedFile, 'path', {
configurable: true,
value: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
})
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
webview: {
...browserHost.webview,
onDragDropEvent: vi.fn().mockResolvedValue(mocks.webviewUnlisten),
},
}
render(<EmptySession />)
fireEvent.paste(screen.getByRole('textbox'), {
clipboardData: {
files: [],
items: [{
kind: 'file',
type: 'application/json',
getAsFile: () => copiedFile,
}],
},
})
expect(await screen.findByText('project-context.json')).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'use this context', selectionStart: 'use this context'.length },
})
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
await waitFor(() => {
expect(mocks.createSession).toHaveBeenCalledWith({ permissionMode: 'default' })
})
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
type: 'user_message',
content: 'use this context',
attachments: [
expect.objectContaining({
type: 'file',
name: 'project-context.json',
path: 'C:\\Users\\Nanmi\\Desktop\\project-context.json',
data: undefined,
}),
],
})
})
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',

View File

@ -25,6 +25,7 @@ import { publicAssetPath } from '../lib/publicAsset'
import { resolveActiveProviderRuntimeSelection } from '../lib/runtimeSelection'
import {
filesToComposerAttachments,
getDataTransferFiles,
selectNativeFileAttachments,
type ComposerAttachment,
} from '../lib/composerAttachments'
@ -472,37 +473,18 @@ export function EmptySession() {
}
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
const files = getDataTransferFiles(event.clipboardData)
if (files.length === 0) return
let hasImage = false
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || !item.type.startsWith('image/')) continue
hasImage = true
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || undefined,
previewUrl: reader.result as string,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
}
if (!hasImage) return
event.preventDefault()
void filesToComposerAttachments(files)
.then((nextAttachments) => {
if (nextAttachments.length === 0) return
setAttachments((prev) => [...prev, ...nextAttachments])
})
.catch((error) => {
console.warn('[attachments] Failed to read pasted files', error)
})
}
const appendFiles = useCallback((files: FileList | File[]) => {