mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Let desktop users attach files by dropping them into the composer
A user-requested drag target should reuse the existing attachment pipeline instead of creating a second upload path. The composer now handles browser DataTransfer drops and Tauri native drag-drop events, while keeping desktop attachments path-only so large files are not serialized into chat payloads. The fallback /goal metadata is also aligned with the existing desktop command surface because the desktop gate exercises that menu while validating composer behavior. Constraint: Desktop attachments must remain path-only to avoid inflated websocket payloads Rejected: Read dropped desktop files with FileReader | would reintroduce large inline data payloads Confidence: high Scope-risk: moderate Directive: Do not replace the Tauri drag-drop path conversion with data URLs without rerunning payload-size regression coverage Tested: cd desktop && bun run test -- --run src/lib/composerAttachments.test.ts src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: bun run check:desktop Tested: agent-browser overlay and dropped-chip smoke at http://127.0.0.1:1421/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456 Tested: bun run verify Not-tested: Finder-to-packaged-.app manual drag smoke
This commit is contained in:
parent
174c6757f2
commit
390c18d82c
@ -169,8 +169,8 @@ describe('Content-only pages render without errors', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findAllByText('/goal')).toHaveLength(2)
|
||||
expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
|
||||
expect(screen.getByText('Set a completion goal')).toBeInTheDocument()
|
||||
expect(screen.getByText('[status|pause|resume|complete|clear|--tokens <budget>|<objective>]')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({
|
||||
browse: vi.fn(),
|
||||
wsSend: vi.fn(),
|
||||
dialogOpen: vi.fn(),
|
||||
webviewDragHandlers: [] as Array<(event: { payload: unknown }) => void>,
|
||||
webviewUnlisten: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
@ -56,6 +58,15 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({
|
||||
open: mocks.dialogOpen,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/webview', () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => {
|
||||
mocks.webviewDragHandlers.push(handler)
|
||||
return mocks.webviewUnlisten
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMobileViewport', () => ({
|
||||
useMobileViewport: () => viewportMocks.isMobile,
|
||||
}))
|
||||
@ -118,6 +129,7 @@ describe('ChatInput file mentions', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.webviewDragHandlers.length = 0
|
||||
delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||
viewportMocks.isMobile = false
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
@ -665,6 +677,77 @@ describe('ChatInput file mentions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts native desktop file drops on the active session composer as path-only attachments', async () => {
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
|
||||
render(<ChatInput compact />)
|
||||
|
||||
const panel = screen.getByTestId('chat-input-panel')
|
||||
Object.defineProperty(panel, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 640,
|
||||
bottom: 180,
|
||||
width: 640,
|
||||
height: 180,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.webviewDragHandlers).toHaveLength(1)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mocks.webviewDragHandlers[0]?.({
|
||||
payload: { type: 'over', position: { x: 24, y: 24 } },
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId('chat-input-drop-overlay')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
mocks.webviewDragHandlers[0]?.({
|
||||
payload: {
|
||||
type: 'drop',
|
||||
position: { x: 24, y: 24 },
|
||||
paths: ['/Users/nanmi/drop/large-a.log'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByText('large-a.log')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-input-drop-overlay')).not.toBeInTheDocument()
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: 'analyze dropped file',
|
||||
selectionStart: 'analyze dropped file'.length,
|
||||
},
|
||||
})
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
|
||||
type: 'user_message',
|
||||
content: 'analyze dropped file',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
name: 'large-a.log',
|
||||
path: '/Users/nanmi/drop/large-a.log',
|
||||
data: undefined,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses larger icon-only mobile action buttons for browser H5 access', async () => {
|
||||
viewportMocks.isMobile = true
|
||||
mocks.search.mockResolvedValueOnce({
|
||||
|
||||
@ -17,6 +17,7 @@ import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../controls/ModelSelector'
|
||||
import type { AttachmentRef } from '../../types/chat'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
import { ComposerDropOverlay } from './ComposerDropOverlay'
|
||||
import { ProjectContextChip } from '../shared/ProjectContextChip'
|
||||
import { RepositoryLaunchControls } from '../shared/RepositoryLaunchControls'
|
||||
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
||||
@ -37,6 +38,7 @@ import {
|
||||
selectNativeFileAttachments,
|
||||
type ComposerAttachment,
|
||||
} from '../../lib/composerAttachments'
|
||||
import { useComposerFileDrop } from './useComposerFileDrop'
|
||||
|
||||
type GitInfo = SessionGitInfo
|
||||
|
||||
@ -88,6 +90,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const [launchTransitioning, setLaunchTransitioning] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
@ -707,6 +710,20 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
})
|
||||
}, [setComposerAttachments])
|
||||
|
||||
const appendAttachments = useCallback((nextAttachments: Attachment[]) => {
|
||||
if (nextAttachments.length === 0) return
|
||||
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
||||
}, [setComposerAttachments])
|
||||
|
||||
const { isDragActive, dragHandlers } = useComposerFileDrop({
|
||||
disabled: isMemberSession || isWorkspaceMissing,
|
||||
panelRef,
|
||||
onAttachments: appendAttachments,
|
||||
onError: (error) => {
|
||||
console.warn('[attachments] Failed to read dropped files', error)
|
||||
},
|
||||
})
|
||||
|
||||
const openAttachmentPicker = useCallback(() => {
|
||||
if (!isTauriRuntime()) {
|
||||
fileInputRef.current?.click()
|
||||
@ -736,15 +753,6 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (isMemberSession) return
|
||||
const files = event.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
appendFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setComposerAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||
if (activeTabId) removeWorkspaceReference(activeTabId, id)
|
||||
@ -798,15 +806,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="chat-input-panel"
|
||||
className={isHeroComposer
|
||||
? `glass-panel relative flex flex-col gap-3 ${embedLaunchControlsInHero ? 'rounded-xl' : 'rounded-t-xl rounded-b-none'} p-4 transition-colors`
|
||||
? `glass-panel relative flex flex-col gap-3 overflow-hidden ${embedLaunchControlsInHero ? 'rounded-xl' : 'rounded-t-xl rounded-b-none'} p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
|
||||
: compact
|
||||
? `glass-panel relative p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'}`
|
||||
: `glass-panel relative transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
? `glass-panel relative overflow-hidden p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'} ${isDragActive ? 'composer-drop-target-active' : ''}`
|
||||
: `glass-panel relative overflow-hidden transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{isDragActive && (
|
||||
<ComposerDropOverlay
|
||||
testId="chat-input-drop-overlay"
|
||||
title={t('chat.dropFilesTitle')}
|
||||
description={t('chat.dropFilesHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMemberSession && fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
ref={fileSearchRef}
|
||||
|
||||
25
desktop/src/components/chat/ComposerDropOverlay.tsx
Normal file
25
desktop/src/components/chat/ComposerDropOverlay.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
type ComposerDropOverlayProps = {
|
||||
title: string
|
||||
description: string
|
||||
testId: string
|
||||
}
|
||||
|
||||
export function ComposerDropOverlay({ title, description, testId }: ComposerDropOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="composer-drop-overlay pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-[inherit] border border-[var(--color-brand)]/45 bg-[var(--color-surface-container-lowest)]/88 p-4 backdrop-blur-[2px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="flex max-w-[280px] items-center gap-3 rounded-[10px] border border-[var(--color-brand)]/30 bg-[var(--color-surface-container-low)] px-4 py-3 text-left shadow-[var(--shadow-dropdown)]">
|
||||
<span className="material-symbols-outlined flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand)]/12 text-[20px] text-[var(--color-brand)]">
|
||||
upload_file
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold leading-5 text-[var(--color-text-primary)]">{title}</span>
|
||||
<span className="block text-xs leading-5 text-[var(--color-text-tertiary)]">{description}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -75,8 +75,8 @@ describe('composerUtils', () => {
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: 'goal',
|
||||
description: 'Set a completion goal',
|
||||
argumentHint: '[<condition> | clear]',
|
||||
description: 'Create or manage an autonomous completion goal',
|
||||
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
|
||||
},
|
||||
]),
|
||||
)
|
||||
@ -89,8 +89,8 @@ describe('composerUtils', () => {
|
||||
commands.map((command) => command.name),
|
||||
).toEqual(['goal'])
|
||||
expect(commands[0]).toMatchObject({
|
||||
description: 'Set a completion goal',
|
||||
argumentHint: '[<condition> | clear]',
|
||||
description: 'Create or manage an autonomous completion goal',
|
||||
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
|
||||
})
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status')
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')
|
||||
|
||||
@ -26,8 +26,8 @@ export const FALLBACK_SLASH_COMMANDS = [
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{
|
||||
name: 'goal',
|
||||
description: 'Set a completion goal',
|
||||
argumentHint: '[<condition> | clear]',
|
||||
description: 'Create or manage an autonomous completion goal',
|
||||
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
|
||||
},
|
||||
{ name: 'review', description: 'Review code changes' },
|
||||
{ name: 'commit', description: 'Create a git commit' },
|
||||
|
||||
163
desktop/src/components/chat/useComposerFileDrop.ts
Normal file
163
desktop/src/components/chat/useComposerFileDrop.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
|
||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||
import {
|
||||
dataTransferHasFiles,
|
||||
dataTransferToComposerAttachments,
|
||||
pathsToComposerAttachments,
|
||||
type ComposerAttachment,
|
||||
} from '../../lib/composerAttachments'
|
||||
|
||||
type TauriDropPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type TauriDragDropPayload =
|
||||
| { type: 'enter'; paths: string[]; position: TauriDropPosition }
|
||||
| { type: 'over'; position: TauriDropPosition }
|
||||
| { type: 'drop'; paths: string[]; position: TauriDropPosition }
|
||||
| { type: 'leave' }
|
||||
| { type: 'cancel' }
|
||||
|
||||
type TauriDragDropEvent = {
|
||||
payload: TauriDragDropPayload
|
||||
}
|
||||
|
||||
type UseComposerFileDropOptions = {
|
||||
disabled?: boolean
|
||||
panelRef: RefObject<HTMLElement | null>
|
||||
onAttachments: (attachments: ComposerAttachment[]) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
function isPointInsideElement(element: HTMLElement | null, position: TauriDropPosition): boolean {
|
||||
if (!element) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
position.x >= rect.left &&
|
||||
position.x <= rect.right &&
|
||||
position.y >= rect.top &&
|
||||
position.y <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function useComposerFileDrop({
|
||||
disabled = false,
|
||||
panelRef,
|
||||
onAttachments,
|
||||
onError,
|
||||
}: UseComposerFileDropOptions) {
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const dragDepthRef = useRef(0)
|
||||
const disabledRef = useRef(disabled)
|
||||
const onAttachmentsRef = useRef(onAttachments)
|
||||
const onErrorRef = useRef(onError)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
useEffect(() => {
|
||||
onAttachmentsRef.current = onAttachments
|
||||
}, [onAttachments])
|
||||
|
||||
useEffect(() => {
|
||||
onErrorRef.current = onError
|
||||
}, [onError])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriRuntime()) return
|
||||
|
||||
let disposed = false
|
||||
let unlisten: (() => void) | undefined
|
||||
|
||||
void import('@tauri-apps/api/webview')
|
||||
.then(({ getCurrentWebview }) =>
|
||||
getCurrentWebview().onDragDropEvent((event) => {
|
||||
if (disposed) return
|
||||
|
||||
const payload = event.payload as TauriDragDropEvent['payload']
|
||||
if (payload.type === 'cancel' || payload.type === 'leave') {
|
||||
dragDepthRef.current = 0
|
||||
setIsDragActive(false)
|
||||
return
|
||||
}
|
||||
|
||||
const isInside = isPointInsideElement(panelRef.current, payload.position)
|
||||
if (payload.type === 'enter' || payload.type === 'over') {
|
||||
setIsDragActive(!disabledRef.current && isInside)
|
||||
return
|
||||
}
|
||||
|
||||
dragDepthRef.current = 0
|
||||
setIsDragActive(false)
|
||||
if (disabledRef.current || !isInside) return
|
||||
|
||||
const attachments = pathsToComposerAttachments(payload.paths)
|
||||
if (attachments.length > 0) onAttachmentsRef.current(attachments)
|
||||
}),
|
||||
)
|
||||
.then((nextUnlisten) => {
|
||||
if (disposed) {
|
||||
nextUnlisten()
|
||||
return
|
||||
}
|
||||
unlisten = nextUnlisten
|
||||
})
|
||||
.catch((error) => {
|
||||
onErrorRef.current?.(error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unlisten?.()
|
||||
}
|
||||
}, [panelRef])
|
||||
|
||||
const onDragEnter = useCallback((event: DragEvent) => {
|
||||
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
dragDepthRef.current += 1
|
||||
setIsDragActive(true)
|
||||
}, [disabled])
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
setIsDragActive(true)
|
||||
}, [disabled])
|
||||
|
||||
const onDragLeave = useCallback((event: DragEvent) => {
|
||||
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setIsDragActive(false)
|
||||
}, [disabled])
|
||||
|
||||
const onDrop = useCallback((event: DragEvent) => {
|
||||
if (disabled || !dataTransferHasFiles(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setIsDragActive(false)
|
||||
|
||||
void dataTransferToComposerAttachments(event.dataTransfer)
|
||||
.then((attachments) => {
|
||||
if (attachments.length > 0) onAttachments(attachments)
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error)
|
||||
})
|
||||
}, [disabled, onAttachments, onError])
|
||||
|
||||
return {
|
||||
isDragActive,
|
||||
dragHandlers: {
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -882,6 +882,8 @@ export const en = {
|
||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
|
||||
'chat.addFiles': 'Add files or photos',
|
||||
'chat.dropFilesTitle': 'Drop files here',
|
||||
'chat.dropFilesHint': 'Attached as file paths.',
|
||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||
'chat.contextReferencesOnly': 'Added {count} references',
|
||||
'chat.addSelectionToChat': 'Add to chat',
|
||||
|
||||
@ -884,6 +884,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
|
||||
'chat.addFiles': '添加文件或图片',
|
||||
'chat.dropFilesTitle': '松手添加文件',
|
||||
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||
'chat.addSelectionToChat': '添加至对话',
|
||||
|
||||
@ -33,6 +33,21 @@ export function pathToComposerAttachment(filePath: string): ComposerAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
export function pathsToComposerAttachments(filePaths: string[]): ComposerAttachment[] {
|
||||
return filePaths
|
||||
.filter((filePath) => typeof filePath === 'string' && filePath.length > 0)
|
||||
.map(pathToComposerAttachment)
|
||||
}
|
||||
|
||||
export function dataTransferHasFiles(dataTransfer: DataTransfer): boolean {
|
||||
const types = Array.from(dataTransfer.types ?? [])
|
||||
return types.includes('Files') || dataTransfer.files.length > 0
|
||||
}
|
||||
|
||||
export async function dataTransferToComposerAttachments(dataTransfer: DataTransfer): Promise<ComposerAttachment[]> {
|
||||
return filesToComposerAttachments(dataTransfer.files)
|
||||
}
|
||||
|
||||
export async function selectNativeFileAttachments(): Promise<ComposerAttachment[] | null> {
|
||||
if (!isTauriRuntime()) return null
|
||||
|
||||
@ -43,7 +58,7 @@ export async function selectNativeFileAttachments(): Promise<ComposerAttachment[
|
||||
directory: false,
|
||||
})
|
||||
const paths = normalizeDialogSelection(selected)
|
||||
return paths.map(pathToComposerAttachment)
|
||||
return pathsToComposerAttachments(paths)
|
||||
} catch (error) {
|
||||
console.warn('[attachments] Native file picker failed; falling back to browser file input', error)
|
||||
return null
|
||||
|
||||
@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({
|
||||
wsSend: vi.fn(),
|
||||
wsDisconnect: vi.fn(),
|
||||
dialogOpen: vi.fn(),
|
||||
webviewDragHandlers: [] as Array<(event: { payload: unknown }) => void>,
|
||||
webviewUnlisten: vi.fn(),
|
||||
isMobile: false,
|
||||
isTauriRuntime: false,
|
||||
}))
|
||||
@ -66,6 +68,15 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({
|
||||
open: mocks.dialogOpen,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/webview', () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: vi.fn(async (handler: (event: { payload: unknown }) => void) => {
|
||||
mocks.webviewDragHandlers.push(handler)
|
||||
return mocks.webviewUnlisten
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../components/shared/DirectoryPicker', () => ({
|
||||
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
|
||||
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
|
||||
@ -151,6 +162,7 @@ describe('EmptySession', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.webviewDragHandlers.length = 0
|
||||
mocks.isMobile = false
|
||||
mocks.isTauriRuntime = false
|
||||
useSettingsStore.setState({ locale: 'en', activeProviderName: null })
|
||||
@ -431,6 +443,52 @@ describe('EmptySession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a drop affordance and sends dropped desktop files as path attachments', async () => {
|
||||
mocks.isTauriRuntime = true
|
||||
const droppedFile = new File(['large file'], 'ignored-name.log', { type: 'text/plain' })
|
||||
Object.defineProperty(droppedFile, 'path', {
|
||||
configurable: true,
|
||||
value: '/Users/nanmi/drop/session-context.log',
|
||||
})
|
||||
const dataTransfer = {
|
||||
types: ['Files'],
|
||||
files: [droppedFile],
|
||||
dropEffect: '',
|
||||
}
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
const panel = screen.getByTestId('empty-session-composer-panel')
|
||||
fireEvent.dragEnter(panel, { dataTransfer })
|
||||
expect(screen.getByTestId('empty-session-drop-overlay')).toBeInTheDocument()
|
||||
|
||||
fireEvent.drop(panel, { dataTransfer })
|
||||
|
||||
expect(await screen.findByText('session-context.log')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-session-drop-overlay')).not.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({})
|
||||
})
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||
type: 'user_message',
|
||||
content: 'use this context',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
name: 'session-context.log',
|
||||
path: '/Users/nanmi/drop/session-context.log',
|
||||
data: undefined,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('starts in a selected non-Git project without showing a repository warning', async () => {
|
||||
mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext())
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchC
|
||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
||||
import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay'
|
||||
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
|
||||
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
|
||||
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
|
||||
@ -23,6 +24,7 @@ import {
|
||||
selectNativeFileAttachments,
|
||||
type ComposerAttachment,
|
||||
} from '../lib/composerAttachments'
|
||||
import { useComposerFileDrop } from '../components/chat/useComposerFileDrop'
|
||||
import {
|
||||
FALLBACK_SLASH_COMMANDS,
|
||||
filterSlashCommands,
|
||||
@ -94,6 +96,7 @@ export function EmptySession() {
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
@ -446,6 +449,19 @@ export function EmptySession() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const appendAttachments = useCallback((nextAttachments: Attachment[]) => {
|
||||
if (nextAttachments.length === 0) return
|
||||
setAttachments((prev) => [...prev, ...nextAttachments])
|
||||
}, [])
|
||||
|
||||
const { isDragActive, dragHandlers } = useComposerFileDrop({
|
||||
panelRef,
|
||||
onAttachments: appendAttachments,
|
||||
onError: (error) => {
|
||||
console.warn('[attachments] Failed to read dropped files', error)
|
||||
},
|
||||
})
|
||||
|
||||
const openAttachmentPicker = useCallback(() => {
|
||||
if (!isTauriRuntime()) {
|
||||
fileInputRef.current?.click()
|
||||
@ -474,14 +490,6 @@ export function EmptySession() {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
const files = event.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
appendFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||
}
|
||||
@ -556,13 +564,21 @@ export function EmptySession() {
|
||||
>
|
||||
<div className={`flex w-full flex-col ${isMobileComposer ? 'max-w-none' : 'max-w-3xl'}`}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="empty-session-composer-panel"
|
||||
className={`glass-panel relative flex flex-col gap-3 ${
|
||||
className={`glass-panel relative flex flex-col gap-3 overflow-hidden ${
|
||||
isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-0'
|
||||
}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
} ${isDragActive ? 'composer-drop-target-active' : ''}`}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{isDragActive && (
|
||||
<ComposerDropOverlay
|
||||
testId="empty-session-drop-overlay"
|
||||
title={t('chat.dropFilesTitle')}
|
||||
description={t('chat.dropFilesHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={isMobileComposer ? 'contents' : 'flex flex-col gap-3 p-4'}>
|
||||
{fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
|
||||
@ -965,6 +965,21 @@ button, input, textarea, select, a, [role="button"] {
|
||||
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
|
||||
}
|
||||
|
||||
.composer-drop-target-active {
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown);
|
||||
}
|
||||
|
||||
.composer-drop-overlay {
|
||||
animation: composer-drop-fade 140ms ease-out, composer-drop-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.composer-drop-overlay {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell-viewport {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
@ -1228,6 +1243,14 @@ button, input, textarea, select, a, [role="button"] {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes composer-drop-fade {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes composer-drop-pulse {
|
||||
0%, 100% { opacity: 0.96; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.animate-shimmer { animation: shimmer 1.5s ease-in-out infinite; }
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
.animate-pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user