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([]) export const $composerTerminalSelections = atom>({}) 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() 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 = {} 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 }