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:
程序员阿江(Relakkes) 2026-05-16 13:18:03 +08:00
parent 174c6757f2
commit 390c18d82c
13 changed files with 438 additions and 35 deletions

View File

@ -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()
})

View File

@ -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({

View File

@ -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}

View 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>
)
}

View File

@ -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')

View File

@ -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' },

View 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,
},
}
}

View File

@ -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',

View File

@ -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': '添加至对话',

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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; }