import { formatRefValue } from '@/components/assistant-ui/directive-text' import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } /** MIME for an in-app session drag (sidebar row → composer). */ export const HERMES_SESSION_MIME = 'application/x-hermes-session' export interface SessionDragPayload { id: string profile: string title: string } export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) { transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload)) transfer.effectAllowed = 'copy' } export function dragHasSession(transfer: DataTransfer | null) { return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME) } export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload { const raw = transfer?.getData(HERMES_SESSION_MIME) if (!raw) { return null } try { const parsed = JSON.parse(raw) as Partial return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null } catch { return null } } /** Build a `@session:/` chip. Value carries the metadata the agent * needs to resolve the link (session_search); label shows the friendly title. */ export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput { return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` } } export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) { if (!transfer) { return false } if (Array.from(transfer.types || []).includes(pathsMime)) { return true } if (Array.from(transfer.types || []).includes('Files')) { return true } return Array.from(transfer.items || []).some(item => item.kind === 'file') } export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) { if (!candidate.path) { return null } const rel = contextPath(candidate.path, cwd || '') if (candidate.line) { const { line, lineEnd } = candidate const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}` return `@line:${formatRefValue(`${rel}:${range}`)}` } const kind = candidate.isDirectory ? 'folder' : 'file' 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)) } function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null { if (typeof ref !== 'string') { return { kind: ref.kind, label: ref.label, rawValue: ref.value } } const match = ref.match(/^@([^:]+):(.+)$/) if (!match) { return null } return { kind: match[1] || 'file', rawValue: match[2] || '' } } function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') { const slice = range.cloneRange() slice.selectNodeContents(editor) if (edge === 'before') { slice.setEnd(range.startContainer, range.startOffset) } else { slice.setStart(range.endContainer, range.endOffset) } const container = document.createElement('div') container.appendChild(slice.cloneContents()) return composerPlainText(container) } function buildRefFragment( refs: readonly { kind: string; label?: string; rawValue: string }[], { needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean } ) { const fragment = document.createDocumentFragment() if (needsBeforeSpace) { fragment.append(document.createTextNode(' ')) } refs.forEach((ref, index) => { if (index > 0) { fragment.append(document.createTextNode(' ')) } fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label)) }) if (needsAfterSpace) { fragment.append(document.createTextNode(' ')) } return fragment } export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable => ref !== null) if (!parsed.length) { return null } editor.focus({ preventScroll: true }) const selection = window.getSelection() const range = selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0) : null if (range && selection) { const beforeText = plainTextInRange(editor, range, 'before') const afterText = plainTextInRange(editor, range, 'after') range.insertNode( buildRefFragment(parsed, { needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText), needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText) }) ) range.collapse(false) selection.removeAllRanges() selection.addRange(range) } else { const current = composerPlainText(editor) editor.append( buildRefFragment(parsed, { needsAfterSpace: true, needsBeforeSpace: current.length > 0 && !/\s$/.test(current) }) ) placeCaretEnd(editor) } normalizeComposerEditorDom(editor) return composerPlainText(editor) }