Files
hermes-agent/apps/desktop/src/store/composer.ts
T
4906dcfc25 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>
2026-06-09 16:50:08 -05:00

204 lines
5.0 KiB
TypeScript

import { atom } from 'nanostores'
import { triggerHaptic } from '@/lib/haptics'
export interface ComposerAttachment {
id: string
kind: 'image' | 'file' | 'folder' | 'terminal' | 'url'
label: string
detail?: string
refText?: string
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('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}
export function appendComposerDraft(value: string) {
const text = value.trim()
if (!text) {
return
}
const current = $composerDraft.get()
const separator = current && !current.endsWith('\n') ? '\n\n' : ''
$composerDraft.set(`${current}${separator}${text}`)
}
export function appendComposerInline(value: string) {
const text = value.trim()
if (!text) {
return
}
const current = $composerDraft.get().trimEnd()
const separator = current ? ' ' : ''
$composerDraft.set(`${current}${separator}${text}`)
}
export function clearComposerDraft() {
$composerDraft.set('')
}
export function addComposerAttachment(attachment: ComposerAttachment) {
const previous = $composerAttachments.get()
const next = upsertAttachment(previous, attachment)
$composerAttachments.set(next)
if (next.length > previous.length && attachment.kind !== 'url') {
triggerHaptic('selection')
}
}
export function removeComposerAttachment(id: string): ComposerAttachment | null {
const current = $composerAttachments.get()
const removed = current.find(attachment => attachment.id === id) || null
$composerAttachments.set(current.filter(attachment => attachment.id !== id))
return removed
}
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) {
const head = raw[0]
const tail = raw[raw.length - 1]
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
return (quoted ? raw.slice(1, -1) : raw).replace(/[,.;!?]+$/, '').trim()
}
function terminalLabelsFromDraft(draft: string) {
const labels: string[] = []
const seen = new Set<string>()
for (const match of draft.matchAll(TERMINAL_REF_RE)) {
const label = unquoteRefValue(match[1] || '')
if (!label || seen.has(label)) {
continue
}
seen.add(label)
labels.push(label)
}
return labels
}
export function setComposerTerminalSelection(label: string, text: string) {
const nextLabel = label.trim()
const nextText = text.trim()
if (!nextLabel || !nextText) {
return
}
const current = $composerTerminalSelections.get()
if (current[nextLabel] === nextText) {
return
}
$composerTerminalSelections.set({
...current,
[nextLabel]: nextText
})
}
export function reconcileComposerTerminalSelections(draft: string) {
const current = $composerTerminalSelections.get()
const labels = new Set(terminalLabelsFromDraft(draft))
let changed = false
const next: Record<string, string> = {}
for (const [label, text] of Object.entries(current)) {
if (!labels.has(label)) {
changed = true
continue
}
next[label] = text
}
if (changed) {
$composerTerminalSelections.set(next)
}
}
export function terminalContextBlocksFromDraft(draft: string) {
const labels = terminalLabelsFromDraft(draft)
if (labels.length === 0) {
return []
}
const selections = $composerTerminalSelections.get()
return labels.flatMap(label => {
const text = selections[label]?.trim()
if (!text) {
return []
}
return `\`\`\`terminal\n${text}\n\`\`\``
})
}
export function clearComposerTerminalSelections() {
if (Object.keys($composerTerminalSelections.get()).length === 0) {
return
}
$composerTerminalSelections.set({})
}
function upsertAttachment(attachments: ComposerAttachment[], attachment: ComposerAttachment) {
const index = attachments.findIndex(item => item.id === attachment.id)
if (index < 0) {
return [...attachments, attachment]
}
const next = [...attachments]
next[index] = attachment
return next
}