mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
02f878ec5a
commit
4906dcfc25
12 changed files with 584 additions and 142 deletions
|
|
@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { useI18n } from '@/i18n'
|
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 { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { ComposerAttachment } from '@/store/composer'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||||
|
|
@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||||
const c = t.composer
|
const c = t.composer
|
||||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||||
const cwd = useStore($currentCwd)
|
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
|
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||||
|
|
||||||
async function openPreview() {
|
async function openPreview() {
|
||||||
|
|
@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||||
throw new Error(c.couldNotPreview(attachment.label))
|
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) {
|
} catch (error) {
|
||||||
notifyError(error, c.previewUnavailable)
|
notifyError(error, c.previewUnavailable)
|
||||||
}
|
}
|
||||||
|
|
@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||||
<div className="group/attachment relative min-w-0 shrink-0">
|
<div className="group/attachment relative min-w-0 shrink-0">
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isUploading || undefined}
|
||||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
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}
|
disabled={!canPreview}
|
||||||
onClick={() => void openPreview()}
|
onClick={() => void openPreview()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
<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">
|
||||||
<img
|
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||||
alt={attachment.label}
|
<img
|
||||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
alt={attachment.label}
|
||||||
draggable={false}
|
className="size-full object-cover"
|
||||||
src={attachment.previewUrl}
|
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">
|
) : (
|
||||||
<Icon className="size-3.5" />
|
<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="min-w-0">
|
||||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||||
{attachment.label}
|
{attachment.label}
|
||||||
</span>
|
</span>
|
||||||
{detail && (
|
{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}
|
{detail}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ import {
|
||||||
import { $gatewayState, $messages } from '@/store/session'
|
import { $gatewayState, $messages } from '@/store/session'
|
||||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
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 { AttachmentList } from './attachments'
|
||||||
import { ContextMenu } from './context-menu'
|
import { ContextMenu } from './context-menu'
|
||||||
|
|
@ -64,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||||
import {
|
import {
|
||||||
dragHasAttachments,
|
dragHasAttachments,
|
||||||
droppedFileInlineRef,
|
droppedFileInlineRefs,
|
||||||
type InlineRefInput,
|
type InlineRefInput,
|
||||||
insertInlineRefsIntoEditor
|
insertInlineRefsIntoEditor
|
||||||
} from './inline-refs'
|
} from './inline-refs'
|
||||||
|
|
@ -919,24 +919,25 @@ export function ChatBar({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
// In-app drags (project tree / gutter) are workspace-relative paths the
|
||||||
const refs = candidates
|
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
// drops are absolute local paths a remote gateway can't read (and images
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
// 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)) {
|
if (refs.length && insertInlineRefs(refs)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
if (osDrops.length) {
|
||||||
if (attached) {
|
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
|
||||||
triggerHaptic('selection')
|
if (attached) {
|
||||||
requestMainFocus()
|
triggerHaptic('selection')
|
||||||
}
|
requestMainFocus()
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||||
|
|
@ -956,11 +957,7 @@ export function ChatBar({
|
||||||
|
|
||||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||||
|
|
||||||
const refs = candidates
|
if (!candidates.length) {
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (!refs.length) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -968,9 +965,27 @@ export function ChatBar({
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
resetDragState()
|
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')
|
triggerHaptic('selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attach && osDrops.length) {
|
||||||
|
void Promise.resolve(attach(osDrops)).then(attached => {
|
||||||
|
if (attached) {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
requestMainFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearDraft = useCallback(() => {
|
const clearDraft = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
||||||
return `@${kind}:${formatRefValue(rel)}`
|
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[]) {
|
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||||
if (!refs.length) {
|
if (!refs.length) {
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
57
apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Normal file
57
apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Normal 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: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
|
||||||
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImagePath(filePath: string): boolean {
|
export function isImagePath(filePath: string): boolean {
|
||||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||||
return result
|
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 {
|
interface ComposerActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
currentCwd: string
|
currentCwd: string
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
|
||||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||||
import { ChatBar, ChatBarFallback } from './composer'
|
import { ChatBar, ChatBarFallback } from './composer'
|
||||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
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 { 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 { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
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
|
// Drop files anywhere in the conversation area, not just on the composer
|
||||||
// input — appending the same inline `@file:` ref chips the composer drop
|
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||||
// produces (vs. attachment cards) so both surfaces behave identically.
|
// 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(
|
const onDropFiles = useCallback(
|
||||||
(candidates: DroppedFile[]) => {
|
(candidates: DroppedFile[]) => {
|
||||||
const refs = candidates
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
|
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (refs.length) {
|
if (refs.length) {
|
||||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
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
|
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isImage) {
|
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) {
|
if (active) {
|
||||||
setState({ dataUrl, loading: false })
|
setState({ dataUrl, loading: false })
|
||||||
|
|
@ -484,7 +486,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return <PageLoader label={t.preview.loading} />
|
return <PageLoader label={t.preview.loading} />
|
||||||
|
|
|
||||||
|
|
@ -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 type { MutableRefObject } from 'react'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $sessions, setSessions } from '@/store/session'
|
import { $sessions, setSessions } from '@/store/session'
|
||||||
import { $connection } 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 type { SessionInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { usePromptActions } from './use-prompt-actions'
|
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 () => {
|
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||||
$connection.set({ mode: 'local' } as never)
|
$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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
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 { getProfiles, transcribeAudio } from '@/hermes'
|
||||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
addComposerAttachment,
|
addComposerAttachment,
|
||||||
clearComposerAttachments,
|
clearComposerAttachments,
|
||||||
type ComposerAttachment,
|
type ComposerAttachment,
|
||||||
|
setComposerAttachmentUploadState,
|
||||||
terminalContextBlocksFromDraft
|
terminalContextBlocksFromDraft
|
||||||
} from '@/store/composer'
|
} from '@/store/composer'
|
||||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||||
|
|
@ -118,6 +120,87 @@ async function readFileDataUrlForAttach(filePath: string): Promise<string | null
|
||||||
return dataUrl || 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 {
|
interface PromptActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
activeSessionIdRef: MutableRefObject<string | null>
|
activeSessionIdRef: MutableRefObject<string | null>
|
||||||
|
|
@ -239,95 +322,23 @@ export function usePromptActions({
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
// Already-synced or pathless refs (terminal, url, etc.) pass through.
|
// 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) {
|
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||||
synced.push(attachment)
|
synced.push(attachment)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.kind === 'image') {
|
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||||
let result: ImageAttachResponse
|
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||||
|
|
||||||
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 (updateComposerAttachments) {
|
if (updateComposerAttachments) {
|
||||||
addComposerAttachment(nextAttachment)
|
addComposerAttachment(nextAttachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
synced.push(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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +350,62 @@ export function usePromptActions({
|
||||||
[requestGateway]
|
[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(
|
const submitPromptText = useCallback(
|
||||||
async (rawText: string, options?: SubmitTextOptions) => {
|
async (rawText: string, options?: SubmitTextOptions) => {
|
||||||
const visibleText = rawText.trim()
|
const visibleText = rawText.trim()
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,12 @@ import {
|
||||||
} from '@/app/chat/composer/focus'
|
} from '@/app/chat/composer/focus'
|
||||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-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 {
|
import {
|
||||||
composerPlainText,
|
composerPlainText,
|
||||||
placeCaretEnd,
|
placeCaretEnd,
|
||||||
|
|
@ -47,7 +52,8 @@ import {
|
||||||
} from '@/app/chat/composer/rich-editor'
|
} from '@/app/chat/composer/rich-editor'
|
||||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
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 { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-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 type { HermesGateway } from '@/hermes'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { LinkifiedText } from '@/lib/external-link'
|
import { LinkifiedText } from '@/lib/external-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
|
@ -84,7 +91,9 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||||
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
|
import { $connection } from '@/store/session'
|
||||||
import { $voicePlayback } from '@/store/voice-playback'
|
import { $voicePlayback } from '@/store/voice-playback'
|
||||||
|
|
||||||
type ThreadLoadingState = 'response' | 'session'
|
type ThreadLoadingState = 'response' | 'session'
|
||||||
|
|
@ -1178,18 +1187,14 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||||
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertDroppedRefs = useCallback(
|
const insertRefStrings = useCallback(
|
||||||
(candidates: ReturnType<typeof extractDroppedFiles>) => {
|
(refs: InlineRefInput[]) => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor || refs.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = candidates
|
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||||
|
|
||||||
if (nextDraft === null) {
|
if (nextDraft === null) {
|
||||||
|
|
@ -1202,7 +1207,60 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||||
|
|
||||||
return true
|
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(() => {
|
const resetDragState = useCallback(() => {
|
||||||
|
|
@ -1256,9 +1314,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
resetDragState()
|
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')
|
triggerHaptic('selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (osDrops.length) {
|
||||||
|
void uploadOsDropRefs(osDrops).then(refs => {
|
||||||
|
if (insertRefStrings(refs)) {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ export interface ComposerAttachment {
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
path?: string
|
path?: string
|
||||||
attachedSessionId?: 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('')
|
export const $composerDraft = atom('')
|
||||||
|
|
@ -73,6 +77,21 @@ export function clearComposerAttachments() {
|
||||||
$composerAttachments.set([])
|
$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
|
const TERMINAL_REF_RE = /@terminal:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||||
|
|
||||||
function unquoteRefValue(raw: string) {
|
function unquoteRefValue(raw: string) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||||
export interface PreviewTarget {
|
export interface PreviewTarget {
|
||||||
binary?: boolean
|
binary?: boolean
|
||||||
byteSize?: number
|
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'
|
kind: 'file' | 'url'
|
||||||
label: string
|
label: string
|
||||||
large?: boolean
|
large?: boolean
|
||||||
|
|
@ -214,7 +219,11 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// Session previews are a desktop convenience; storage failures are nonfatal.
|
// Session previews are a desktop convenience; storage failures are nonfatal.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue