@spzxg/cli-ui 0.0.0-stage → 0.3.0

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.
@@ -0,0 +1,639 @@
1
+ // cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
2
+ // 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
3
+ // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
6
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
7
+ import { convKey, emptyConv, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
8
+ import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
9
+ import { Icon } from "../ui/icons.js";
10
+ import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
11
+ import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
12
+ import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
13
+ import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
14
+ import { Composer, type ComposerOptions } from "./Composer.js";
15
+ import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
16
+ import { exportMarkdown } from "./export.js";
17
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
18
+ import { FileViewer } from "../board/FileViewer.js";
19
+ import { BrowserPane } from "../board/BrowserPane.js";
20
+
21
+ /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
22
+ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
23
+ /**
24
+ * 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
25
+ * 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
26
+ */
27
+ jobId?: string | null;
28
+ /**
29
+ * 对话任务里的哪个对话(子任务 task_id),和 jobId 一起用。不给 = 对话任务的第一个对话(只有一个对话时就是它);
30
+ * "new" = 在这个对话任务里新开一个对话(同一节点、同一工作区,新的 Agent 会话),第一条消息发出后组件跟到开出来的那个。
31
+ * 结束对话时:给了 taskId 只关掉这一个对话,没给结束整个对话任务。
32
+ */
33
+ taskId?: string | null;
34
+ /** 面包屑里的名称与色块(如业务 / 项目名)。 */
35
+ title?: string;
36
+ color?: string;
37
+ /** 只看不发。 */
38
+ readOnly?: boolean;
39
+ /** 是否显示「结束对话」按钮,缺省 true(等同 config.header.close)。 */
40
+ allowClose?: boolean;
41
+ /** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
42
+ config?: CliChatConfig;
43
+ /**
44
+ * Agent 消息里本地文件链接(report.md、/abs/path、src/a.ts:12)的打开地址。缺省没有:显示成不可点的文字。
45
+ * 有文件服务时(例如以后的看板 / Operator 后端)给出地址,链接就能打开(与 Operator 一致)。
46
+ * taskId:看的是对话任务里指定的那个对话时才有。
47
+ */
48
+ fileUrl?: (path: string, jobId: string, kind: "link" | "image", taskId?: string | null) => string | null;
49
+ /**
50
+ * 点了本地文件链接 / 「查看浏览器」怎么打开:
51
+ * · "auto"(缺省)—— 同一个 Provider 里有 cli-board 在看这个对话就在看板里打开,否则在对话里弹窗预览;
52
+ * · "board" —— 只交给看板(没有看板时按 fileUrl 的地址打开);
53
+ * · "modal" —— 总是在对话里弹窗;
54
+ * · "link" —— 不拦截,按 fileUrl 的地址在新窗口打开(以前的行为)。
55
+ * 无论哪种,都会发 open 事件(onEvent),宿主可以据此展开右侧面板。
56
+ */
57
+ fileOpen?: "auto" | "board" | "modal" | "link";
58
+ /** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
59
+ onEvent?: (event: CliEvent) => void;
60
+
61
+ theme?: "light" | "dark";
62
+ className?: string;
63
+ style?: CSSProperties;
64
+ store?: CliStore;
65
+ }
66
+
67
+ export function CliChat(props: CliChatProps) {
68
+ const ctx = useCliStoreContext();
69
+ return withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx);
70
+ }
71
+
72
+ // 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
73
+ const FINAL_REASONS = new Set(["closed", "cancelled", "conversation_closed"]);
74
+ const REASON_TEXT: Record<string, string> = {
75
+ closed: "对话已结束", cancelled: "对话已取消", conversation_closed: "这个对话已结束", expired: "对话空闲太久已过期",
76
+ node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
77
+ };
78
+
79
+ /** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
80
+ const QUIET_AFTER_MS = 180_000;
81
+ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
82
+ const [now, setNow] = useState(() => Date.now());
83
+ useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
84
+ const elapsed = startedAt ? Math.max(0, now - startedAt) : null;
85
+ const quiet = now - lastActivityRef.current;
86
+ const clock = (ms: number) => {
87
+ const s = Math.floor(ms / 1000);
88
+ const h = Math.floor(s / 3600);
89
+ const m = Math.floor((s % 3600) / 60);
90
+ const ss = String(s % 60).padStart(2, "0");
91
+ return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
92
+ };
93
+ return (
94
+ <div className="msg assistant">
95
+ <div className="who"><Avatar who="cc" agent={agent} /> {agentName}</div>
96
+ <div className="msg-body run-ind">
97
+ <span className="typing"><i /><i /><i /></span>
98
+ {elapsed !== null && <span className="run-ind-t">运行中 · {clock(elapsed)}</span>}
99
+ {quiet >= QUIET_AFTER_MS && <span className="run-ind-quiet">已 {Math.floor(quiet / 60_000)} 分钟没有新输出(可能在执行耗时操作);确认卡住时可点输入框右侧的停止按钮</span>}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
106
+ useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
107
+ // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
108
+ const configKey = JSON.stringify(config ?? {});
109
+ const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
110
+ const readOnly = !!readOnlyProp || !cfg.composer.show;
111
+ const store = useCliStore();
112
+ const selected = useCliState((s) => s.selected);
113
+ // 显示哪个对话:
114
+ // · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
115
+ // · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
116
+ // center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId / taskId 就以宿主为准。
117
+ // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
118
+ // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
119
+ const instanceId = useId();
120
+ const controlled = jobIdProp !== undefined;
121
+ // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
122
+ const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
123
+ const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: keyProp, id: keyProp ?? null }));
124
+ let viewId = followed.id;
125
+ if (followed.prop !== keyProp) {
126
+ viewId = keyProp ?? null;
127
+ setShown({ prop: keyProp, id: viewId });
128
+ }
129
+ const key = controlled ? viewId : selected;
130
+ const ref = key ? parseConvKey(key) : null;
131
+ const jobId = ref?.jobId ?? null;
132
+ // 「在这个对话任务里新开一个对话」:还没有对话,第一条消息开出来。
133
+ const isNew = isNewInJob(key);
134
+ const taskId = ref?.taskId && !isNew ? ref.taskId : null;
135
+ const go = useCallback((id: string | null) => {
136
+ if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
137
+ else store.select(id);
138
+ }, [controlled, store]);
139
+ const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
140
+ const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
141
+
142
+ // 看这个对话:开实时流(同一对话多处看只连一次)。
143
+ useEffect(() => (key ? store.open(key) : undefined), [store, key]);
144
+ // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
145
+ const keyRef = useRef(key);
146
+ keyRef.current = key;
147
+ useEffect(() => store.on((event) => {
148
+ if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
149
+ }), [store, go]);
150
+
151
+ const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
152
+ // config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
153
+ const messages = useMemo(() => (cfg.transcript.tools ? allMessages : allMessages.filter((m) => {
154
+ if (m.role !== "tool") return true;
155
+ const data = toolDataOf(m);
156
+ return !!(data.ask || data.input);
157
+ })), [allMessages, cfg.transcript.tools]);
158
+ const running = conv ? isRunning(conv) : false;
159
+ const job = entry?.job;
160
+ // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示)。
161
+ const task = (taskId ? job?.tasks?.find((t) => t.id === taskId) : undefined) ?? (taskId ? undefined : job?.tasks?.[0]);
162
+ const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
163
+ const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
164
+ const agentText = labelOf(agent);
165
+ const jobStatus = conv?.job?.status ?? job?.status ?? null;
166
+ const jobEnded = !!jobStatus && TERMINAL_JOB.has(jobStatus);
167
+ // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
168
+ const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
169
+ const status = convClosed ? "succeeded" : jobStatus;
170
+ const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
171
+ const ended = jobEnded || convClosed;
172
+ const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
173
+ const continuedFrom = job?.conversation?.continued_from_job_id ?? null;
174
+ // 接续自旧对话任务里的哪个对话(指定了对话时跟回那一个)。
175
+ const continuedFromKey = continuedFrom ? convKey(continuedFrom, taskId ? task?.continue_from_task_id ?? info?.continue_from_task_id ?? null : null) : null;
176
+ const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
177
+ const jobTitle = job?.title ?? conv?.job?.title;
178
+ // 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
179
+ const manyConvs = (job?.conversation?.conversations?.length ?? 0) > 1;
180
+ const convName = conv?.task?.title ?? info?.title ?? task?.title;
181
+ const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
182
+ : taskId && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
183
+ : jobTitle ?? (key ? "对话" : "新对话");
184
+ const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
185
+ const lastTurn = conv?.turns[conv.turns.length - 1];
186
+ const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
187
+ const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
188
+
189
+ // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
190
+ const usageTask = isNew ? undefined : task;
191
+ const fresh = (usageTask ? Math.max(0, usageTask.total_tokens - usageTask.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
192
+ const cacheRead = (usageTask?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
193
+ const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
194
+
195
+ const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
196
+ if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
197
+ if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
198
+ if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
199
+ if (running) return { dotCls: "a", statusText: "运行中" };
200
+ if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
201
+ return { dotCls: "a", statusText: "进行中" };
202
+ }, [key, isNew, ended, status, closedReason, awaiting, running]);
203
+
204
+ // ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
205
+ // 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
206
+ const [actionError, setActionError] = useState<string | null>(null);
207
+ useEffect(() => setActionError(null), [key]);
208
+ const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
209
+ try {
210
+ const result = await work;
211
+ setActionError(null);
212
+ return result;
213
+ } catch (error) {
214
+ setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
215
+ throw error;
216
+ }
217
+ }, []);
218
+ const latest = useRef({ key, messages, conv });
219
+ latest.current = { key, messages, conv };
220
+ // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
221
+ const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
222
+ sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
223
+ const send = useCallback(async (text: string) => {
224
+ const id = latest.current.key;
225
+ const target = await store.send(id ?? null, text, sendOpts.current);
226
+ if (target !== id) go(target);
227
+ }, [store, go]);
228
+ // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
229
+ // 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
230
+ const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
231
+ useEffect(() => {
232
+ if (!key || !conv || fileOpen === "link" || fileOpen === "modal") return;
233
+ if (handoffSeen.current.key !== key) handoffSeen.current = { key, ids: null };
234
+ if (!conv.hasTranscript) return;
235
+ const handoffs = conv.msgs.filter((m) => m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX));
236
+ const seen = handoffSeen.current;
237
+ if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
238
+ const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
239
+ for (const m of fresh) seen.ids.add(m.id);
240
+ if (fresh.length) store.openInBoard(key, { kind: "browser" });
241
+ }, [key, conv, fileOpen, store]);
242
+
243
+ // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
244
+ const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
245
+ useEffect(() => setPopup(null), [key]);
246
+ useEffect(() => {
247
+ if (!popup) return;
248
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPopup(null); };
249
+ window.addEventListener("keydown", onKey);
250
+ return () => window.removeEventListener("keydown", onKey);
251
+ }, [popup]);
252
+ const handlers = useMemo<MessageHandlers>(() => ({
253
+ // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
254
+ onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
255
+ const id = latest.current.key;
256
+ if (!id) return;
257
+ const sent = askId
258
+ ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
259
+ : send(formatAnswersText(questions, answers));
260
+ await report(sent, "回答没有发出去");
261
+ },
262
+ onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
263
+ // 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
264
+ onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
265
+ const id = latest.current.key;
266
+ if (!id) return "对话还没开始";
267
+ try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
268
+ },
269
+ // 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
270
+ onHandback: readOnly || !(store.client.handback || store.client.board) ? undefined : async () => {
271
+ const id = latest.current.key;
272
+ if (!id) return "对话还没开始";
273
+ try {
274
+ if (store.client.handback) return await store.client.handback(id);
275
+ await store.client.board!.browserAction(id, "handback");
276
+ return null;
277
+ } catch (error) { return error instanceof Error ? error.message : String(error); }
278
+ },
279
+ onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
280
+ const id = latest.current.key;
281
+ if (!id) return;
282
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
283
+ if (fileOpen !== "board") setPopup({ kind: "browser" });
284
+ },
285
+ onRetry: readOnly ? undefined : (msgId: string) => {
286
+ const list = latest.current.messages;
287
+ const at = list.findIndex((m) => m.id === msgId);
288
+ for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
289
+ },
290
+ }), [readOnly, send, store, go, report, fileOpen]);
291
+ const stop = useCallback(async () => {
292
+ const { key: id, conv: c } = latest.current;
293
+ const turn = c ? runningTurn(c) : null;
294
+ if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
295
+ }, [store, report]);
296
+ const [closing, setClosing] = useState<"idle" | "confirm" | "busy">("idle");
297
+
298
+ // ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
299
+ const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
300
+ const [runSeenAt, setRunSeenAt] = useState<number | null>(null);
301
+ const lastActivityRef = useRef(Date.now());
302
+ const wasRunningRef = useRef(running);
303
+ useEffect(() => {
304
+ if (running && !wasRunningRef.current) { setRunSeenAt(Date.now()); lastActivityRef.current = Date.now(); }
305
+ else if (!running) setRunSeenAt(null);
306
+ wasRunningRef.current = running;
307
+ }, [running]);
308
+ useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
309
+ const turnStartedAt = runSeenAt ?? lastUserTs;
310
+
311
+ // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
312
+ const scrollRef = useRef<HTMLDivElement>(null);
313
+ const pinned = useRef(true);
314
+ const [atBottom, setAtBottom] = useState(true);
315
+ const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
316
+ const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
317
+ const items = useMemo(() => buildTranscriptItems(committed), [committed]);
318
+ const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
319
+ const { startIdx, endIdx } = useMemo(() => {
320
+ const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
321
+ const e = find(win.end);
322
+ const last = e >= 0 ? e : items.length - 1;
323
+ let s = find(win.start);
324
+ if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
325
+ return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
326
+ }, [items, win]);
327
+ const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
328
+ const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
329
+ const hiddenBelow = useMemo(() => { let n = 0; for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++; return n; }, [items, endIdx]);
330
+ const itemsRef = useRef(items); itemsRef.current = items;
331
+ const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
332
+ const winRef = useRef(win); winRef.current = win;
333
+ type Anchor = { key: string; top: number };
334
+ const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
335
+
336
+ const measure = useCallback((key?: string): Anchor | null => {
337
+ const el = scrollRef.current;
338
+ const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
339
+ return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
340
+ }, []);
341
+ const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
342
+ const its = itemsRef.current;
343
+ let a = s; while (a < its.length && its[a].kind !== "msg") a++;
344
+ let b = e; while (b > 0 && its[b].kind !== "msg") b--;
345
+ const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
346
+ if (winRef.current.start === next.start && winRef.current.end === next.end) return;
347
+ if (anchor) anchorRef.current = { ...anchor, win: next };
348
+ setWin(next);
349
+ }, []);
350
+ const revealEarlier = useCallback(() => {
351
+ const { startIdx: s, endIdx: e } = rangeRef.current;
352
+ if (s <= 0) return;
353
+ const ns = Math.max(0, s - RENDER_STEP);
354
+ setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
355
+ }, [measure, setRange]);
356
+ const revealLater = useCallback(() => {
357
+ const its = itemsRef.current;
358
+ const { startIdx: s, endIdx: e } = rangeRef.current;
359
+ if (e >= its.length - 1) return;
360
+ const ne = Math.min(its.length - 1, e + RENDER_STEP);
361
+ const ns = Math.max(s, ne - RENDER_MAX + 1);
362
+ setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
363
+ }, [measure, setRange]);
364
+ const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
365
+ revealRef.current = { earlier: revealEarlier, later: revealLater };
366
+
367
+ const onScroll = useCallback(() => {
368
+ const el = scrollRef.current;
369
+ if (!el) return;
370
+ const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
371
+ pinned.current = bottom;
372
+ setAtBottom(bottom);
373
+ if (!bottom && winRef.current.start === null) {
374
+ const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
375
+ if (first) setWin((w) => ({ ...w, start: first.key }));
376
+ } else if (bottom && winRef.current.start !== null) {
377
+ setWin({ start: null, end: null });
378
+ }
379
+ }, []);
380
+
381
+ const topSentinelRef = useRef<HTMLDivElement>(null);
382
+ const bottomSentinelRef = useRef<HTMLDivElement>(null);
383
+ const hasAbove = hiddenAbove > 0;
384
+ const hasBelow = hiddenBelow > 0;
385
+ useEffect(() => {
386
+ const root = scrollRef.current;
387
+ if (!root || typeof IntersectionObserver === "undefined") return;
388
+ const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
389
+ if (!target) return null;
390
+ const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
391
+ io.observe(target);
392
+ return io;
393
+ };
394
+ const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
395
+ const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
396
+ return () => { up?.disconnect(); down?.disconnect(); };
397
+ }, [hasAbove, hasBelow]);
398
+
399
+ useLayoutEffect(() => {
400
+ if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
401
+ const el = scrollRef.current;
402
+ if (!el) return;
403
+ if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
404
+ else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
405
+ }, [win, startIdx, endIdx, setRange, measure]);
406
+
407
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
408
+ const el = scrollRef.current;
409
+ if (!el) return;
410
+ pinned.current = true;
411
+ setAtBottom(true);
412
+ if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
413
+ el.scrollTo({ top: el.scrollHeight, behavior });
414
+ }, []);
415
+
416
+ // 在「你」的消息之间跳。
417
+ const scrollToUserMsg = useCallback((dir: -1 | 1) => {
418
+ const el = scrollRef.current;
419
+ if (!el) return;
420
+ const base = el.getBoundingClientRect().top;
421
+ const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
422
+ const cur = el.scrollTop;
423
+ const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
424
+ if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
425
+ if (!tops.length) return;
426
+ const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
427
+ el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
428
+ }, []);
429
+
430
+ useLayoutEffect(() => {
431
+ const a = anchorRef.current;
432
+ const el = scrollRef.current;
433
+ if (a && a.win === win) {
434
+ anchorRef.current = null;
435
+ const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
436
+ if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
437
+ }
438
+ }, [win]);
439
+ useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
440
+ useEffect(() => {
441
+ const el = scrollRef.current;
442
+ if (!el || typeof ResizeObserver === "undefined") return;
443
+ const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
444
+ ro.observe(el);
445
+ return () => ro.disconnect();
446
+ }, []);
447
+ useEffect(() => {
448
+ pinned.current = true;
449
+ setAtBottom(true);
450
+ setWin({ start: null, end: null });
451
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
452
+ }, [key]);
453
+
454
+ const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
455
+ const h = cfg.header;
456
+ const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent }), [cfg]);
457
+ const composerOptions = useMemo<ComposerOptions>(() => ({
458
+ placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
459
+ fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
460
+ }), [cfg]);
461
+ const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
462
+ const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
463
+
464
+ const fileUrlRef = useRef(fileUrl);
465
+ fileUrlRef.current = fileUrl;
466
+ const board = store.client.board;
467
+ // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
468
+ const resolveFile = useMemo(() => {
469
+ if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
470
+ if (board?.directLinks && key) return (path: string) => board.fileUrl(key, path);
471
+ return null;
472
+ }, [!!fileUrl, key, jobId, taskId, board]); // eslint-disable-line react-hooks/exhaustive-deps
473
+ // 点了本地文件:看板 → 弹窗 → 地址(见 fileOpen)。
474
+ const openFile = useMemo(() => {
475
+ if (!key || fileOpen === "link") return null;
476
+ return (path: string) => {
477
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
478
+ if (fileOpen === "board" || !board) return false;
479
+ setPopup({ kind: "file", path });
480
+ return true;
481
+ };
482
+ }, [key, fileOpen, store, board]);
483
+
484
+ return (
485
+ <FileLinkContext.Provider value={resolveFile}>
486
+ <FileOpenContext.Provider value={openFile}>
487
+ <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
488
+ <div className="session">
489
+ {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
490
+ {(h.crumb || h.export || h.title) && <div className="sh-main">
491
+ {(h.crumb || h.export) && <div className="crumb">
492
+ {h.crumb && <>
493
+ <span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
494
+ {title} <span className="sep">/</span> 对话
495
+ </>}
496
+ {h.export && key && allMessages.length > 0 && (
497
+ <button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
498
+ )}
499
+ </div>}
500
+ {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
501
+ </div>}
502
+ <div className="sh-tools">
503
+ {h.agent && agent && <AgentBadge label={agentText} />}
504
+ {h.usage && (fresh > 0 || cacheRead > 0) && (
505
+ <span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
506
+ {fmtTokens(fresh)} tok
507
+ {cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
508
+ </span>
509
+ )}
510
+ {h.model && key && !isNew && (
511
+ <span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
512
+ {Icon.spark()}<span className="cv">默认</span>
513
+ {resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
514
+ </span>
515
+ )}
516
+ {h.status && <span className={`status-ctl ${awaiting ? "awaiting" : ""}`} style={{ cursor: "default" }}>
517
+ <StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
518
+ <span className="cv">{statusText}</span>
519
+ </span>}
520
+ {h.close && key && !isNew && allowClose && !readOnly && !ended && (
521
+ <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
522
+ )}
523
+ </div>
524
+ </div>}
525
+
526
+ {closing !== "idle" && key && (
527
+ <div className="clear-confirm">
528
+ <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
529
+ <span className="spacer" />
530
+ <button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
531
+ <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(key), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
532
+ </div>
533
+ )}
534
+ {actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
535
+ {conv?.stream === "reconnecting" && <div className="sync-banner"><span className="sync-msg">实时连接中断,正在重连…{conv.streamError ? `(${conv.streamError})` : ""}</span></div>}
536
+ {conv?.stream === "error" && <div className="sync-banner conflict"><span className="sync-msg">⚠ {conv.streamError}</span></div>}
537
+
538
+ <div className="transcript-wrap">
539
+ <div className="transcript" ref={scrollRef} onScroll={onScroll}>
540
+ <div className="tw">
541
+ {(!key || isNew) && cfg.empty.show && (
542
+ <div className="hero cli-hero">
543
+ <div className="h-ic">{Icon.bolt()}</div>
544
+ <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
545
+ <h2>{cfg.empty.title}</h2>
546
+ {isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
547
+ {cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
548
+ </div>
549
+ )}
550
+ {continuedFrom && (
551
+ <div className="msg system info"><div className="msg-body">ℹ 这个对话接续自上一个已结束的对话({continuedFrom})。工作区文件沿用,之前的对话内容没有带进来。
552
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFromKey)}>查看上一个对话</button>
553
+ </div></div>
554
+ )}
555
+ {contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
556
+ {loading && (
557
+ <div aria-hidden>
558
+ <div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
559
+ <div className="msg user" style={{ opacity: .7 }}>
560
+ <div className="who"><Skel w={18} h={18} r={5} /><Skel w={34} h={9} /></div>
561
+ <div className="msg-body"><Skel w="72%" h={12} /><Skel w="46%" h={12} style={{ marginTop: 9 }} /></div>
562
+ </div>
563
+ <div className="msg" style={{ opacity: .7 }}>
564
+ <div className="who"><Skel w={18} h={18} r={5} /><Skel w={70} h={9} /></div>
565
+ <Skel w="90%" h={11} /><Skel w="83%" h={11} style={{ marginTop: 8 }} /><Skel w="58%" h={11} style={{ marginTop: 8 }} />
566
+ </div>
567
+ </div>
568
+ )}
569
+ {hasAbove && (
570
+ <div ref={topSentinelRef} className="load-older">
571
+ <button className="btn btn-ghost btn-sm" onClick={revealEarlier}>加载更早的消息(还有 {hiddenAbove} 条)</button>
572
+ </div>
573
+ )}
574
+ {shown.map((it) =>
575
+ it.kind === "label" ? (
576
+ cfg.transcript.sessionLabel ? <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div> : null
577
+ ) : it.kind === "break" ? (
578
+ <div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
579
+ ) : (
580
+ <div key={it.key} data-tkey={it.key} data-msg="">
581
+ <MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
582
+ </div>
583
+ ),
584
+ )}
585
+ {hasBelow && (
586
+ <div ref={bottomSentinelRef} className="load-older">
587
+ <button className="btn btn-ghost btn-sm" onClick={revealLater}>显示后面的消息(还有 {hiddenBelow} 条)</button>
588
+ <button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
589
+ </div>
590
+ )}
591
+ {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
592
+ {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
593
+ {ended && (
594
+ <div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
595
+ )}
596
+ {continuedBy && (
597
+ <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
598
+ )}
599
+ </div>
600
+ </div>
601
+ {cfg.transcript.nav && <div className="msg-nav">
602
+ <button className="msg-nav-btn" onClick={() => scrollToUserMsg(-1)} title="上一条消息" aria-label="滚动到上一条消息">{Icon.chevUp()}</button>
603
+ <button className="msg-nav-btn" onClick={() => scrollToUserMsg(1)} title="下一条消息" aria-label="滚动到下一条消息">{Icon.chevDown()}</button>
604
+ {!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
605
+ </div>}
606
+ </div>
607
+ {!readOnly && (
608
+ <Composer
609
+ key={key ?? "new"}
610
+ draft={key ?? `new:${instanceId}`}
611
+ placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
612
+ disabled={composerDisabled}
613
+ disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
614
+ running={running}
615
+ onSend={send}
616
+ onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
617
+ options={composerOptions}
618
+ />
619
+ )}
620
+ </div>
621
+ {popup && key && board && (
622
+ <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
623
+ <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
624
+ {popup.kind === "file" ? (
625
+ <FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
626
+ ) : (
627
+ <>
628
+ <div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
629
+ <BrowserPane backend={board} jobId={key} readOnly={readOnly} />
630
+ </>
631
+ )}
632
+ </div>
633
+ </div>
634
+ )}
635
+ </div>
636
+ </FileOpenContext.Provider>
637
+ </FileLinkContext.Provider>
638
+ );
639
+ }