返回 DeepSeek-Reasonix
ComposerInboxQueue.tsx
根目录 / desktop / frontend / src / components / ComposerInboxQueue.tsx
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
128 lines Plain Text