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>
204 lines
5.0 KiB
TypeScript
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
|
|
}
|