Merge pull request #43109 from NousResearch/fix/desktop-remote-attach-drops

fix(desktop): stage dropped files into the remote session workspace
This commit is contained in:
brooklyn! 2026-06-09 19:22:11 -05:00 committed by GitHub
commit aecdacb11b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 963 additions and 164 deletions

View file

@ -246,7 +246,14 @@ def _expand_file_reference(
if not path.is_file():
return f"{ref.raw}: path is not a file", None
if _is_binary_file(path):
return f"{ref.raw}: binary files are not supported", None
# A binary file can't be inlined as text, but it IS on disk (the agent's
# tools run where this resolves — the local cwd, or the staged copy in a
# remote session workspace). Returning a bare "not supported" warning
# with no content was a dead end: the model saw a failure and gave up
# (told the user the file type wasn't supported). Instead, hand it an
# actionable block — the path, type, size, and a nudge to use its tools —
# so it can read/convert/view the file itself.
return None, _binary_reference_block(ref, path)
text = path.read_text(encoding="utf-8")
if ref.line_start is not None:
@ -493,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
return files[:limit]
def _human_bytes(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime, _ = mimetypes.guess_type(path.name)
mime = mime or "application/octet-stream"
try:
size = _human_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
return (
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
f"It is available on disk at `{path}`. Use your tools to work with it "
f"(read or convert it, extract its text, or view/render it as needed); "
f"do not tell the user the file type is unsupported."
)
def _file_metadata(path: Path) -> str:
if _is_binary_file(path):
return f"{path.stat().st_size} bytes"

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,14 +1,13 @@
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 { $connection, $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
@ -385,6 +384,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)
@ -413,6 +455,63 @@ describe('usePromptActions file attachment sync', () => {
})
})
describe('usePromptActions eager-upload races', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
$composerAttachments.set([])
})
afterEach(() => {
cleanup()
$composerAttachments.set([])
$connection.set(null)
vi.restoreAllMocks()
})
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
// submit doesn't join it, both calls stage the file and leave a duplicate
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
// and reuse its gateway-side ref.
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
let releaseAttach: () => void = () => {}
const methods: string[] = []
const requestGateway = vi.fn(async (method: string) => {
methods.push(method)
if (method === 'file.attach') {
// Block until released so submit runs while the upload is in flight.
await new Promise<void>(resolve => {
releaseAttach = resolve
})
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())
// Drop a file → the eager effect fires file.attach and blocks on it.
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
// Submit reads the store, sees the upload in flight, and joins it.
const submitting = handle!.submitText('here you go')
releaseAttach()
expect(await submitting).toBe(true)
// Exactly one file.attach (submit reused the eager result), then the send.
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
expect(methods).toContain('prompt.submit')
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
@ -518,3 +617,138 @@ 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())
})
})
describe('uploadComposerAttachment remote read failures', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
})
}
})
const requestGateway = vi.fn(async () => ({}) as never)
await expect(
uploadComposerAttachment(
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
// The cap is hit before any gateway round-trip.
expect(requestGateway).not.toHaveBeenCalled()
})
it('passes non-cap read errors through unchanged', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('ENOENT: no such file')
})
}
})
await expect(
uploadComposerAttachment(
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('ENOENT: no such file')
})
})

View file

@ -1,11 +1,12 @@
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'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
@ -24,10 +25,11 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
$composerAttachments,
addComposerAttachment,
clearComposerAttachments,
type ComposerAttachment,
terminalContextBlocksFromDraft
setComposerAttachmentUploadState,
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@ -118,6 +120,122 @@ async function readFileDataUrlForAttach(filePath: string): Promise<string | null
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
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) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
payload = await readImageForRemoteAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
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.
let dataUrl: string | null = null
if (remote) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
if (!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>
@ -227,6 +345,11 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
// In-flight drop-time eager uploads, keyed by attachment id. Submit joins
// these before re-uploading so a drop-then-immediately-Enter can't fire
// file.attach twice and stage duplicate copies on the gateway.
const eagerUploadInFlight = useRef<Map<string, Promise<void>>>(new Map())
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
@ -237,97 +360,40 @@ export function usePromptActions({
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
for (const original of attachments) {
let attachment = original
// Join a drop-time eager upload still in flight for this attachment
// before deciding anything — otherwise submit and the eager task both
// call file.attach and stage duplicate files. After it settles, take the
// store's updated copy (its gateway ref, or its failure) over the stale
// pre-upload snapshot.
const inFlight = eagerUploadInFlight.current.get(attachment.id)
if (inFlight) {
await inFlight
attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
}
// 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 })
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
updateComposerAttachment(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 +405,64 @@ 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 {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(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)
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
}
const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
eagerUploadInFlight.current.delete(attachment.id)
)
eagerUploadInFlight.current.set(attachment.id, task)
}
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
const submitPromptText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
@ -351,7 +475,9 @@ export function usePromptActions({
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// Images use their base64 preview so the thumbnail renders inline without
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
@ -483,7 +609,8 @@ export function usePromptActions({
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)

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'
@ -962,6 +971,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
// True while OS-drop files are being staged/uploaded into the session. Blocks
// submit and shows a spinner so confirming the edit can't race the async
// upload and drop the gateway-side ref before it lands in the draft.
const [staging, setStaging] = useState(false)
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
@ -1178,18 +1191,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 +1211,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 +1318,25 @@ 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) {
setStaging(true)
void uploadOsDropRefs(osDrops)
.then(refs => {
if (insertRefStrings(refs)) {
triggerHaptic('selection')
}
})
.finally(() => setStaging(false))
}
}
const handleInput = (event: FormEvent<HTMLDivElement>) => {
@ -1289,7 +1367,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const submitEdit = (editor: HTMLDivElement) => {
const nextDraft = syncDraftFromEditor(editor)
if (submitting || !nextDraft.trim()) {
if (submitting || staging || !nextDraft.trim()) {
return
}
@ -1446,10 +1524,19 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
suppressContentEditableWarning
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
data-slot="aui_edit-staging"
>
<Loader2Icon className="size-3 animate-spin" />
{copy.attachingFile}
</span>
)}
<button
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting}
disabled={!canSubmit || submitting || staging}
onClick={() => {
const editor = editorRef.current

View file

@ -1622,7 +1622,8 @@ export const en: Translations = {
restoreCheckpoint: 'Restore checkpoint',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
sendEdited: 'Send edited message'
sendEdited: 'Send edited message',
attachingFile: 'Attaching…'
},
approval: {
gatewayDisconnected: 'Hermes gateway is not connected',

View file

@ -1766,7 +1766,8 @@ export const ja = defineLocale({
restoreCheckpoint: 'チェックポイントを復元',
restoreNext: '次のチェックポイントに戻す',
goForward: '進む',
sendEdited: '編集済みメッセージを送信'
sendEdited: '編集済みメッセージを送信',
attachingFile: '添付中…'
},
approval: {
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',

View file

@ -1293,6 +1293,7 @@ export interface Translations {
restoreNext: string
goForward: string
sendEdited: string
attachingFile: string
}
approval: {
gatewayDisconnected: string

View file

@ -1727,7 +1727,8 @@ export const zhHant = defineLocale({
restoreCheckpoint: '還原檢查點',
restoreNext: '還原至下一個檢查點',
goForward: '前進',
sendEdited: '傳送編輯後的訊息'
sendEdited: '傳送編輯後的訊息',
attachingFile: '正在附加…'
},
approval: {
gatewayDisconnected: 'Hermes 閘道未連線',

View file

@ -1802,7 +1802,8 @@ export const zh: Translations = {
restoreCheckpoint: '恢复检查点',
restoreNext: '恢复下一个检查点',
goForward: '前进',
sendEdited: '发送编辑后的消息'
sendEdited: '发送编辑后的消息',
attachingFile: '正在附加…'
},
approval: {
gatewayDisconnected: 'Hermes 网关未连接',

View file

@ -1,6 +1,42 @@
import { describe, expect, it } from 'vitest'
import { coerceThinkingText } from './chat-runtime'
import type { ComposerAttachment } from '@/store/composer'
import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
return { id: 'a', label: 'file.png', ...overrides }
}
describe('optimisticAttachmentRef', () => {
it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
// The raw data URL flows through extractEmbeddedImages → inline thumbnail,
// dodging the remote /api/media 403 an @image:<localpath> ref would hit.
expect(ref).toBe(DATA_URL)
})
it('falls back to an @image: path ref when no preview is available', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png')
})
it('ignores a non-data preview url and uses the path ref', () => {
const ref = optimisticAttachmentRef(
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
)
expect(ref).toBe('@image:/tmp/shot.png')
})
it('passes non-image attachments straight through to attachmentDisplayText', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
'@file:src/a.ts'
)
})
})
describe('coerceThinkingText', () => {
it('strips streaming status prefixes from thinking deltas', () => {

View file

@ -165,6 +165,29 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
return null
}
/**
* Display ref for the optimistic (in-flight) user bubble.
*
* Images prefer their in-hand base64 preview (a `data:` URL) over a file path.
* `DirectiveContent` runs `extractEmbeddedImages` first, so a raw `data:` URL
* renders as an inline thumbnail with zero network. An `@image:<localpath>` ref
* would instead route through `/api/media`, which in remote mode 403s ("Path
* outside media roots") on a local path the gateway can't read yet flashing a
* fallback chip until submit uploads the bytes. The preview also survives the
* post-sync rewrite (bytes go to the agent via the attached-image pipeline, not
* this display ref), so the thumbnail stays stable instead of remounting.
*
* Everything else (files, folders, terminals, post-sync `@file:` refs) falls
* through to `attachmentDisplayText`.
*/
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
return attachment.previewUrl
}
return attachmentDisplayText(attachment)
}
export function personalityNamesFromConfig(config: unknown): string[] {
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}

View file

@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
$composerAttachments,
addComposerAttachment,
type ComposerAttachment,
removeComposerAttachment,
updateComposerAttachment
} from './composer'
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
return { kind: 'file', label: 'doc.pdf', ...overrides }
}
describe('updateComposerAttachment', () => {
afterEach(() => {
$composerAttachments.set([])
})
it('replaces an existing attachment in place', () => {
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
expect(updated).toBe(true)
const current = $composerAttachments.get()
expect(current).toHaveLength(1)
expect(current[0]?.attachedSessionId).toBe('sess-1')
expect(current[0]?.uploadState).toBeUndefined()
})
it('does NOT resurrect an attachment the user removed mid-upload', () => {
// Drop → eager upload starts → user removes the chip → upload resolves.
// The late success must not re-add the removed attachment.
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
removeComposerAttachment('file:a')
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
expect(updated).toBe(false)
expect($composerAttachments.get()).toHaveLength(0)
})
})

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('')
@ -69,10 +73,44 @@ export function removeComposerAttachment(id: string): ComposerAttachment | null
return removed
}
/** Replace an existing attachment in place by id. No-op (returns false) when the
* id is gone e.g. the user removed the chip while an eager upload was still in
* flight, so a late success must NOT resurrect it. Use this instead of
* addComposerAttachment for async results that may land after a removal. */
export function updateComposerAttachment(attachment: ComposerAttachment): boolean {
const current = $composerAttachments.get()
const index = current.findIndex(item => item.id === attachment.id)
if (index < 0) {
return false
}
const next = [...current]
next[index] = attachment
$composerAttachments.set(next)
return true
}
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.
}

View file

@ -192,21 +192,40 @@ def test_expand_git_diff_staged_and_log(sample_repo: Path):
assert "VALUE = 2" in result.message
def test_binary_and_missing_files_become_warnings(sample_repo: Path):
def test_missing_file_becomes_warning(sample_repo: Path):
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Check @file:blob.bin and @file:nope.txt",
"Check @file:nope.txt",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
assert len(result.warnings) == 2
assert "binary" in result.message.lower()
assert len(result.warnings) == 1
assert "not found" in result.message.lower()
def test_binary_file_yields_actionable_block_not_a_dead_warning(sample_repo: Path):
from agent.context_references import preprocess_context_references
result = preprocess_context_references(
"Check @file:blob.bin",
cwd=sample_repo,
context_length=100_000,
)
assert result.expanded
# The whole point: a binary attachment must NOT degrade into a discouraging
# warning that makes the model give up — it gets an actionable content block.
assert not result.warnings
assert "blob.bin" in result.message
assert "binary" in result.message.lower()
assert "not supported" not in result.message.lower()
# And it must point the agent at the file so it can act on it with tools.
assert str(sample_repo / "blob.bin") in result.message
def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):
from agent.context_references import preprocess_context_references