import { type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { addScratchlistEntry, deleteScratchlistEntry, moveScratchlistEntry, persistScratchlist, readScratchlist, SCRATCHLIST_MAX_ENTRIES, SCRATCHLIST_MAX_TEXT_LENGTH, shouldConfirmDelete, type ScratchlistEntry, } from '@/lib/scratchlist' import type { ApiClient } from '@/api/client' import type { ScratchlistAttachmentMetadata } from '@hapi/protocol' import { isImageMimeType } from '@/lib/fileAttachments' import { safeCopyToClipboard } from '@/lib/clipboard' import { useTranslation } from '@/lib/use-translation' import { formatAbsoluteDateTime, formatRelativeTime } from '@/lib/relativeTime' const STORAGE_KEY_PREFIX = 'hapi.scratchlist-collapsed.v1.' function readCollapsedPref(sessionId: string): boolean { if (typeof window === 'undefined') return true try { const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${sessionId}`) return raw === null ? true : raw === '1' } catch { return true } } function writeCollapsedPref(sessionId: string, collapsed: boolean): void { if (typeof window === 'undefined') return try { window.localStorage.setItem( `${STORAGE_KEY_PREFIX}${sessionId}`, collapsed ? '1' : '0' ) } catch { // Non-fatal. } } function NoteIcon() { return ( ) } function ChevronIcon({ open }: { open: boolean }) { return ( ) } function ArrowUpIcon() { return ( ) } function ArrowDownIcon() { return ( ) } function PencilIcon() { return ( ) } function SendIcon() { return ( ) } function TrashIcon() { return ( ) } function CopyIcon() { return ( ) } function ClockIcon() { return ( ) } /** * Per-entry age indicator: clock icon with a tooltip showing * smart-relative time (e.g. "2m ago") and the absolute timestamp on a * second line, so an operator can tell at-a-glance how stale a note is. * * Renders nothing when no usable timestamp is available - this happens * for legacy localStorage entries that pre-date the v2 hub-sync work * (no `updatedAt` recorded) AND have no `createdAt` either, which is * vanishingly rare but still a guard against `NaN` titles. * * Falls back to `createdAt` when `updatedAt` is missing so newly-loaded * v1-only rows still get a useful tooltip during the migration window. */ function EntryAgeIndicator({ entry, }: { entry: ScratchlistEntry }) { const { t } = useTranslation() const stamp = entry.updatedAt ?? entry.createdAt if (!Number.isFinite(stamp) || stamp <= 0) return null const relative = formatRelativeTime(stamp, t) if (!relative) return null const absolute = formatAbsoluteDateTime(stamp) const ariaLabel = t('scratchlist.entry.lastSavedAriaLabel', { time: relative }) const title = absolute ? `${t('scratchlist.entry.lastSaved', { time: relative })}\n${absolute}` : t('scratchlist.entry.lastSaved', { time: relative }) return ( ) } function ClipboardCheckIcon() { return ( ) } /** * Tracks which entry was most-recently copied to the clipboard so the UI * can briefly swap the copy icon to a check + the tooltip to "Copied". * Auto-clears after `clearAfterMs` (default 1500ms). Pure state machine - * the caller wires `safeCopyToClipboard` separately so the hook stays * easy to test and free of jsdom clipboard quirks. */ const COPIED_FEEDBACK_MS = 1500 function useCopiedFeedback(clearAfterMs: number = COPIED_FEEDBACK_MS) { const [copiedEntryId, setCopiedEntryId] = useState(null) const timerRef = useRef | null>(null) const signalCopied = useCallback((entryId: string) => { setCopiedEntryId(entryId) if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => setCopiedEntryId(null), clearAfterMs) }, [clearAfterMs]) useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, []) return { copiedEntryId, signalCopied } } /** * Inventory list with per-entry action buttons. Pure presentational - takes * entries + callbacks. Used by both the always-visible ScratchlistPanel * and the composer-controlled drawer below. */ function ScratchlistAttachmentThumbnails(props: { sessionId: string api: ApiClient attachments: ScratchlistAttachmentMetadata[] }) { const [urls, setUrls] = useState>([]) useEffect(() => { let cancelled = false const created: string[] = [] void (async () => { const next: Array<{ id: string; url: string; filename: string }> = [] for (const attachment of props.attachments) { if (!isImageMimeType(attachment.mimeType)) continue try { const blob = await props.api.fetchScratchlistAttachmentBlob(props.sessionId, attachment.id) const url = URL.createObjectURL(blob) created.push(url) next.push({ id: attachment.id, url, filename: attachment.filename }) } catch { // Non-fatal: entry still shows text/actions. } } if (!cancelled) { setUrls(next) } else { for (const url of created) URL.revokeObjectURL(url) } })() return () => { cancelled = true setUrls((prev) => { for (const item of prev) URL.revokeObjectURL(item.url) return [] }) } }, [props.api, props.sessionId, props.attachments]) if (urls.length === 0) return null return (
{urls.map((item) => ( {item.filename} ))}
) } function ScratchlistInventory({ entries, busyEntryId, onPromoteToComposer, onPromoteToQueue, onDelete, onMove, sessionId, api, disabled = false, }: { entries: ScratchlistEntry[] busyEntryId: string | null onPromoteToComposer: (entry: ScratchlistEntry) => void | Promise onPromoteToQueue: (entry: ScratchlistEntry) => void | Promise onDelete: (entry: ScratchlistEntry) => void onMove: (entry: ScratchlistEntry, direction: 'up' | 'down') => void sessionId?: string api?: ApiClient disabled?: boolean }) { const { t } = useTranslation() const { copiedEntryId, signalCopied } = useCopiedFeedback() const handleCopy = useCallback(async (entry: ScratchlistEntry) => { try { await safeCopyToClipboard(entry.text) signalCopied(entry.id) } catch { // safeCopyToClipboard exhausted both the navigator.clipboard // path and the execCommand fallback; nothing useful left to do. // Silently no-op rather than throw at the click handler. } }, [signalCopied]) if (entries.length === 0) { return (

{t('scratchlist.emptyHint')}

) } return (
    {entries.map((entry, index) => { const isFirst = index === 0 const isLast = index === entries.length - 1 const isBusy = busyEntryId === entry.id const mutationsDisabled = disabled || isBusy return (
  • {sessionId && api && entry.attachments && entry.attachments.length > 0 ? ( ) : null}

    {entry.text || (entry.attachments?.length ? t('scratchlist.attachmentOnly') : '')}

  • ) })}
) } /** * Composer-controlled drawer. No own header / no own textarea: the composer * is the input source (composerSendsToScratchlist toggle in SessionChat). * * State is owned by the caller via useScratchlist(). The drawer is purely * presentational + behavior glue around the inventory list. */ export function ScratchlistDrawer({ entries, onMove, onDelete, onPromoteToComposer, onPromoteToQueue, sessionId, api, disabled = false, }: { entries: ScratchlistEntry[] onMove: (id: string, direction: 'up' | 'down') => void onDelete: (id: string) => void onPromoteToComposer: (entry: ScratchlistEntry) => void | Promise onPromoteToQueue: (entry: ScratchlistEntry) => Promise sessionId: string api: ApiClient disabled?: boolean }) { const { t } = useTranslation() const [busyEntryId, setBusyEntryId] = useState(null) const summary = useMemo(() => { if (entries.length === 0) return t('scratchlist.empty') if (entries.length === 1) return t('scratchlist.count.one') return t('scratchlist.count.other', { n: entries.length }) }, [entries.length, t]) const handleDelete = useCallback((entry: ScratchlistEntry) => { if (disabled) return if (shouldConfirmDelete(entry)) { const confirmed = typeof window !== 'undefined' ? window.confirm(t('scratchlist.confirmDelete')) : true if (!confirmed) return } onDelete(entry.id) }, [disabled, onDelete, t]) const handleMove = useCallback((entry: ScratchlistEntry, direction: 'up' | 'down') => { if (disabled) return onMove(entry.id, direction) }, [disabled, onMove]) const handlePromoteToComposer = useCallback((entry: ScratchlistEntry) => { if (disabled) return void onPromoteToComposer(entry) }, [disabled, onPromoteToComposer]) const handlePromoteToQueue = useCallback(async (entry: ScratchlistEntry) => { if (disabled || busyEntryId) return setBusyEntryId(entry.id) try { const accepted = await onPromoteToQueue(entry) if (accepted) onDelete(entry.id) } finally { setBusyEntryId(null) } }, [busyEntryId, disabled, onDelete, onPromoteToQueue]) return (
{t('scratchlist.title')} {summary}

{t('scratchlist.drawerHint')}

) } /** * Per-session scratchlist (issue #11) -- the operator's "workbench". * * Distinct from the queue (`QueuedMessagesBar`): * - Queue = conveyor belt: messages auto-fire in order once the agent is idle. * - Scratchlist = workbench: notes / drafts / parking-lot ideas held until the * operator explicitly promotes them (to the composer or into the queue). * * The "held -- not sent" pill plus a subtle amber border is the visual * signal that nothing here is being sent without an explicit action. The * panel surface mirrors the user-message chat surface so it stays calm in * the scroll; the strong amber destination signal lives on the composer * Send button (which only goes amber while scratchlist mode is routing). */ export function ScratchlistPanel({ sessionId, onPromoteToComposer, onPromoteToQueue, }: { sessionId: string /** * Copies the entry text into the composer for editing. Called with the * raw entry text. Implementation lives in SessionChat (it owns the * AssistantUI runtime that exposes setText). */ onPromoteToComposer: (text: string) => void /** * Sends the entry into the existing send-queue (same path as a normal * composer send). Resolves true when the send was accepted, false when * pre-mutation guards rejected it -- matches the contract of * useSendMessage.sendMessage so the UI knows whether to remove the * scratchlist entry on success. */ onPromoteToQueue: (text: string) => Promise }) { const { t } = useTranslation() const [entries, setEntries] = useState(() => readScratchlist(sessionId)) const [collapsed, setCollapsed] = useState(() => readCollapsedPref(sessionId)) const [draft, setDraft] = useState('') const [busyEntryId, setBusyEntryId] = useState(null) const inputRef = useRef(null) const { copiedEntryId, signalCopied } = useCopiedFeedback() const handleCopy = useCallback(async (entry: ScratchlistEntry) => { try { await safeCopyToClipboard(entry.text) signalCopied(entry.id) } catch { // see ScratchlistInventory.handleCopy for rationale } }, [signalCopied]) // Re-hydrate when the session id changes (route navigation between sessions). useEffect(() => { setEntries(readScratchlist(sessionId)) setCollapsed(readCollapsedPref(sessionId)) setDraft('') setBusyEntryId(null) }, [sessionId]) // Persist on every change. The storage layer swallows quota / serialization // errors so this won't throw. useEffect(() => { persistScratchlist(sessionId, entries) }, [sessionId, entries]) // Global keyboard shortcut: Ctrl/Cmd + Shift + S focuses the add-input // and expands the panel. Suggested by the handoff doc; matches the // convention used by other composer-adjacent shortcuts (Ctrl/Cmd-m for // model cycling) so it shouldn't collide with browser defaults that the // app cares about. useEffect(() => { const onKeyDown = (e: globalThis.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'S' || e.key === 's')) { e.preventDefault() setCollapsed(false) writeCollapsedPref(sessionId, false) queueMicrotask(() => inputRef.current?.focus()) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [sessionId]) const toggleCollapsed = useCallback(() => { setCollapsed((prev) => { const next = !prev writeCollapsedPref(sessionId, next) return next }) }, [sessionId]) const handleAdd = useCallback((rawText: string) => { setEntries((prev) => addScratchlistEntry(prev, rawText).entries) setDraft('') }, []) const handleSubmit = useCallback((event: ReactFormEvent) => { event.preventDefault() handleAdd(draft) }, [draft, handleAdd]) const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { // Plain Enter adds; Shift+Enter inserts a newline. Mirrors the // composer's default keyboard-send behavior so muscle memory carries // over and reduces accidental newlines in scratchlist titles. if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() handleAdd(draft) } }, [draft, handleAdd]) const handleDelete = useCallback((entry: ScratchlistEntry) => { if (shouldConfirmDelete(entry)) { const confirmed = typeof window !== 'undefined' ? window.confirm(t('scratchlist.confirmDelete')) : true if (!confirmed) return } setEntries((prev) => deleteScratchlistEntry(prev, entry.id)) }, [t]) const handleMove = useCallback((entry: ScratchlistEntry, direction: 'up' | 'down') => { setEntries((prev) => moveScratchlistEntry(prev, entry.id, direction)) }, []) const handlePromoteToComposer = useCallback((entry: ScratchlistEntry) => { onPromoteToComposer(entry.text) // Promote-to-composer is a copy, not a move: the entry stays in the // scratchlist so the operator can iterate. Promote-to-queue is the // destructive variant. }, [onPromoteToComposer]) const handlePromoteToQueue = useCallback(async (entry: ScratchlistEntry) => { if (busyEntryId) return setBusyEntryId(entry.id) try { const accepted = await onPromoteToQueue(entry.text) if (accepted) { setEntries((prev) => deleteScratchlistEntry(prev, entry.id)) } } finally { setBusyEntryId(null) } }, [busyEntryId, onPromoteToQueue]) const summary = useMemo(() => { if (entries.length === 0) return t('scratchlist.empty') if (entries.length === 1) return t('scratchlist.count.one') return t('scratchlist.count.other', { n: entries.length }) }, [entries.length, t]) const hasReachedCap = entries.length >= SCRATCHLIST_MAX_ENTRIES return (
{/* * `inert` removes the inner controls from the focus and * pointer-events tree (and the accessibility tree) while * collapsed. CSS-only collapse left the textarea + buttons * focusable under aria-hidden, which is the regression * flagged by the upstream PR review (a11y violation: * focusable descendants inside an aria-hidden subtree). * Using inert preserves the grid-template-rows expand * animation while keeping the collapsed body unreachable. */}