fix(desktop): stage dropped files into the remote session workspace

Finder/OS drops became `@file:/Users/...` refs that only resolve when the
gateway shares the local disk, so on a remote gateway non-image files
(PDF/CSV/Markdown/...) never reached the agent. Route OS drops through the
file.attach / image.attach_bytes upload pipeline — in-app project-tree and
gutter drags stay inline workspace-relative refs — across every drop surface:
the conversation area, the composer form, the contenteditable input, and the
message-edit composer (which still reproduced the bug).

Also:
- upload dropped files eagerly when a session exists, so the card shows a
  spinner instead of stalling the send (images stay submit-time to avoid
  racing their thumbnail write);
- round the attachment card and drop the monospace detail;
- render image previews from the bytes we already hold, so a pasted/dropped
  screenshot shows its thumbnail and previews even when its only on-disk copy
  is a transient path (the data URL is not persisted to localStorage).

Supersedes #38615, #41203.

Co-authored-by: LeonSGP <154585401+LeonSGP43@users.noreply.github.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-06-09 16:50:08 -05:00
parent 02f878ec5a
commit 4906dcfc25
12 changed files with 584 additions and 142 deletions

View file

@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
// We already hold the image bytes (the card thumbnail) — render those
// directly so a screenshot/clipboard image previews even when its only
// on-disk copy is a transient path the renderer can't re-read.
const withBytes =
attachment.kind === 'image' && attachment.previewUrl
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<Icon className="size-3.5" />
</span>
)}
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
{detail}
</span>
)}

View file

@ -43,7 +43,7 @@ import {
import { $gatewayState, $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
@ -64,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
droppedFileInlineRef,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
@ -919,24 +919,25 @@ export function ChatBar({
return
}
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
// In-app drags (project tree / gutter) are workspace-relative paths the
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
// drops are absolute local paths a remote gateway can't read (and images
// need byte upload for vision), so route them through the upload pipeline.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, cwd)
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
return
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
if (osDrops.length) {
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
@ -956,11 +957,7 @@ export function ChatBar({
const candidates = extractDroppedFiles(event.dataTransfer)
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
if (!candidates.length) {
return
}
@ -968,9 +965,27 @@ export function ChatBar({
event.stopPropagation()
resetDragState()
if (insertInlineRefs(refs)) {
// Dropping straight onto the text box used to inline-ref *every* file —
// including OS/Finder drops, whose absolute local path a remote gateway
// can't read and whose image bytes never reached vision. Split by origin:
// in-app drags stay inline refs; OS drops go through the upload pipeline.
// (When no upload handler is wired, fall back to inline refs for all.)
const attach = onAttachDroppedItems
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (attach && osDrops.length) {
void Promise.resolve(attach(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const clearDraft = useCallback(() => {

View file

@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
* dropping any that carry no path. */
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
// through the upload pipeline (OS drop — absolute local path a remote gateway
// can't read, plus image bytes for vision).
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
describe('partitionDroppedFiles', () => {
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
const projectFile = inAppRef('src/index.ts')
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
expect(osDrops).toEqual([finderPdf])
expect(inAppRefs).toEqual([projectFile])
})
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
expect(osDrops).toEqual([screenshot])
expect(inAppRefs).toEqual([])
})
it('keeps gutter line-range drags inline (no File handle)', () => {
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
expect(osDrops).toEqual([])
expect(inAppRefs).toEqual([lineRef])
})
it('splits a mixed drop and preserves order within each group', () => {
const a = inAppRef('a.ts')
const b = osDrop('/abs/b.pdf')
const c = inAppRef('c.ts')
const d = osDrop('/abs/d.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
expect(inAppRefs).toEqual([a, c])
expect(osDrops).toEqual([b, d])
})
it('returns empty groups for an empty drop', () => {
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
})
})

View file

@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
function isImagePath(filePath: string): boolean {
export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
return result
}
/**
* Split dropped entries by origin. OS/Finder drops carry a native `File`
* handle; in-app drags (project tree, gutter line refs) are path-only.
*
* The distinction is load-bearing: an in-app path is workspace-relative and
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
* An OS drop is an absolute path on *this* machine the gateway can't read it
* in remote mode, and an image needs its bytes uploaded to get vision either
* way. So OS drops must go through the attachment/upload pipeline rather than
* leaking a local path into the prompt text.
*/
export function partitionDroppedFiles(candidates: DroppedFile[]): {
osDrops: DroppedFile[]
inAppRefs: DroppedFile[]
} {
const osDrops: DroppedFile[] = []
const inAppRefs: DroppedFile[] = []
for (const candidate of candidates) {
if (candidate.file) {
osDrops.push(candidate)
} else {
inAppRefs.push(candidate)
}
}
return { osDrops, inAppRefs }
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string

View file

@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@ -299,19 +299,25 @@ export function ChatView({
})
// Drop files anywhere in the conversation area, not just on the composer
// input — appending the same inline `@file:` ref chips the composer drop
// produces (vs. attachment cards) so both surfaces behave identically.
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
// drops carry absolute local paths that don't exist on a remote gateway (and
// images need byte upload for vision), so route them through the attachment
// pipeline — otherwise the local path leaks into the prompt verbatim.
const onDropFiles = useCallback(
(candidates: DroppedFile[]) => {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
.filter((ref): ref is string => Boolean(ref))
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd]
[currentCwd, onAttachDroppedItems]
)
// Dropping a sidebar session inserts an @session link the agent can resolve

View file

@ -446,7 +446,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })
@ -484,7 +486,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
if (state.loading) {
return <PageLoader label={t.preview.loading} />

View file

@ -1,11 +1,11 @@
import { cleanup, render } from '@testing-library/react'
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { $connection } from '@/store/session'
import type { ComposerAttachment } from '@/store/composer'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
@ -385,6 +385,49 @@ describe('usePromptActions file attachment sync', () => {
})
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
// has no bytes to send, so syncAttachments leaves it untouched and the ref
// reaches the gateway as-is — correct for workspace-relative refs.
//
// The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
// ref in remote mode) is fixed upstream at the DROP layer: OS drops now
// carry a path and route through the upload pipeline instead of becoming a
// path-less inline ref. See partitionDroppedFiles in use-composer-actions.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const pathlessRef: ComposerAttachment = {
id: 'file:devis',
kind: 'file',
label: 'DEVIS_signed.pdf',
// NOTE: no `path` field — only the pre-baked local @file: ref.
refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
expect(ok).toBe(true)
// No path → no file.attach, no byte read: the ref passes through unchanged.
expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
@ -518,3 +561,89 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
})
describe('usePromptActions eager attachment upload (drop-time)', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$connection.set(null)
$composerAttachments.set([])
})
it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
// A Finder drop adds a chip with a local path but no attachedSessionId. With
// a session already open, the hook should stage it right away — so the send
// is instant and the card can show a spinner while bytes upload — instead of
// waiting for submit.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
}
return {} as never
})
$composerAttachments.set([
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(calls).toContain('file.attach'))
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
const chip = $composerAttachments.get()[0]!
expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
expect(chip.uploadState).toBeUndefined()
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
})
it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'file.attach') {
throw new Error('[Errno 13] Permission denied')
}
return {} as never
})
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
})
it('does not eagerly re-upload a chip already attached to this session', async () => {
$connection.set({ mode: 'remote' } as never)
const requestGateway = vi.fn(async () => ({}) as never)
$composerAttachments.set([
{
id: 'file:done',
kind: 'file',
label: 'done.pdf',
path: '/abs/done.pdf',
refText: '@file:data/done.pdf',
attachedSessionId: RUNTIME_SESSION_ID
}
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await Promise.resolve()
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
})
})

View file

@ -1,5 +1,6 @@
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
@ -27,6 +28,7 @@ import {
addComposerAttachment,
clearComposerAttachments,
type ComposerAttachment,
setComposerAttachmentUploadState,
terminalContextBlocksFromDraft
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@ -118,6 +120,87 @@ async function readFileDataUrlForAttach(filePath: string): Promise<string | null
return dataUrl || null
}
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
const payload = await readImageForRemoteAttach(path)
if (!payload) {
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
path,
session_id: sessionId
})
}
if (!result.attached) {
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || path
return {
...attachment,
attachedSessionId: sessionId,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
uploadState: undefined
}
}
// Non-image file.
const dataUrl = remote ? await readFileDataUrlForAttach(path) : null
if (remote && !dataUrl) {
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
name: label,
path,
session_id: sessionId,
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
throw new Error(result.message || `Could not attach ${label}`)
}
return {
...attachment,
attachedSessionId: sessionId,
refText: result.ref_text,
uploadState: undefined
}
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@ -239,95 +322,23 @@ export function usePromptActions({
for (const attachment of attachments) {
// Already-synced or pathless refs (terminal, url, etc.) pass through.
// A drop-time eager upload may already have staged this one (matching
// attachedSessionId) — don't re-upload it.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
continue
}
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = await readImageForRemoteAttach(attachment.path)
if (!payload) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
if (!result.attached) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
if (attachment.kind === 'file') {
// Non-image file refs are @file: paths the gateway reads with its file
// tools. On a remote gateway the desktop path doesn't exist there, so
// upload the bytes; the gateway stages them into the session workspace
// and hands back a workspace-relative ref that actually resolves.
// Local mode can pass the path directly (gateway shares this disk).
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
if (remote && !dataUrl) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
session_id: sessionId,
path: attachment.path,
name: attachment.label || pathLabel(attachment.path),
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
refText: result.ref_text,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
@ -339,6 +350,62 @@ export function usePromptActions({
[requestGateway]
)
// Stage a freshly dropped file as soon as it lands (when a session already
// exists), so the upload runs while the user is still typing rather than
// stalling the send. The card shows a spinner via `uploadState`; on success
// the chip carries its gateway-side ref so submit skips re-uploading.
//
// Images are intentionally NOT eager-uploaded: attachImagePath adds the chip
// and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so
// an eager upload would race that write — clobbering the thumbnail and
// swapping `path` to a gateway path the local preview can't read. Images are
// small and still byte-upload at submit via image.attach_bytes.
const eagerlyUploadAttachment = useCallback(
async (sessionId: string, attachment: ComposerAttachment) => {
const remote = $connection.get()?.mode === 'remote'
setComposerAttachmentUploadState(attachment.id, 'uploading')
try {
addComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable
// file, gateway perms) isn't swallowed while the user keeps typing.
setComposerAttachmentUploadState(attachment.id, 'error')
notifyError(err, copy.dropFiles)
}
},
[copy.dropFiles, requestGateway]
)
const composerAttachments = useStore($composerAttachments)
const eagerUploadInFlight = useRef<Set<string>>(new Set())
useEffect(() => {
if (!activeSessionId) {
return
}
for (const attachment of composerAttachments) {
const needsUpload =
attachment.kind === 'file' &&
Boolean(attachment.path) &&
!attachment.attachedSessionId &&
!attachment.uploadState &&
!eagerUploadInFlight.current.has(attachment.id)
if (!needsUpload) {
continue
}
eagerUploadInFlight.current.add(attachment.id)
void eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
eagerUploadInFlight.current.delete(attachment.id)
)
}
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
const submitPromptText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()

View file

@ -37,7 +37,12 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
import {
dragHasAttachments,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from '@/app/chat/composer/inline-refs'
import {
composerPlainText,
placeCaretEnd,
@ -47,7 +52,8 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
@ -76,6 +82,7 @@ import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
@ -84,7 +91,9 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
@ -1178,18 +1187,14 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
)
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => {
const insertRefStrings = useCallback(
(refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor) {
if (!editor || refs.length === 0) {
return false
}
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
if (nextDraft === null) {
@ -1202,7 +1207,60 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
return true
},
[aui, cwd, requestEditFocus]
[aui, requestEditFocus]
)
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
[cwd, insertRefStrings]
)
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
// read it in remote mode, and an image needs its bytes uploaded for vision.
// Stage each through the same file.attach/image.attach_bytes pipeline the main
// composer uses, then insert the *gateway-side* ref the agent can resolve —
// never the raw local path (the MahmoudR remote-attach bug, which the main
// composer fixes but this edit composer used to reproduce).
const uploadOsDropRefs = useCallback(
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
if (!gateway || !sessionId) {
// No session to stage into — best-effort inline refs (matches old path).
return droppedFileInlineRefs(osDrops, cwd)
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
const path = candidate.path || ''
if (!path) {
continue
}
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ remote, requestGateway, sessionId }
)
const ref = attachmentDisplayText(uploaded)
if (ref) {
refs.push(ref)
}
} catch (err) {
notifyError(err, t.desktop.dropFiles)
}
}
return refs
},
[cwd, gateway, sessionId, t.desktop.dropFiles]
)
const resetDragState = useCallback(() => {
@ -1256,9 +1314,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
event.stopPropagation()
resetDragState()
if (insertDroppedRefs(candidates)) {
// In-app drags (project tree / gutter) are workspace-relative paths that
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
// be staged + uploaded first, then their gateway-side ref is inserted.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
if (insertDroppedRefs(inAppRefs)) {
triggerHaptic('selection')
}
if (osDrops.length) {
void uploadOsDropRefs(osDrops).then(refs => {
if (insertRefStrings(refs)) {
triggerHaptic('selection')
}
})
}
}
const handleInput = (event: FormEvent<HTMLDivElement>) => {

View file

@ -11,6 +11,10 @@ export interface ComposerAttachment {
previewUrl?: string
path?: string
attachedSessionId?: string
/** Set while the file/image bytes are being staged into the session
* workspace (remote upload or local stage), and 'error' if that failed.
* Drives the spinner / error state on the composer attachment card. */
uploadState?: 'uploading' | 'error'
}
export const $composerDraft = atom('')
@ -73,6 +77,21 @@ export function clearComposerAttachments() {
$composerAttachments.set([])
}
/** Update only the upload state of an existing attachment (no-op if it's gone,
* e.g. the user removed it mid-upload). Pass `undefined` to clear it. */
export function setComposerAttachmentUploadState(id: string, uploadState?: ComposerAttachment['uploadState']) {
const current = $composerAttachments.get()
const index = current.findIndex(attachment => attachment.id === id)
if (index < 0) {
return
}
const next = [...current]
next[index] = { ...next[index]!, uploadState }
$composerAttachments.set(next)
}
const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
function unquoteRefValue(raw: string) {

View file

@ -6,6 +6,11 @@ import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
binary?: boolean
byteSize?: number
/** Inline image bytes (a `data:` URL) when the renderer already holds them
* e.g. a pasted/dropped screenshot whose only on-disk copy is a transient
* path the preview can't reliably re-read. Rendered directly and NOT
* persisted to the session-preview registry (it would bloat localStorage). */
dataUrl?: string
kind: 'file' | 'url'
label: string
large?: boolean
@ -214,7 +219,11 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
}
try {
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
// Drop the inline image bytes before persisting — a screenshot data URL is
// megabytes and would blow the localStorage quota. On reload the record
// falls back to reading its `path`/`url`.
const lean = JSON.stringify(pruneRegistry(registry), (key, value) => (key === 'dataUrl' ? undefined : value))
window.localStorage.setItem(REGISTRY_STORAGE_KEY, lean)
} catch {
// Session previews are a desktop convenience; storage failures are nonfatal.
}