@xgspz/cli-ui 0.0.0-stage → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +517 -2
  3. package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
  4. package/dist/cli-ui-index-R24x28OX.js +11209 -0
  5. package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
  6. package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
  7. package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +960 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +581 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1109 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +507 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1952 -0
  149. package/src/operator.css +1952 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { jget } from "./api";
6
+ import type { PaletteTaskRow, ProjectRow } from "./types";
7
+ import { StatusDot } from "./shared";
8
+
9
+ // One executable command surfaced by the palette. Callers pass only the
10
+ // commands that currently make sense (e.g. no "Toggle Terminal" without a
11
+ // project selected), so the palette never renders a dead row.
12
+ export interface PaletteCommand {
13
+ id: string;
14
+ label: string;
15
+ hint?: string; // right-aligned annotation, e.g. what state the toggle lands on
16
+ keywords?: string; // aliases the fuzzy matcher sees but the row doesn't show
17
+ icon: React.ReactNode;
18
+ run: () => void;
19
+ }
20
+
21
+ // Subsequence fuzzy matcher. Exact-substring hits dominate (earlier = better,
22
+ // word-start better still); otherwise every query char must appear in order,
23
+ // with bonuses for word starts and consecutive runs and a small gap penalty.
24
+ // Returns -Infinity when the query doesn't match at all.
25
+ export function fuzzyScore(query: string, text: string): number {
26
+ const q = query.toLowerCase().trim();
27
+ if (!q) return 0;
28
+ const t = text.toLowerCase();
29
+ const sub = t.indexOf(q);
30
+ if (sub >= 0) return 1000 - sub + (sub === 0 || /[\s\-_/·.]/.test(t[sub - 1]) ? 50 : 0);
31
+ let score = 0;
32
+ let from = 0;
33
+ let prev = -2;
34
+ for (const ch of q) {
35
+ if (ch === " ") continue; // spaces in the query just separate words
36
+ const at = t.indexOf(ch, from);
37
+ if (at < 0) return -Infinity;
38
+ score += 1;
39
+ if (at === prev + 1) score += 2; // consecutive run
40
+ if (at === 0 || /[\s\-_/·.]/.test(t[at - 1])) score += 3; // word start
41
+ score -= Math.min(10, at - from) * 0.05;
42
+ prev = at;
43
+ from = at + 1;
44
+ }
45
+ return score;
46
+ }
47
+
48
+ type Entry =
49
+ | { kind: "project"; project: ProjectRow }
50
+ | { kind: "task"; task: PaletteTaskRow }
51
+ | { kind: "command"; command: PaletteCommand };
52
+
53
+ const entryKey = (e: Entry) =>
54
+ e.kind === "project" ? `p:${e.project.id}` : e.kind === "task" ? `t:${e.task.id}` : `c:${e.command.id}`;
55
+
56
+ // With no query the palette is a launcher (a few recent things + every command);
57
+ // once you type, it's a search (more room for matches per group).
58
+ const EMPTY_LIMITS = { project: 5, task: 6 };
59
+ const QUERY_LIMITS = { project: 6, task: 10 };
60
+
61
+ // The ⌘K command palette: fuzzy search over projects, sessions (tasks across
62
+ // ALL active projects) and commands, grouped, keyboard-first. The overlay is a
63
+ // centered top sheet over a scrim — Esc or an outside click dismisses, ↑/↓
64
+ // move, ⏎ runs the active row. Every color comes from theme vars, so it works
65
+ // in both themes for free.
66
+ export function CommandPalette({ projects, commands, onPickProject, onPickTask, onClose }: {
67
+ projects: ProjectRow[];
68
+ commands: PaletteCommand[];
69
+ onPickProject: (projectId: string) => void;
70
+ onPickTask: (projectId: string, taskId: string) => void;
71
+ onClose: () => void;
72
+ }) {
73
+ const [query, setQuery] = useState("");
74
+ const [active, setActive] = useState(0);
75
+ const [tasks, setTasks] = useState<PaletteTaskRow[]>([]);
76
+ const listRef = useRef<HTMLDivElement>(null);
77
+
78
+ // Sessions across every project are server state (the client only holds the
79
+ // selected project's tasks) — fetched fresh each open, like NeedsYouMenu.
80
+ useEffect(() => {
81
+ let alive = true;
82
+ jget<{ tasks: PaletteTaskRow[] }>("/api/tasks")
83
+ .then((d) => { if (alive) setTasks(d.tasks); })
84
+ .catch(() => {});
85
+ return () => { alive = false; };
86
+ }, []);
87
+
88
+ const { sections, flat } = useMemo(() => {
89
+ const trimmed = query.trim();
90
+ const limits = trimmed ? QUERY_LIMITS : EMPTY_LIMITS;
91
+ const rank = <T,>(items: T[], text: (i: T) => string, limit: number): T[] => {
92
+ if (!trimmed) return items.slice(0, limit);
93
+ return items
94
+ .map((i) => ({ i, s: fuzzyScore(trimmed, text(i)) }))
95
+ .filter((x) => x.s > -Infinity)
96
+ .sort((a, b) => b.s - a.s)
97
+ .slice(0, limit)
98
+ .map((x) => x.i);
99
+ };
100
+ const groups: { title: string; entries: Entry[] }[] = [
101
+ { title: "项目", entries: rank(projects, (p) => `${p.name} ${p.sub}`, limits.project).map((project) => ({ kind: "project", project })) },
102
+ { title: "会话", entries: rank(tasks, (t) => `${t.title} ${t.project_name}`, limits.task).map((task) => ({ kind: "task", task })) },
103
+ { title: "命令", entries: rank(commands, (c) => `${c.label} ${c.keywords ?? ""}`, commands.length).map((command) => ({ kind: "command", command })) },
104
+ ];
105
+ // Flatten in display order so ↑/↓ walk straight through the groups.
106
+ const flat: Entry[] = groups.flatMap((g) => g.entries);
107
+ let idx = 0;
108
+ const sections = groups
109
+ .filter((g) => g.entries.length > 0)
110
+ .map((g) => ({ title: g.title, rows: g.entries.map((entry) => ({ entry, idx: idx++ })) }));
111
+ return { sections, flat };
112
+ }, [query, projects, tasks, commands]);
113
+
114
+ // Typing (or the async session load) reshapes the list — snap the highlight
115
+ // back to the top / into range rather than leaving it on a stale row.
116
+ useEffect(() => { setActive(0); }, [query]);
117
+ useEffect(() => { if (active >= flat.length) setActive(0); }, [active, flat.length]);
118
+
119
+ const run = (e: Entry) => {
120
+ if (e.kind === "project") onPickProject(e.project.id);
121
+ else if (e.kind === "task") onPickTask(e.task.project_id, e.task.id);
122
+ else e.command.run();
123
+ onClose();
124
+ };
125
+
126
+ // Keyboard driving. A window *capture* listener so it wins regardless of
127
+ // focus, and so Escape can stopPropagation before any stacked Modal's own
128
+ // bubble-phase Escape handler sees the event (the palette may sit on top).
129
+ useEffect(() => {
130
+ const onKey = (e: KeyboardEvent) => {
131
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); return; }
132
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (flat.length ? (a + 1) % flat.length : 0)); return; }
133
+ if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (flat.length ? (a - 1 + flat.length) % flat.length : 0)); return; }
134
+ if (e.key === "Enter") { e.preventDefault(); const cur = flat[active]; if (cur) run(cur); }
135
+ };
136
+ window.addEventListener("keydown", onKey, true);
137
+ return () => window.removeEventListener("keydown", onKey, true);
138
+ });
139
+
140
+ useEffect(() => {
141
+ listRef.current?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: "nearest" });
142
+ }, [active]);
143
+
144
+ const row = (entry: Entry, idx: number) => {
145
+ const props = {
146
+ "data-idx": idx,
147
+ className: `pal-row${idx === active ? " on" : ""}`,
148
+ onMouseEnter: () => setActive(idx),
149
+ onClick: () => run(entry),
150
+ };
151
+ if (entry.kind === "project") {
152
+ const p = entry.project;
153
+ return (
154
+ <button key={entryKey(entry)} {...props}>
155
+ <span className="pr-chip" style={{ background: p.color }}>{(p.icon || p.name[0] || "?").toUpperCase()}</span>
156
+ <span className="pr-title">{p.name}</span>
157
+ {p.sub && <span className="pr-sub">{p.sub}</span>}
158
+ {idx === active && <span className="pr-hint">⏎ 打开</span>}
159
+ </button>
160
+ );
161
+ }
162
+ if (entry.kind === "task") {
163
+ const t = entry.task;
164
+ return (
165
+ <button key={entryKey(entry)} {...props}>
166
+ <span className="pr-chip" style={{ background: t.project_color }} title={t.project_name}>
167
+ {(t.project_icon || t.project_name[0] || "?").toUpperCase()}
168
+ </span>
169
+ <StatusDot status={t.status} running={t.running === 1} awaiting={t.awaiting_input === 1 && t.running !== 1} />
170
+ <span className="pr-title">{t.title}</span>
171
+ <span className="pr-sub">{t.project_name}</span>
172
+ {idx === active && <span className="pr-hint">⏎ 打开</span>}
173
+ </button>
174
+ );
175
+ }
176
+ const c = entry.command;
177
+ return (
178
+ <button key={entryKey(entry)} {...props}>
179
+ <span className="pr-ic">{c.icon}</span>
180
+ <span className="pr-title">{c.label}</span>
181
+ {c.hint && <span className="pr-sub">{c.hint}</span>}
182
+ {idx === active && <span className="pr-hint">⏎ 执行</span>}
183
+ </button>
184
+ );
185
+ };
186
+
187
+ return (
188
+ <div className="palette-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
189
+ <div className="palette">
190
+ <div className="palette-input">
191
+ <span className="p-ic">{Icon.search()}</span>
192
+ <input
193
+ autoFocus
194
+ value={query}
195
+ placeholder="跳转到项目或会话,或执行命令…"
196
+ spellCheck={false}
197
+ onChange={(e) => setQuery(e.target.value)}
198
+ />
199
+ <span className="palette-kbd">esc</span>
200
+ </div>
201
+ <div className="palette-list" ref={listRef}>
202
+ {flat.length === 0 && <div className="palette-empty">没有匹配“{query.trim()}”的结果。</div>}
203
+ {sections.map((sec) => (
204
+ <div key={sec.title}>
205
+ <div className="pop-sec">{sec.title}</div>
206
+ {sec.rows.map((r) => row(r.entry, r.idx))}
207
+ </div>
208
+ ))}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,316 @@
1
+ "use client";
2
+
3
+ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import { DOCUMENT_MAX_BYTES, FILE_UPLOAD_ACCEPT, isOriginalDocument, uploadFileType, UPLOAD_OTHER_MAX_BYTES } from "../../core/documentUpload";
5
+ import { Popover } from "./shared";
6
+ import type { TaskRow } from "./types";
7
+ import { Icon } from "./icons";
8
+ import { attachmentMarker, fileAttachmentMarker } from "./format";
9
+ import { MAX_MESSAGE_CHARS, PASTE_ATTACH_THRESHOLD } from "./protocol/promptLimits";
10
+ import { RunControls, type RunControlProps } from "./RunControls";
11
+ import { appendBoardContext } from "../../board/context";
12
+ import { readBoardState, useBoardState } from "./boardState";
13
+ import { clientId } from "../../core/id";
14
+ import { useComposerAutosize } from "../../ui/useComposerAutosize";
15
+ import { ReferenceCard } from "../../ui/ReferenceCard";
16
+ import { UploadProgress } from "../../ui/UploadProgress";
17
+ import { Markdown } from "./Markdown";
18
+ import { FileLinkContext } from "./fileLinks";
19
+ import { isConfigurationBusy, useConfigurationBusy } from "../../chat/configuration";
20
+ import { acknowledgeDraft, DRAFT_EVENT, isTaskSending, setTaskSending, readDraft, writeDraft, quoteText, type ComposerDraft, type Quote } from "./composerDraft";
21
+ import { getOperatorBoardBackend } from "./boardBackend";
22
+ import { OperatorBoardContext, openOperatorBoard } from "./boardInteraction";
23
+
24
+ export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork, queued, fileUpload = true }: {
25
+ task: TaskRow; agentLabel: string; disabled: boolean; running: boolean;
26
+ tasks: TaskRow[]; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
27
+ onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>; onClear: () => void;
28
+ configBusy: boolean; quoteRequest: Quote | null; onQuoteHandled: () => void; controls: RunControlProps;
29
+ queued?: ReactNode;
30
+ fileUpload?: boolean;
31
+ }) {
32
+ const boardView=useBoardState(task.id);
33
+ const [draft, setDraft] = useState(() => readDraft(task.id));
34
+ const draftRef = useRef(draft);
35
+ const [addOpen, setAddOpen] = useState(false);
36
+ const [caret, setCaret] = useState(0);
37
+ const [mentionClosed, setMentionClosed] = useState(false);
38
+ const [mentionIndex, setMentionIndex] = useState(0);
39
+ const [slash, setSlash] = useState(false);
40
+ const [sending, setSending] = useState(() => isTaskSending(task.id));
41
+ const [stopping, setStopping] = useState(false);
42
+ const sendingRef = useRef(false);
43
+ const stoppingRef = useRef(false);
44
+ const [error, setError] = useState("");
45
+ const [dragging, setDragging] = useState(false);
46
+ const dragDepth = useRef(0);
47
+ const fileRef = useRef<HTMLInputElement>(null);
48
+ const ref = useRef<HTMLTextAreaElement>(null);
49
+ const caretRequest = useRef<number | null>(null);
50
+ const composing = useRef(false);
51
+ const uploads = useRef(new Map<string, AbortController>());
52
+ const uploadFiles = useRef(new Map<string, File>());
53
+ const backend = useMemo(() => getOperatorBoardBackend(), []);
54
+ const parentBoard = useContext(OperatorBoardContext);
55
+ const uploadScope = useRef({ taskId: task.id, openBoard: parentBoard ?? openOperatorBoard });
56
+ const previews = useRef(new Set<string>());
57
+ const mounted = useRef(true);
58
+ const update = (fn: (prev: ComposerDraft) => ComposerDraft) => {
59
+ const next = fn(draftRef.current);
60
+ draftRef.current = next;
61
+ writeDraft(task.id, next);
62
+ if (mounted.current) setDraft(next);
63
+ };
64
+ useLayoutEffect(() => { uploadScope.current = { taskId: task.id, openBoard: parentBoard ?? openOperatorBoard }; }, [task.id, parentBoard]);
65
+ useLayoutEffect(() => {
66
+ mounted.current = true;
67
+ const current = readDraft(task.id);
68
+ draftRef.current = current; setDraft(current);
69
+ const pending = uploads.current, urls = previews.current, files = uploadFiles.current;
70
+ const sync = (event: Event) => {
71
+ const change = (event as CustomEvent<{ id: string; draft?: ComposerDraft; sending?: boolean }>).detail;
72
+ if (change.id !== task.id) return;
73
+ if (change.draft) { draftRef.current = change.draft; setDraft(change.draft); }
74
+ if (change.sending !== undefined) setSending(change.sending);
75
+ };
76
+ window.addEventListener(DRAFT_EVENT, sync);
77
+ return () => {
78
+ mounted.current = false;
79
+ window.removeEventListener(DRAFT_EVENT, sync);
80
+ const interrupted = new Set(pending.keys());
81
+ pending.forEach((controller) => controller.abort());
82
+ // The in-memory draft survives task switches. An aborted upload must not
83
+ // remain "uploading" there forever or keep a revoked blob URL on remount.
84
+ if (interrupted.size) {
85
+ const current = readDraft(task.id);
86
+ writeDraft(task.id, { ...current, attachments: current.attachments.map((attachment) =>
87
+ interrupted.has(attachment.key) && attachment.status === "uploading"
88
+ ? { ...attachment, preview: "", status: "error", error: "上传已中断,请重新选择文件。" }
89
+ : attachment) });
90
+ }
91
+ pending.clear();
92
+ files.clear();
93
+ urls.forEach((url) => URL.revokeObjectURL(url));
94
+ urls.clear();
95
+ };
96
+ }, [task.id]);
97
+ useEffect(() => { if (!running) { setStopping(false); stoppingRef.current = false; } }, [running]);
98
+ useEffect(() => {
99
+ if (!quoteRequest) return;
100
+ update((d) => ({ ...d, quote: quoteRequest }));
101
+ ref.current?.focus({ preventScroll: true });
102
+ onQuoteHandled();
103
+ // Each request is a new object even when quoting the same message again.
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [quoteRequest]);
106
+ useComposerAutosize(ref, draft.text);
107
+ // A menu action hands keyboard control straight to the edited text. Deferring
108
+ // focus to rAF leaves a visible mention list whose first Escape/arrow is lost.
109
+ useLayoutEffect(() => {
110
+ const pos = caretRequest.current;
111
+ if (pos === null || !ref.current) return;
112
+ caretRequest.current = null;
113
+ ref.current.focus({ preventScroll: true });
114
+ ref.current.setSelectionRange(pos, pos);
115
+ }, [draft.text, addOpen]);
116
+
117
+ const sharedConfigBusy = useConfigurationBusy(task.id);
118
+ const locked = disabled || sending || configBusy || sharedConfigBusy;
119
+ const startUpload = (key: string, file: File) => {
120
+ if (!fileUpload || !backend.uploadFile || locked || uploads.current.has(key)) return;
121
+ const taskId = task.id;
122
+ const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
123
+ const controller = new AbortController();
124
+ uploads.current.set(key, controller);
125
+ uploadFiles.current.set(key, file);
126
+ const current = () => mounted.current && uploadScope.current.taskId === taskId && uploads.current.get(key) === controller && !controller.signal.aborted && draftRef.current.attachments.some(a => a.key === key);
127
+ update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "uploading", phase: "queued", bytes: 0, size: file.size, error: undefined } : a) }));
128
+ void Promise.resolve().then(() => backend.uploadFile!(taskId, file, {
129
+ draftId: key, signal: controller.signal,
130
+ onPhase: phase => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, phase } : a) })); },
131
+ onBytes: bytes => { if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, bytes } : a) })); },
132
+ })).then(receipt => {
133
+ if (!current()) return;
134
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试。");
135
+ const path = receipt.path;
136
+ update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, path, preview: a.kind === "image" ? receipt.url ?? backend.fileUrl(taskId, path) : "", status: "ready", bytes: file.size } : a) }));
137
+ uploadFiles.current.delete(key);
138
+ uploadScope.current.openBoard({ kind: "file", taskId, path });
139
+ }).catch((e: unknown) => {
140
+ if (current()) update(d => ({ ...d, attachments: d.attachments.map(a => a.key === key ? { ...a, status: "error", error: e instanceof Error ? e.message : "上传失败,请重试。" } : a) }));
141
+ }).finally(() => {
142
+ if (uploads.current.get(key) === controller) uploads.current.delete(key);
143
+ if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
144
+ });
145
+ };
146
+ const addFiles = (files: File[]) => {
147
+ if (locked || !fileUpload) return;
148
+ setError("");
149
+ if (!backend.uploadFile) { setError("当前服务暂不支持附件上传。"); return; }
150
+ for (const f of files) {
151
+ const type = uploadFileType(f.name);
152
+ const document = isOriginalDocument(f.name);
153
+ const isImage = !!type?.startsWith("image/");
154
+ if (!type) { setError(`“${f.name}”暂不支持,请选择图片、文档、表格或文本文件。`); continue; }
155
+ if (f.size > (document ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES)) { setError(`“${f.name}”超过 ${document ? "100 MiB" : "10 MiB"},请缩小后重试。`); continue; }
156
+ const key = clientId();
157
+ const preview = isImage ? URL.createObjectURL(f) : "";
158
+ if (preview) previews.current.add(preview);
159
+ update((d) => ({ ...d, attachments: [...d.attachments, { key, kind: isImage ? "image" : "file", name: f.name || "附件", preview, path: "", status: "uploading", phase: "queued", bytes: 0, size: f.size }] }));
160
+ startUpload(key, f);
161
+ }
162
+ };
163
+ const removeAtt = (key: string) => {
164
+ uploads.current.get(key)?.abort();
165
+ uploads.current.delete(key);
166
+ uploadFiles.current.delete(key);
167
+ const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
168
+ if (preview && previews.current.delete(preview)) URL.revokeObjectURL(preview);
169
+ update((d) => ({ ...d, attachments: d.attachments.filter((a) => a.key !== key) }));
170
+ };
171
+ const sources = draft.sources ?? [];
172
+ const mention = draft.text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
173
+ const mentionOpen = !!mention && !mentionClosed && !locked;
174
+ const candidates = tasks.filter((t) => t.project_id === task.project_id && t.status === "done" && !t.running && !!t.work_branch && !sources.some((s) => s.id === t.id) && (!mention?.[1] || t.title.toLowerCase().includes(mention[1].toLowerCase())));
175
+ const pickSource = (source: TaskRow) => {
176
+ if (sources.length >= 4 || !mention) return;
177
+ const start = caret - mention[1].length - 1;
178
+ caretRequest.current = start;
179
+ update((d) => ({ ...d, text: d.text.slice(0, start) + d.text.slice(caret), sources: [...(d.sources ?? []), { id: source.id, title: source.title }], forkRequest: undefined }));
180
+ setMentionClosed(true); setCaret(start);
181
+ };
182
+ const openMention = () => {
183
+ setAddOpen(false); setMentionClosed(false); setMentionIndex(0);
184
+ const pos = ref.current?.selectionStart ?? draft.text.length;
185
+ const prefix = pos > 0 && !/\s/.test(draft.text[pos - 1]) ? " @" : "@";
186
+ caretRequest.current = pos + prefix.length;
187
+ update((d) => ({ ...d, text: d.text.slice(0, pos) + prefix + d.text.slice(pos) }));
188
+ setCaret(pos + prefix.length);
189
+ };
190
+ const uploading = draft.attachments.some((a) => a.status === "uploading");
191
+ const failed = draft.attachments.some((a) => a.status === "error");
192
+ const readyAttachments = draft.attachments.filter(a => a.status === "ready");
193
+ const ownPayload = [draft.quote ? quoteText(draft.quote) : "", draft.text.trim(), readyAttachments.length ? "请先读取以下附件原件,再处理本条消息。" : "", ...readyAttachments.map((a) => a.kind === "image" ? attachmentMarker(a.path) : fileAttachmentMarker(a.path))].filter(Boolean).join("\n\n");
194
+ const isClear=draft.text.trim()==='/clear'&&!draft.attachments.length&&!draft.quote;
195
+ const currentSignature=JSON.stringify({payload:ownPayload,ids:sources.map(source=>source.id),...(boardView?.autoContext===false?{withoutBoardContext:true}:{})});
196
+ const pendingFork=sources.length&&draft.forkRequest?.signature===currentSignature?draft.forkRequest:null;
197
+ const payload=pendingFork?pendingFork.payload??ownPayload:ownPayload&&!isClear?appendBoardContext(ownPayload,boardView,task.id):ownPayload;
198
+ const tooLong = payload.length > MAX_MESSAGE_CHARS;
199
+ const canSend = !!payload && !uploading && !failed && !tooLong && !locked && !stopping;
200
+ const showStop = running && !sending && (stopping || locked || !payload);
201
+ const clear = () => {
202
+ if (running || locked || task.started !== 1) return;
203
+ onClear(); setSlash(false);
204
+ if (draft.text.trim() === "/clear") update((d) => ({ ...d, text: "" }));
205
+ };
206
+ const submit = async () => {
207
+ if (!canSend || stoppingRef.current || sendingRef.current || isTaskSending(task.id) || isConfigurationBusy(task.id)) return;
208
+ if (draft.text.trim() === "/clear" && !draft.attachments.length && !draft.quote) { clear(); return; }
209
+ let submittedDraft = draftRef.current;
210
+ let requestId = "";
211
+ const currentView=readBoardState(task.id);
212
+ let submittedPayload=appendBoardContext(ownPayload,currentView,task.id);
213
+ if (sources.length) {
214
+ const signature = JSON.stringify({ payload:ownPayload, ids: sources.map((s) => s.id), ...(currentView?.autoContext===false?{withoutBoardContext:true}:{}) });
215
+ const previous=submittedDraft.forkRequest?.signature===signature?submittedDraft.forkRequest:null;
216
+ requestId=previous?.id??clientId();
217
+ // Once dispatched, retries must keep the exact payload as well as the id.
218
+ // A passive page/file change is not a new user request or a new branch.
219
+ if(previous)submittedPayload=previous.payload??ownPayload;
220
+ if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
221
+ update((d) => ({ ...d, forkRequest: { id: requestId, signature, payload:submittedPayload } }));
222
+ submittedDraft = draftRef.current;
223
+ }
224
+ if(submittedPayload.length>MAX_MESSAGE_CHARS){setError("消息和看板背景合计超过长度限制,请精简消息内容后重试。");return;}
225
+ sendingRef.current = true; setTaskSending(task.id, true); setSending(true); setError("");
226
+ try {
227
+ if (await (sources.length ? onFork(submittedPayload, sources.map((s) => s.id), requestId) : onSend(submittedPayload))) {
228
+ if (mounted.current) {update(current=>acknowledgeDraft(current,submittedDraft));}
229
+ else writeDraft(task.id,acknowledgeDraft(readDraft(task.id),submittedDraft));
230
+ }
231
+ else if (mounted.current) setError("发送失败,草稿已保留,请重试。");
232
+ } catch (e) { if (mounted.current) setError(e instanceof Error ? e.message : "发送失败,草稿已保留。"); }
233
+ finally { sendingRef.current = false; setTaskSending(task.id, false); if (mounted.current) { setSending(false); ref.current?.focus(); } }
234
+ };
235
+ const stop = async () => {
236
+ if (stoppingRef.current) return;
237
+ stoppingRef.current = true; setStopping(true); setError("");
238
+ try { if (!await onStop()) throw new Error("停止失败,请重试。"); }
239
+ catch (e) { stoppingRef.current = false; if (mounted.current) { setStopping(false); setError(e instanceof Error ? e.message : "停止失败,请重试。"); } }
240
+ };
241
+ const hasFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
242
+ return <div className="composer">
243
+ <div className="composer-inner">
244
+ {mentionOpen && <div className="mention-menu" id={`mentions-${task.id}`} role="listbox" aria-label="选择派生来源">
245
+ <div className="mention-heading">从已完成任务派生新分支 <span>{sources.length}/4</span></div>
246
+ {sources.length >= 4 ? <p>最多同时派生 4 条分支,请先移除一个来源。</p> : candidates.length ? candidates.map((t, index) => <button key={t.id} id={`mention-${t.id}`} role="option" aria-selected={index === Math.min(mentionIndex, candidates.length - 1)} onMouseDown={(e) => e.preventDefault()} onClick={() => pickSource(t)}>
247
+ {Icon.git()}<span><strong>{t.title}</strong><small>{t.work_branch}</small></span>{Icon.plus()}
248
+ </button>) : <p>没有匹配的已完成任务。请先完成任务并提交代码,再从这里派生。</p>}
249
+ </div>}
250
+
251
+ {slash && !running && !locked && "/clear".startsWith(draft.text.trim()) && <div className="slash"><button className="slash-item" onClick={clear}><span className="cmd">/clear</span><span className="cd">保存摘要 · 开启新上下文</span></button></div>}
252
+ {queued}
253
+ <div className={`comp-box${dragging ? " dropping" : ""}`}
254
+ onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (fileUpload && !locked) { dragDepth.current++; setDragging(true); } } }}
255
+ onDragOver={(e) => { if (hasFileDrag(e)) e.preventDefault(); }}
256
+ onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
257
+ onDrop={(e) => { if (!hasFileDrag(e)) return; e.preventDefault(); dragDepth.current = 0; setDragging(false); addFiles(Array.from(e.dataTransfer.files)); }}>
258
+ {sources.length > 0 && <div className="fork-sources">{sources.map((source) => <span key={source.id}>{Icon.git()} 从 {source.title} 派生<button disabled={locked} aria-label={`移除来源 ${source.title}`} onClick={() => update((d) => ({ ...d, sources: d.sources?.filter((s) => s.id !== source.id), forkRequest: undefined }))}>{Icon.x()}</button></span>)}</div>}
259
+ {draft.quote && <ReferenceCard key={draft.quote.id} className="comp-quote" title={draft.quote.author} text={draft.quote.text} disabled={locked} removeLabel="移除引用" onRemove={() => { update((d) => ({ ...d, quote: null })); ref.current?.focus({ preventScroll: true }); }} renderContent={draft.quote.sourceTaskId ? text => <FileLinkContext.Provider value={{ taskId: draft.quote!.sourceTaskId! }}><Markdown>{text}</Markdown></FileLinkContext.Provider> : undefined} />}
260
+ {draft.attachments.some(a => a.status !== "ready") && <div className="composer-upload-list">{draft.attachments.filter(a => a.status !== "ready").map(a => <UploadProgress key={a.key} name={a.name}
261
+ status={a.status === "error" ? "error" : a.phase ?? "queued"} bytes={a.bytes} total={a.size} disabled={locked}
262
+ error={`${a.error || "上传失败,请重试。"}${uploadFiles.current.has(a.key) || a.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`}
263
+ onCancel={() => removeAtt(a.key)} onRemove={() => removeAtt(a.key)} onRetry={fileUpload && uploadFiles.current.has(a.key) ? () => startUpload(a.key, uploadFiles.current.get(a.key)!) : undefined} />)}</div>}
264
+ {readyAttachments.length > 0 && <div className="attach-row">{readyAttachments.map((a) => <div key={a.key} className={`attach-chip ${a.kind} ${a.status}`} title={a.name}>
265
+ {a.kind === "image" && a.preview ? <img src={a.preview} alt={a.name} /> : <span className="attach-file">{Icon.clip()} {a.name}</span>}
266
+ <button className="attach-x" disabled={locked} aria-label={`移除 ${a.name}`} onClick={() => removeAtt(a.key)}>×</button>
267
+ </div>)}</div>}
268
+ <div className="comp-area"><textarea ref={ref} rows={2} value={draft.text} disabled={locked} aria-label="消息输入框" aria-expanded={mentionOpen} aria-controls={mentionOpen ? `mentions-${task.id}` : undefined} aria-activedescendant={mentionOpen && candidates.length ? `mention-${candidates[Math.min(mentionIndex, candidates.length - 1)].id}` : undefined}
269
+ placeholder={configBusy ? "正在保存设置…" : disabled ? "启动会话后即可回复…" : sources.length ? "描述这些新分支要继续完成的工作…" : running ? "补充下一步,当前轮次结束后发送…" : `和 ${agentLabel} 一起工作`}
270
+ onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
271
+ onChange={(e) => { const text = e.target.value; update((d) => ({ ...d, text })); setSlash(text.trim().startsWith("/")); setCaret(e.target.selectionStart); setMentionClosed(false); setMentionIndex(0); }}
272
+ onSelect={(e) => setCaret(e.currentTarget.selectionStart)}
273
+ onKeyDown={(e) => {
274
+ if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return;
275
+ if (mentionOpen) {
276
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((i) => Math.max(0, Math.min(candidates.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))); return; }
277
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setMentionClosed(true); return; }
278
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (candidates.length) pickSource(candidates[Math.min(mentionIndex, candidates.length - 1)]); return; }
279
+ }
280
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); }
281
+ if (e.key === "Escape") setSlash(false);
282
+ }}
283
+ onPaste={(e) => {
284
+ const files = Array.from(e.clipboardData.files);
285
+ if (files.length) { e.preventDefault(); addFiles(files); return; }
286
+ const text = e.clipboardData.getData("text/plain");
287
+ if (fileUpload && text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
288
+ }} /></div>
289
+ <div className="comp-toolbar">
290
+ {fileUpload && <input ref={fileRef} type="file" accept={FILE_UPLOAD_ACCEPT} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
291
+ <div className="comp-control"><button className={`comp-add${addOpen ? " on" : ""}`} disabled={locked} title={fileUpload ? "附件、派生任务与新上下文" : "派生任务与新上下文"} aria-label="添加内容" aria-haspopup="dialog" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
292
+ {addOpen && <Popover className="composer-popover composer-add-popover" ariaLabel="添加内容" placement="top" align="start" onClose={() => setAddOpen(false)}>
293
+ <div className="composer-menu-heading">添加内容</div>
294
+ {fileUpload && <button className="pop-item composer-option" disabled={locked} onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>
295
+ <span className="composer-option-icon">{Icon.clip()}</span><span className="composer-option-copy"><span className="composer-option-title">添加附件</span><span className="composer-option-sub">HTML、Markdown 最多 100 MiB,其他文件最多 10 MiB</span></span>
296
+ </button>}
297
+ <button className="pop-item composer-option" disabled={locked} onClick={openMention}>
298
+ <span className="composer-option-icon">{Icon.git()}</span><span className="composer-option-copy"><span className="composer-option-title">从已完成任务派生</span><span className="composer-option-sub">带上已完成的任务,在新分支继续工作</span></span><span className="composer-option-trailing composer-menu-key">@</span>
299
+ </button>
300
+ <button className="pop-item composer-option" disabled={running || locked || task.started !== 1} onClick={() => { setAddOpen(false); clear(); }}>
301
+ <span className="composer-option-icon">{Icon.clear()}</span><span className="composer-option-copy"><span className="composer-option-title">新上下文</span><span className="composer-option-sub">{running ? "本轮结束后可用" : "保存摘要,开启新的上下文窗口"}</span></span>
302
+ </button>
303
+ </Popover>}
304
+ </div>
305
+ <RunControls {...controls} disabled={controls.disabled || sending} />
306
+ <div className="send-group">
307
+ {showStop ? <button type="button" className="send stop" disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>
308
+ : <button type="button" className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-busy={sending} aria-label={sending ? "正在发送" : sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息(Enter)"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
309
+ </div>
310
+ </div>
311
+ </div>
312
+ {(error || failed || tooLong) && <div className="comp-error" role="alert">{error || (tooLong ? "消息过长,请将部分内容作为文本附件添加。" : "请重试或移除上传失败的附件,再发送。")}</div>}
313
+ {uploading && <div className="comp-upload-status" role="status">正在上传附件,完成后可发送…</div>}
314
+ </div>
315
+ </div>;
316
+ }