mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
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:
commit
aecdacb11b
22 changed files with 963 additions and 164 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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'
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1766,7 +1766,8 @@ export const ja = defineLocale({
|
|||
restoreCheckpoint: 'チェックポイントを復元',
|
||||
restoreNext: '次のチェックポイントに戻す',
|
||||
goForward: '進む',
|
||||
sendEdited: '編集済みメッセージを送信'
|
||||
sendEdited: '編集済みメッセージを送信',
|
||||
attachingFile: '添付中…'
|
||||
},
|
||||
approval: {
|
||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||
|
|
|
|||
|
|
@ -1293,6 +1293,7 @@ export interface Translations {
|
|||
restoreNext: string
|
||||
goForward: string
|
||||
sendEdited: string
|
||||
attachingFile: string
|
||||
}
|
||||
approval: {
|
||||
gatewayDisconnected: string
|
||||
|
|
|
|||
|
|
@ -1727,7 +1727,8 @@ export const zhHant = defineLocale({
|
|||
restoreCheckpoint: '還原檢查點',
|
||||
restoreNext: '還原至下一個檢查點',
|
||||
goForward: '前進',
|
||||
sendEdited: '傳送編輯後的訊息'
|
||||
sendEdited: '傳送編輯後的訊息',
|
||||
attachingFile: '正在附加…'
|
||||
},
|
||||
approval: {
|
||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||
|
|
|
|||
|
|
@ -1802,7 +1802,8 @@ export const zh: Translations = {
|
|||
restoreCheckpoint: '恢复检查点',
|
||||
restoreNext: '恢复下一个检查点',
|
||||
goForward: '前进',
|
||||
sendEdited: '发送编辑后的消息'
|
||||
sendEdited: '发送编辑后的消息',
|
||||
attachingFile: '正在附加…'
|
||||
},
|
||||
approval: {
|
||||
gatewayDisconnected: 'Hermes 网关未连接',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>) : {}
|
||||
|
|
|
|||
43
apps/desktop/src/store/composer.test.ts
Normal file
43
apps/desktop/src/store/composer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue