| 1 | // Queue layout and drag mechanics adapted from ZCode (Apache-2.0). |
| 2 | // Reasonix retains the original queue identity and an independent edit draft. |
| 3 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 4 | import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type Modifier } from "@dnd-kit/core"; |
| 5 | import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"; |
| 6 | import { ChevronDown, ChevronUp, MoreHorizontal, Paperclip, Pause, Play, Square } from "lucide-react"; |
| 7 | import { AnchoredPopover } from "./AnchoredPopover"; |
| 8 | import type { PendingGuidance } from "./ComposerGuidanceShelf"; |
| 9 | import type { InboxSnapshotLike } from "../lib/composerInboxQueue"; |
| 10 | import { queueMoveAnchor, queuePending } from "../lib/inboxQueueCommands"; |
| 11 | import { useInboxQueueEditor } from "../lib/useInboxQueueEditor"; |
| 12 | import { inboxQueueCopy } from "../lib/inboxQueueCopy"; |
| 13 | import { useI18n } from "../lib/i18n"; |
| 14 | import { InboxQueueRow } from "./InboxQueueRow"; |
| 15 | import { writeClipboardText } from "../lib/clipboard"; |
| 16 | import "./ComposerInboxQueue.css"; |
| 17 | |
| 18 | // Same constrained vertical drag as ZCode; preserve dimensions while moving. |
| 19 | const restrictToQueue: Modifier = ({ transform, draggingNodeRect, activeNodeRect, containerNodeRect, windowRect }) => { |
| 20 | const node = draggingNodeRect ?? activeNodeRect, boundary = containerNodeRect ?? windowRect; |
| 21 | return { ...transform, x: 0, y: node && boundary ? Math.min(Math.max(transform.y, boundary.top - node.top), boundary.bottom - node.bottom) : transform.y }; |
| 22 | }; |
| 23 | |
| 24 | export function ComposerInboxQueue({ scope, tabId, sessionPath, items, snapshot, disabled, running, turnId, onSnapshot, onRefresh, onEditingChange, onRestoreDraft, onStop, stopDisabled }: { |
| 25 | scope: string; tabId: string; sessionPath: string; items: PendingGuidance[]; snapshot?: InboxSnapshotLike; |
| 26 | disabled: boolean; running: boolean; onSnapshot: (snapshot: InboxSnapshotLike) => unknown; |
| 27 | onRefresh: () => void; onEditingChange: (active: boolean) => void; turnId?: string; |
| 28 | onRestoreDraft: (text: string) => void; onStop?: () => void; stopDisabled?: boolean; |
| 29 | }) { |
| 30 | const { locale } = useI18n(); |
| 31 | const copy = inboxQueueCopy(locale); |
| 32 | const editor = useInboxQueueEditor(scope, tabId, sessionPath, onSnapshot, onRefresh); |
| 33 | const [expanded, setExpanded] = useState(false); |
| 34 | const [menu, setMenu] = useState(false); |
| 35 | const menuAnchor = useRef<HTMLButtonElement>(null); |
| 36 | const [dragError, setDragError] = useState(false); |
| 37 | const [copied, setCopied] = useState(false); |
| 38 | const dragRevision = useRef<number | undefined>(undefined); |
| 39 | const editInput = useRef<HTMLTextAreaElement>(null); |
| 40 | const queueRoot = useRef<HTMLElement>(null); |
| 41 | const wasEditing = useRef(false); |
| 42 | const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); |
| 43 | const pending = items.filter(item => queuePending(item.state)); |
| 44 | const accepted = items.filter(item => item.state === "steer_accepted"); |
| 45 | const ids = pending.map(item => item.id); |
| 46 | const draft = editor.open ? editor.drafts.edit : undefined; |
| 47 | // Editing preserves the user's disclosure choice; a restored draft must |
| 48 | // remain reachable even when it belongs to a normally collapsed row. |
| 49 | const visible = expanded ? pending : pending.filter((item, index) => index < 2 || item.id === draft?.id); |
| 50 | const locked = disabled || Boolean(snapshot?.readonly) || snapshot?.mutationsSupported !== true || editor.busy; |
| 51 | const currentItem = draft && snapshot?.items?.find(item => item.id === draft.id); |
| 52 | const departed = Boolean(draft && snapshot && (!currentItem || !queuePending(currentItem.state))); |
| 53 | const reason = draft?.reason || (departed ? "item_not_pending" : ""); |
| 54 | const notice = dragError ? "order_changed" : editor.notice === reason ? "" : editor.notice; |
| 55 | const message = (key: string) => copy[key as keyof typeof copy] || copy.error; |
| 56 | useLayoutEffect(() => { onEditingChange(Boolean(draft)); return () => onEditingChange(false); }, [Boolean(draft), onEditingChange]); |
| 57 | useEffect(() => { if (draft && !editor.busy) editInput.current?.focus(); }, [draft?.id, editor.busy, departed]); |
| 58 | useEffect(() => { |
| 59 | const restoreFocus = wasEditing.current && !draft; |
| 60 | wasEditing.current = Boolean(draft); |
| 61 | if (!restoreFocus) return; |
| 62 | const composer = queueRoot.current?.closest(".composer-wrap")?.querySelector<HTMLElement>("#composer-input"); |
| 63 | const frame = requestAnimationFrame(() => { if (composer?.isConnected) composer.focus({ preventScroll: true }); }); |
| 64 | return () => cancelAnimationFrame(frame); |
| 65 | }, [Boolean(draft)]); |
| 66 | const move = (id: string, before: string | null) => { |
| 67 | setDragError(false); |
| 68 | void editor.mutate({ kind: "move", itemId: id, beforeItemId: before, queueRevision: snapshot?.revision }); |
| 69 | }; |
| 70 | const editForm = draft && <div className="inbox-queue__editor" |
| 71 | onKeyDown={event => { |
| 72 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 73 | if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); editor.close(); } |
| 74 | if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); if (!locked && !reason) void editor.save(); } |
| 75 | }}> |
| 76 | <textarea ref={editInput} rows={3} value={draft.value} aria-label={copy.editing} disabled={editor.busy} onChange={event => editor.update(event.target.value)} /> |
| 77 | {!!draft.references.length && <div className="inbox-queue__references">{draft.references.map((ref, i) => <span key={`${ref}:${i}`}><Paperclip size={13} />{ref}</span>)}</div>} |
| 78 | {reason && <p className="inbox-queue__notice inbox-queue__notice--warning" role="alert">{message(reason)}</p>} |
| 79 | <footer> |
| 80 | <button type="button" className="btn btn--small" disabled={editor.busy} onClick={editor.close}>{copy.cancel}</button> |
| 81 | {reason ? <><button type="button" className="btn btn--small" disabled={editor.busy} onClick={() => void editor.reload()}>{copy.reload}</button><button type="button" className="btn btn--primary" disabled={editor.busy} onClick={() => { const value = editor.recover(); if (value !== undefined) onRestoreDraft(value); }}>{copy.recover}</button></> |
| 82 | : <button type="button" className="btn btn--primary" disabled={locked || !draft.value.trim()} title={copy.shortcut} onClick={() => void editor.save()}>{copy.save}</button>} |
| 83 | </footer> |
| 84 | </div>; |
| 85 | if (!items.length && !editor.drafts.edit && !editor.drafts.recovered.length) return null; |
| 86 | return <> |
| 87 | {draft && running && <div className="inbox-queue__running"><span>{copy.currentRunning}</span>{onStop && <button type="button" className="btn btn--small" disabled={stopDisabled} onClick={onStop}><Square size={13} />{copy.stop}</button>}</div>} |
| 88 | <section ref={queueRoot} className={`inbox-queue${draft ? " inbox-queue--editing" : ""}`} aria-label={copy.title}> |
| 89 | {accepted.length > 0 && <div className="inbox-queue__accepted">{copy.inFlight} · {accepted.map(item => item.text).join(" · ")}</div>} |
| 90 | <header className="inbox-queue__head"> |
| 91 | {pending.length > 2 && <button type="button" className="inbox-queue__icon" onClick={() => setExpanded(!expanded)} aria-label={expanded ? copy.collapse : copy.expand} aria-expanded={expanded}>{expanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}</button>} |
| 92 | <strong title={copy.after}>{copy.title} <span>{pending.length}</span></strong> |
| 93 | {snapshot?.paused && <span className="inbox-queue__hint">{copy.paused}</span>} |
| 94 | {snapshot?.paused && <button type="button" className="btn btn--small" disabled={locked} onClick={() => void editor.mutate({ kind: "pause", paused: false })}><Play size={13} />{copy.resume}</button>} |
| 95 | {pending.length > 0 && <div className="inbox-queue__more" onKeyDown={event => { if (event.key === "Escape") { setMenu(false); event.stopPropagation(); } }}> |
| 96 | <button ref={menuAnchor} type="button" className="inbox-queue__icon" aria-label={copy.queueMore} aria-expanded={menu} disabled={locked} onClick={() => setMenu(!menu)}><MoreHorizontal size={16} /></button> |
| 97 | <AnchoredPopover open={menu} anchorRef={menuAnchor} onClose={() => setMenu(false)} className="inbox-queue__menu" align="end"> |
| 98 | <button type="button" disabled={locked} onClick={() => { setMenu(false); void editor.mutate({ kind: "pause", paused: !snapshot?.paused }); }}>{snapshot?.paused ? <Play size={13} /> : <Pause size={13} />}{snapshot?.paused ? copy.resume : copy.pause}</button> |
| 99 | </AnchoredPopover> |
| 100 | </div>} |
| 101 | </header> |
| 102 | {snapshot?.mutationsSupported === false && <p className="inbox-queue__notice">{copy.unsupported}</p>} |
| 103 | {snapshot?.readonly && <p className="inbox-queue__notice">{copy.read_only}</p>} |
| 104 | <DndContext sensors={sensors} collisionDetection={closestCenter} modifiers={[restrictToQueue]} |
| 105 | onDragStart={() => { dragRevision.current = snapshot?.revision; setExpanded(true); }} |
| 106 | onDragCancel={() => { dragRevision.current = undefined; }} |
| 107 | onDragEnd={({ active, over }) => { |
| 108 | if (dragRevision.current !== snapshot?.revision) { setDragError(true); onRefresh(); return; } |
| 109 | const before = over && queueMoveAnchor(ids, String(active.id), String(over.id)); |
| 110 | if (over && before !== undefined) move(String(active.id), before); |
| 111 | dragRevision.current = undefined; |
| 112 | }}> |
| 113 | <SortableContext items={ids} strategy={verticalListSortingStrategy}> |
| 114 | <ol className="inbox-queue__list">{visible.map(item => <InboxQueueRow key={item.id} item={item} index={ids.indexOf(item.id)} ids={ids} copy={copy} |
| 115 | locked={locked} editing={draft?.id === item.id} onEdit={() => void editor.edit(item.id)} onMove={before => move(item.id, before)} |
| 116 | onDelete={() => void editor.mutate({ kind: "delete", itemId: item.id })} onRetry={() => void editor.mutate({ kind: "retry", itemId: item.id })} |
| 117 | onGuide={running && turnId ? () => void editor.mutate({ kind: "steer", itemId: item.id, turnId }) : undefined}>{draft?.id === item.id ? editForm : null}</InboxQueueRow>)}</ol> |
| 118 | </SortableContext> |
| 119 | </DndContext> |
| 120 | {notice && <p className="inbox-queue__notice" role="status">{message(notice)}</p>} |
| 121 | {/* A dispatch may remove the row while it is being edited. Keep its input |
| 122 | reachable as a recovery surface, never disguise it as a pending row. */} |
| 123 | {draft && !ids.includes(draft.id) && <div className="inbox-queue__departed">{editForm}</div>} |
| 124 | {!draft && editor.drafts.edit && <button type="button" className="btn btn--small" disabled={editor.busy} onClick={editor.show}>{copy.editing} · {copy.retained}</button>} |
| 125 | {!!editor.drafts.recovered.length && <details className="inbox-queue__drafts"><summary>{copy.recovered} ({editor.drafts.recovered.length})</summary>{editor.drafts.recovered.map((saved, i) => <div key={i}><span>{saved.value.slice(0, 100)}</span><button type="button" className="btn btn--small" disabled={editor.busy} onClick={() => { editor.close(); onRestoreDraft(saved.value); }}>{copy.restore}</button><button type="button" className="btn btn--small" onClick={() => void writeClipboardText(saved.value).then(setCopied)}>{copy.copy}</button></div>)}{copied && <span role="status">{copy.copied}</span>}</details>} |
| 126 | </section></>; |
| 127 | } |
| 128 |