dsh-activity-pane 0.1.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.
package/src/core.mjs ADDED
@@ -0,0 +1,1933 @@
1
+ // dsh-activity-pane 核心纯函数:把 DSH 原生 sessions/workspaces 客户端服务
2
+ // 快照映射成窗格里的"活动会话条目"。这一层不触碰 DOM,可单测。
3
+ //
4
+ // 数据源约定(来自 @deepseek-ai/dsh-client-runtime 的 sessions.list 快照):
5
+ // ids: SessionId[] —— 宿主列表顺序(祖先在前的 lineage 顺序)
6
+ // byId[id]: { id, displayTitle, title?, cwd?, parentId?, running, completed?,
7
+ // pendingInteraction?: 'approval'|'plan-review'|'question', blank, ... }
8
+ // current: SessionId | undefined
9
+ // subagentsByParent: { [parentId]: { entries: [{ id, label, ... }] } }
10
+ // jobsBySession: { [sessionId]: JobView[] }
11
+ // 以及 workspaces.list 的 items: [{ title, path, sessionIds }]。
12
+ //
13
+ // 展示规则:
14
+ // 主会话(无有效 parentId):running || completed || pendingInteraction 都显示;
15
+ // 子代理:仅 running || pendingInteraction 时显示(结束后即消失);
16
+ // pendingInteraction 总是优先视为"等待用户行动"(即使在 running 中)。
17
+
18
+ const PENDING_LABELS = {
19
+ approval: "待确认",
20
+ "plan-review": "待审查",
21
+ question: "提问中",
22
+ };
23
+
24
+ /** 阻塞等待备注行动作说明(R-01-002/AC-09):说明等待的具体动作与「不答就无法继续」的后果。 */
25
+ const PENDING_NOTES = {
26
+ approval: "等待你确认授权后继续",
27
+ "plan-review": "等待你审查计划后继续",
28
+ question: "等待你回答问题后继续",
29
+ };
30
+
31
+ /** 未知阻塞种类的中性兜底(评审修正,C-028): pendingInteraction 是宿主封闭集合,
32
+ * 未知值按阻塞对待(不答就无法继续),但文案/图标不冒充任何已知类型。 */
33
+ const PENDING_UNKNOWN_LABEL = "待处理";
34
+ const PENDING_UNKNOWN_NOTE = "等待你处理后继续";
35
+
36
+ /** 完成提醒正文行(R-01-002/AC-09,C-040、C-043):引导继续对话或移入历史——「已完成」
37
+ * 语义已由首行胶囊承载,正文不再重复;文案长度受末行宽度约束——须与「移入历史」
38
+ * 按钮同排在默认窗格宽度下单行完整可见。 */
39
+ export const ROUND_DONE_NOTE = "继续对话,或移入历史";
40
+
41
+ /** 错误提醒信息截断上限(宿主登记 lastTurnEndError 与客户端渲染共用,C-043):
42
+ * 错误信息只为提示用户会话出错了,超长正文无展示价值;与提问列表单项上界同量级。 */
43
+ export const ERROR_NOTE_MAX = 120;
44
+
45
+ /** 错误提醒正文回落文案(R-01-002/AC-13,C-043):error 回合无可用错误信息时使用。 */
46
+ export const ERROR_NOTE_FALLBACK = "回合以错误结束,请检查会话";
47
+
48
+ /** 错误信息截断(R-01-002/AC-13,C-043):按 Unicode 码点截断至 ERROR_NOTE_MAX(省略号
49
+ * 不计入上限、属展示截断信号);非字符串返回空串(宿主侧仅字符串入参,防御性边界)。
50
+ * 截断在宿主登记时执行一次、渲染侧直接消费已截断的 lastTurnEndError;共享核心承载便于
51
+ * 单一事实源与可执行验证(Spec 轴审核:截断须有测试锚点)。 */
52
+ export function truncateErrorNote(text) {
53
+ if (typeof text !== "string") return "";
54
+ const chars = [...text];
55
+ return chars.length <= ERROR_NOTE_MAX ? text : `${chars.slice(0, ERROR_NOTE_MAX).join("")}…`;
56
+ }
57
+
58
+ /** 镜像原生 toolRowModel 的 classifyTool(dsh-client-ui-tool):摘要参数键按 variant 分派(C-011)。 */
59
+ const TOOL_VARIANTS = {
60
+ bash: "bash",
61
+ pwsh: "bash",
62
+ read: "read",
63
+ web_fetch: "read",
64
+ web_search: "search",
65
+ grep: "search",
66
+ glob: "search",
67
+ write: "write",
68
+ edit: "edit",
69
+ run_code: "code",
70
+ cordis_package_inspect: "read",
71
+ cordis_runtime_inspect: "read",
72
+ cordis_run: "others",
73
+ cordis_stop: "others",
74
+ cordis_undefine: "others",
75
+ };
76
+ /** 镜像原生 SUMMARY_KEYS:各 variant 的参数键优先级,bash 含 command(可展示原始命令,C-011)。 */
77
+ const SUMMARY_KEYS = {
78
+ bash: ["description", "command"],
79
+ read: ["path", "file_path", "url"],
80
+ search: ["query", "pattern", "url"],
81
+ write: ["path", "file_path"],
82
+ edit: ["path", "file_path"],
83
+ code: ["description"],
84
+ others: [],
85
+ };
86
+ /** 镜像原生「摘要不带 `工具名 · ` 前缀」的工具集:TOOL_TITLES 键集 + keyed 行(cordis_define 显示插件名,todo/ask 由专用摘要覆盖)。 */
87
+ const NATIVE_TOOL_TITLES = new Set(["cordis_package_inspect", "cordis_runtime_inspect", "cordis_run", "cordis_stop", "cordis_undefine", "cordis_define", "pwsh"]);
88
+ // 动作标题对齐主会话窗口文案:通用行镜像 dsh-client-ui-tool 的 VARIANT_TITLES/TOOL_TITLES/SEARCH_TITLES/WEB_TITLES
89
+ // figma literals,keyed 行(todo/ask/cordis_define)镜像其中文 locale;未知工具回退 "Tool call"(原生 others 标题)。
90
+ const TOOL_LABELS = {
91
+ bash: "Bash",
92
+ pwsh: "Pwsh",
93
+ read: "Read",
94
+ web_fetch: "Fetch",
95
+ web_search: "Search",
96
+ grep: "Grep",
97
+ glob: "Glob",
98
+ write: "Write",
99
+ edit: "Edit",
100
+ run_code: "Code",
101
+ cordis_package_inspect: "Inspect",
102
+ cordis_runtime_inspect: "Inspect",
103
+ cordis_run: "Run Cordis Plugin",
104
+ cordis_stop: "Stop Cordis Plugin",
105
+ cordis_undefine: "Remove Cordis Plugin",
106
+ cordis_define: "注册 Cordis 插件",
107
+ todo_write: "更新任务清单",
108
+ ask_user_question: "提问",
109
+ };
110
+
111
+ /** 桌面窗格拖拽调宽边界(R-01-015):拖拽实时夹取与 localStorage 恢复共用的同源常量。 */
112
+ export const PANE_WIDTH_MIN = 200;
113
+ export const PANE_WIDTH_MAX = 480;
114
+ export const PANE_WIDTH_DEFAULT = 280;
115
+
116
+ /**
117
+ * 把任意输入(拖拽像素值或 localStorage 字符串)归一为合法列宽:
118
+ * 非有限数值(含空串)回退默认宽;有限数值取整后夹取进 [PANE_WIDTH_MIN, PANE_WIDTH_MAX]。
119
+ */
120
+ export function clampPaneWidth(raw) {
121
+ if (typeof raw === "string" && raw.trim() === "") return PANE_WIDTH_DEFAULT;
122
+ const value = typeof raw === "string" ? Number(raw) : raw;
123
+ if (typeof value !== "number" || !Number.isFinite(value)) return PANE_WIDTH_DEFAULT;
124
+ return Math.min(PANE_WIDTH_MAX, Math.max(PANE_WIDTH_MIN, Math.round(value)));
125
+ }
126
+
127
+ function isRecord(value) {
128
+ return value !== null && typeof value === "object" && !Array.isArray(value);
129
+ }
130
+
131
+ function cleanText(value) {
132
+ return typeof value === "string" ? value.trim() : "";
133
+ }
134
+
135
+ /** 展示摘要文本:折叠空白并截断到 max 字符(用于工具参数摘要等长文本);非字符串返回 null。 */
136
+ export function cleanPreview(value, max = 88) {
137
+ if (typeof value !== "string") return null;
138
+ const text = value.replace(/\s+/g, " ").trim();
139
+ if (text.length === 0) return null;
140
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
141
+ }
142
+
143
+ /** 取原始文本的第一个非空物理行;不把换行折叠成同一行。 */
144
+ export function firstPhysicalLine(value, max = 120) {
145
+ if (typeof value !== "string") return "";
146
+ for (const line of value.split(/\r?\n/)) {
147
+ const text = line.trim();
148
+ if (!text) continue;
149
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
150
+ }
151
+ return "";
152
+ }
153
+
154
+ function contentText(content) {
155
+ if (!Array.isArray(content)) return "";
156
+ return content
157
+ .filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
158
+ .map((block) => block.text)
159
+ .join("\n");
160
+ }
161
+
162
+ function assistantBlockText(blocks, kind = "text") {
163
+ if (!Array.isArray(blocks)) return "";
164
+ return blocks
165
+ .filter((block) => isRecord(block) && block.kind === kind && typeof block.text === "string")
166
+ .map((block) => block.text)
167
+ .join("\n");
168
+ }
169
+
170
+ function mapValue(source, key) {
171
+ if (source instanceof Map) return source.get(key);
172
+ return isRecord(source) ? source[key] : undefined;
173
+ }
174
+
175
+ /** 原生 firstLine 语义:首行截断,不折叠空白、不限长(行内由 CSS 省略)。 */
176
+ function firstLineOf(text) {
177
+ const nl = text.indexOf("\n");
178
+ return nl === -1 ? text : text.slice(0, nl);
179
+ }
180
+
181
+ /** 原生 latestLine 语义:流式思考行显示尾部最新行(ReasoningRow running 分支)。 */
182
+ function latestLineOf(text) {
183
+ const visible = text.trimEnd();
184
+ const nl = visible.lastIndexOf("\n");
185
+ return nl === -1 ? visible : visible.slice(nl + 1);
186
+ }
187
+
188
+ /** 镜像原生 relativizeToCwd:工作区内绝对路径显示为相对路径。 */
189
+ function relativizeToCwd(text, cwd) {
190
+ if (typeof cwd !== "string" || cwd === "") return text;
191
+ const root = cwd.replace(/[/\\]+$/, "");
192
+ if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1);
193
+ return text;
194
+ }
195
+
196
+ /** 镜像原生 deriveSummary:variant 参数键 → 首个字符串参数值 → argsRaw 首行。 */
197
+ function deriveToolSummary(name, argsRaw) {
198
+ const variant = TOOL_VARIANTS[name] ?? "others";
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(argsRaw);
202
+ } catch {
203
+ return firstLineOf(argsRaw);
204
+ }
205
+ if (typeof parsed !== "object" || parsed === null) return firstLineOf(argsRaw);
206
+ for (const key of SUMMARY_KEYS[variant]) {
207
+ const value = parsed[key];
208
+ if (typeof value === "string" && value !== "") return firstLineOf(value);
209
+ }
210
+ for (const value of Object.values(parsed)) if (typeof value === "string" && value !== "") return firstLineOf(value);
211
+ return firstLineOf(argsRaw);
212
+ }
213
+
214
+ /**
215
+ * 工具参数摘要:镜像原生 `deriveSummary` + `relativizeToCwd` 语义(分工具类型参数键,
216
+ * bash 含 command,无命中取首个字符串参数值,兜底 argsRaw 首行;C-011 起可展示原始命令)。
217
+ * 参数非字符串或为空串时返回 null(原生该场景显示 callId,由 timelineToolItem 补)。
218
+ */
219
+ export function summarizeToolArguments(name, raw, cwd = "") {
220
+ if (typeof raw !== "string" || raw === "") return null;
221
+ return relativizeToCwd(deriveToolSummary(name, raw), cwd);
222
+ }
223
+
224
+ /** 镜像原生 resultText:结果内容块拍平为文本(非文本块 JSON),空内容且带 error 时为 `name: code`。 */
225
+ function toolResultText(root) {
226
+ const content = Array.isArray(root.content) ? root.content : [];
227
+ const parts = [];
228
+ for (const block of content) {
229
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
230
+ else parts.push(JSON.stringify(block, null, 2));
231
+ }
232
+ if (parts.length === 0 && isRecord(root.error)) parts.push(`${root.error.name}: ${root.error.code}`);
233
+ return parts.join("\n");
234
+ }
235
+
236
+ /** 复刻原生 TodoRow 摘要:`done/total 已完成 · 首个进行中项`;todos 结构不符返回 null 走参数兜底(C-011)。 */
237
+ function todoProgressSummary(argsRaw) {
238
+ let parsed;
239
+ try {
240
+ parsed = JSON.parse(argsRaw);
241
+ } catch {
242
+ return null;
243
+ }
244
+ if (!isRecord(parsed)) return null;
245
+ const todos = parsed.todos;
246
+ if (!Array.isArray(todos) || !todos.every(isRecord)) return null;
247
+ const active = todos.filter((todo) => todo.status === "in_progress");
248
+ const first = active[0]?.content;
249
+ const named = typeof first === "string" && first.trim() !== "";
250
+ const head = `${todos.filter((todo) => todo.status === "completed").length}/${todos.length} 已完成`;
251
+ return named ? `${head} · ${first}` : head;
252
+ }
253
+
254
+ /** 复刻原生 AskQuestionRow 摘要:已取消/已中断/等待回答/已答 x/y;数据不足返回 null 走参数兜底(C-011)。 */
255
+ function askStatusSummary(root, status) {
256
+ const code = isRecord(root.error) ? root.error.code : undefined;
257
+ if (code === "ASK_CANCELLED") return "已取消";
258
+ if (code === "ASK_ABORTED") return "已中断";
259
+ if (status === "running") return "等待回答";
260
+ if (status === "done") {
261
+ try {
262
+ // 原生 answeredSummary 以 join("") 拼接文本块,不能用 contentText 的 \n 拼接(多行 JSON 会解析失败)。
263
+ const answerText = (Array.isArray(root.content) ? root.content : [])
264
+ .filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
265
+ .map((block) => block.text)
266
+ .join("");
267
+ const parsed = JSON.parse(answerText);
268
+ const answers = isRecord(parsed) ? parsed.answers : null;
269
+ if (Array.isArray(answers) && answers.every(isRecord)) {
270
+ const answered = answers.filter(
271
+ (answer) => Array.isArray(answer.selected) && answer.selected.length > 0 || typeof answer.custom === "string" && answer.custom !== "",
272
+ ).length;
273
+ return `${answered}/${answers.length} 已回答`;
274
+ }
275
+ } catch {
276
+ /* 结果内容缺失或非 JSON 时走参数兜底 */
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /** 取 ask_user_question 参数中的结构化问题预览(R-01-002/AC-09,C-064):逐条取
283
+ * 问题正文物理首行(正文缺失时回落 header,仍不可得则跳过该条),剥除行尾多余
284
+ * 冒号,并保留原始 1 基序号;最多返回 3 条,仍有可展示问题时 omitted=true。
285
+ * 结构不符或全部问题均不可得返回 null,由调用方回落动作说明。 */
286
+ export function askQuestionsPreview(argsRaw, max = 60) {
287
+ if (typeof argsRaw !== "string" || argsRaw === "") return null;
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(argsRaw);
291
+ } catch {
292
+ return null;
293
+ }
294
+ if (!isRecord(parsed) || !Array.isArray(parsed.questions)) return null;
295
+ const items = [];
296
+ for (let i = 0; i < parsed.questions.length; i += 1) {
297
+ const item = parsed.questions[i];
298
+ if (!isRecord(item)) continue;
299
+ const text = firstPhysicalLine(item.question, max) || firstPhysicalLine(item.header, max);
300
+ const stripped = text.replace(/[::]+\s*$/, "");
301
+ if (stripped === "") continue;
302
+ items.push({ index: i + 1, text: stripped });
303
+ }
304
+ if (items.length === 0) return null;
305
+ return { items: items.slice(0, 3), omitted: items.length > 3 };
306
+ }
307
+
308
+ function isQuestionPreview(value) {
309
+ return isRecord(value) && Array.isArray(value.items) && value.items.length > 0;
310
+ }
311
+
312
+ function timelineToolItem(root, fallbackView = null, cwd = "") {
313
+ if (!isRecord(root)) return null;
314
+ const call = isRecord(root.call) ? root.call : root;
315
+ const rawName = typeof call.name === "string" ? call.name : "";
316
+ const name = rawName || "tool";
317
+ const argsRaw = typeof call.argsRaw === "string" ? call.argsRaw : root.argsRaw;
318
+ const view = root.callView ?? fallbackView;
319
+ const resultView = root.resultView;
320
+ const errorCode = isRecord(root.error) ? root.error.code : undefined;
321
+ // 镜像原生状态派生:interrupted(与 ask 的 ASK_ABORTED)归 stopped,不归 error。
322
+ const status =
323
+ root.kind !== "tool-result"
324
+ ? "running"
325
+ : errorCode === "interrupted" || (name === "ask_user_question" && errorCode === "ASK_ABORTED")
326
+ ? "stopped"
327
+ : root.isError === true
328
+ ? "error"
329
+ : "done";
330
+ // 镜像原生 toolRowModel:`工具名 · ` 前缀只出现在 others variant 且无 TOOL_TITLES 条目时。
331
+ const argsBase =
332
+ summarizeToolArguments(name, argsRaw, cwd) ?? (typeof root.callId === "string" ? root.callId : "");
333
+ const argsSummary =
334
+ (TOOL_VARIANTS[name] ?? "others") === "others" && rawName !== "" && !NATIVE_TOOL_TITLES.has(rawName)
335
+ ? `${rawName} · ${argsBase}`
336
+ : argsBase;
337
+ // 错误首行优先取结果内容块(原生 resultText 语义);history 事件不带 content,回退 resultView.output。
338
+ const output = toolResultText(root) || (isRecord(resultView) && typeof resultView.output === "string" ? resultView.output : "");
339
+ // 摘要优先级镜像原生行:错误首行 → terminal callView description → search 结果卡标题 → todo 进度 → 参数派生。
340
+ const detail =
341
+ name === "ask_user_question"
342
+ ? askStatusSummary(root, status) ?? argsSummary
343
+ : (status === "error" && output !== "" ? firstLineOf(output) : null) ??
344
+ (isRecord(view) && view.card === "terminal" && typeof view.description === "string" ? view.description : null) ??
345
+ (isRecord(resultView) && resultView.card === "search" && typeof resultView.title === "string" ? resultView.title : null) ??
346
+ (name === "todo_write" && typeof argsRaw === "string" ? todoProgressSummary(argsRaw) : null) ??
347
+ argsSummary;
348
+ return {
349
+ id: typeof root.callId === "string" ? root.callId : `tool:${name}`,
350
+ kind: "tool",
351
+ icon: isRecord(view) && typeof view.kind === "string" ? view.kind : "tool",
352
+ toolName: name,
353
+ callId: typeof root.callId === "string" ? root.callId : "",
354
+ label: TOOL_LABELS[name] ?? "Tool call",
355
+ text: name,
356
+ summary: detail,
357
+ detail,
358
+ // 结构化提问预览随工作项携带(R-01-002/AC-09,C-064),供待回复卡渲染原生列表。
359
+ question: name === "ask_user_question" ? askQuestionsPreview(argsRaw) : null,
360
+ status,
361
+ };
362
+ }
363
+
364
+ function timelineItemFromChatNode(node, cwd = "") {
365
+ if (!isRecord(node)) return null;
366
+ const data = isRecord(node.data) ? node.data : {};
367
+ if (node.visibility === "hidden") return null;
368
+ if (node.kind === "user" || node.kind === "steering") {
369
+ const text = contentText(data.content);
370
+ return {
371
+ id: String(node.key ?? node.anchorSeq ?? `user:${text}`),
372
+ kind: "user",
373
+ icon: "user",
374
+ label: "用户",
375
+ text,
376
+ detail: null,
377
+ status: "done",
378
+ };
379
+ }
380
+ if (node.kind === "assistant-step" || node.kind === "assistant") {
381
+ const text = assistantBlockText(data.blocks, "text");
382
+ const reasoning = assistantBlockText(data.blocks, "reasoning");
383
+ if (!text && !reasoning) return null;
384
+ const label = reasoning ? "思考" : "助手";
385
+ return {
386
+ id: String(node.key ?? `assistant:${data.turn}:${data.step}`),
387
+ kind: "assistant",
388
+ icon: "assistant",
389
+ label,
390
+ turn: data.turn,
391
+ step: data.step,
392
+ text,
393
+ summary: reasoning ? (data.status === "running" ? latestLineOf(reasoning) : firstLineOf(reasoning)) : text,
394
+ detail: reasoning || null,
395
+ status: data.status === "running" ? "running" : "done",
396
+ };
397
+ }
398
+ if (node.kind === "tool-call") return timelineToolItem(data.root ?? data, null, cwd);
399
+ if (node.kind === "context") {
400
+ const text = contentText(data.content);
401
+ // 镜像原生 ContextInjectionRow:标题按 provenance.role 取注入/召回文案,摘要为来源标识;
402
+ // 原始内容只供原生行匹配(matchNativeContextRow),不得作为摘要上卡。
403
+ const provenance = isRecord(data.provenance) ? data.provenance : null;
404
+ return text
405
+ ? {
406
+ id: String(node.key ?? `context:${text}`),
407
+ kind: "context",
408
+ icon: "context",
409
+ label: provenance?.role === "recall" ? "跨会话召回" : "上下文注入",
410
+ text,
411
+ summary: typeof provenance?.label === "string" ? provenance.label : "",
412
+ detail: null,
413
+ status: "done",
414
+ }
415
+ : null;
416
+ }
417
+ return null;
418
+ }
419
+
420
+ /** 用户节点廉价判定(指令锚行前走用):与 timelineItemFromChatNode 的 user/steering
421
+ * 转换入口同口径(非 hidden 的 user/steering),并按锚行语义额外要求非空文本——
422
+ * 空白指令不值得钉住;只读 kind/visibility 与文本块类型,不解析正文全文。 */
423
+ function isUserChatNode(node) {
424
+ if (!isRecord(node) || node.visibility === "hidden") return false;
425
+ if (node.kind !== "user" && node.kind !== "steering") return false;
426
+ const data = isRecord(node.data) ? node.data : {};
427
+ return Array.isArray(data.content) && data.content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim() !== "");
428
+ }
429
+
430
+ function chatNodeAt(nodes, key) {
431
+ try {
432
+ return nodes?.get?.(key) ?? nodes?.[key];
433
+ } catch {
434
+ return undefined;
435
+ }
436
+ }
437
+
438
+ /** 尾部反向收集原始工作项(不含 live 合并),取够 want 个可转换项或耗尽 order 即停。
439
+ * continueToUser:取够后以廉价结构检查(isUserChatNode:非 hidden 的 user/steering 且含非空文本块)继续前走至最近一个未收集的用户节点(含
440
+ * steering),命中才转换并入队首——供指令锚行派生(R-01-012/AC-12),不为找锚做全序转换。 */
441
+ function rawTailItems(snapshot, want, cwd = "", continueToUser = false) {
442
+ const chat = snapshot?.chat;
443
+ const order = Array.isArray(chat?.order) ? chat.order : [];
444
+ const nodes = chat?.nodes;
445
+ const max = Math.max(0, want);
446
+ if (max === 0) return [];
447
+ const items = [];
448
+ let i = order.length - 1;
449
+ for (; i >= 0 && items.length < max; i -= 1) {
450
+ const item = timelineItemFromChatNode(chatNodeAt(nodes, order[i]), cwd);
451
+ if (item) items.unshift(item);
452
+ }
453
+ if (continueToUser) {
454
+ for (; i >= 0; i -= 1) {
455
+ const node = chatNodeAt(nodes, order[i]);
456
+ if (!isUserChatNode(node)) continue;
457
+ const item = timelineItemFromChatNode(node, cwd);
458
+ // 判定与转换口径分叉时继续前走,不提前终止(漏掉更早的用户节点)。
459
+ if (!item) continue;
460
+ items.unshift(item);
461
+ break;
462
+ }
463
+ }
464
+ return items;
465
+ }
466
+
467
+ /** live 合并:partial 流式项与 runningCalls 摘除匹配项后并入尾部窗口;
468
+ * max 仅约束返回长度(折叠路径传大值以保留全部分组成员)。 */
469
+ function mergeLiveItems(items, snapshot, max, cwd = "") {
470
+ const liveItems = [];
471
+ const partialText = assistantBlockText(snapshot?.partial?.blocks, "text");
472
+ const partialReasoning = assistantBlockText(snapshot?.partial?.blocks, "reasoning");
473
+ if (partialText || partialReasoning) {
474
+ // turn/step 定位缺省(非有限数)时不参与摘除匹配,避免误吞最后一个无定位 assistant 节点。
475
+ const partialTurn = snapshot.partial.turn;
476
+ const partialStep = snapshot.partial.step;
477
+ const currentIndex =
478
+ Number.isFinite(partialTurn) && Number.isFinite(partialStep)
479
+ ? items.findLastIndex((item) => item.kind === "assistant" && item.turn === partialTurn && item.step === partialStep)
480
+ : -1;
481
+ const current = currentIndex >= 0 ? items.splice(currentIndex, 1)[0] : null;
482
+ liveItems.push({
483
+ ...(current ?? { id: `partial:${snapshot.partial.turn}:${snapshot.partial.step}`, kind: "assistant", icon: "assistant" }),
484
+ text: partialText,
485
+ detail: partialReasoning || null,
486
+ // 镜像原生 ReasoningRow:流式思考显示尾部最新行,避免与已定案首行摘要漂移。
487
+ summary: partialReasoning ? latestLineOf(partialReasoning) : partialText,
488
+ status: "running",
489
+ live: true,
490
+ });
491
+ }
492
+ for (const call of Array.isArray(snapshot?.runningCalls) ? snapshot.runningCalls : []) {
493
+ const item = timelineToolItem(call, null, cwd);
494
+ if (!item) continue;
495
+ const existingIndex = items.findIndex((candidate) => candidate.id === item.id);
496
+ if (existingIndex >= 0) liveItems.push({ ...items.splice(existingIndex, 1)[0], ...item, live: true });
497
+ else liveItems.push({ ...item, live: true });
498
+ }
499
+ return liveItems.length > 0
500
+ ? items.slice(-Math.max(0, max - liveItems.length)).concat(liveItems).slice(-max)
501
+ : items;
502
+ }
503
+
504
+ /** R-01-009/AC-10:会话运行中(无 pending)、无执行中项且尾部为已定案非用户项时,
505
+ * 克隆提升为 running 作为 agent 工作中的持续标志;error/stopped 与用户输入项不提升。
506
+ * descendantActive:存在活动后代(委托周期呈现)视同运行中。 */
507
+ function promoteRunningTail(timeline, snapshot, descendantActive = false) {
508
+ if (
509
+ (snapshot?.running === true || descendantActive === true) &&
510
+ !(Array.isArray(snapshot?.pending) && snapshot.pending.length > 0) &&
511
+ !timeline.some((item) => item.status === "running")
512
+ ) {
513
+ const tail = timeline[timeline.length - 1];
514
+ if (tail?.status === "done" && tail.kind !== "user") {
515
+ return timeline.slice(0, -1).concat({ ...tail, status: "running" });
516
+ }
517
+ }
518
+ return timeline;
519
+ }
520
+
521
+ /** 非执行呈现(快照 pending,或渲染层判定等待/暂停且快照为冻结值)下无任何在飞项:
522
+ * 派生行中残留的 running 一律落定为 done——等待卡时间线不再闪烁(执行中呈现只保留真实在飞项)。 */
523
+ function settleWhenIdle(rows, idle) {
524
+ if (idle !== true) return rows;
525
+ return rows.map((row) => (row?.status === "running" ? { ...row, status: "done" } : row));
526
+ }
527
+
528
+ /** 快照级 idle 判定:pending 非空即回合冻结。 */
529
+ function snapshotIdle(snapshot) {
530
+ return Array.isArray(snapshot?.pending) && snapshot.pending.length > 0;
531
+ }
532
+
533
+ /** 从主会话 ChatSnapshot 的真实 order 收集尾部扁平工作项(含 live 合并与尾部提升),
534
+ * 作为折叠分组(foldedConversationTimeline/foldWorkGroups)的输入内核与分组成员级观察接缝。 */
535
+ export function conversationWorkItems(snapshot, limit = 4, cwd = "") {
536
+ const max = Math.max(0, limit);
537
+ if (max === 0) return [];
538
+ // 尾部反向收集:工作项只取尾部 max 个,长会话不再全序扫描;
539
+ // live 合并只作用于尾部子集(partial/runningCalls 的对应已定案项必在最近窗口内)。
540
+ const full = mergeLiveItems(rawTailItems(snapshot, max, cwd), snapshot, max, cwd);
541
+ return settleWhenIdle(promoteRunningTail(full.slice(-max), snapshot), snapshotIdle(snapshot));
542
+ }
543
+
544
+ /** 折叠分组硬边界:用户输入项与含正文输出的 assistant 项(R-01-017/AC-02)。 */
545
+ function isFoldBoundary(item) {
546
+ if (item.kind === "user") return true;
547
+ return item.kind === "assistant" && typeof item.text === "string" && item.text.trim() !== "";
548
+ }
549
+
550
+ /** 分组成员归一:tool/context 直接映射,assistant 取其 reasoning(detail)为思考成员。 */
551
+ function foldMemberOf(item) {
552
+ if (item.kind === "context") {
553
+ return { cat: "context", label: "上下文注入", summary: "", text: "", icon: "context", status: item.status, live: item.live === true };
554
+ }
555
+ if (item.kind === "tool") {
556
+ return {
557
+ cat: "tool",
558
+ label: TOOL_LABELS[item.toolName] ?? item.label ?? "Tool",
559
+ summary: typeof item.summary === "string" ? item.summary : "",
560
+ text: "",
561
+ icon: typeof item.icon === "string" ? item.icon : undefined,
562
+ // 结构化提问预览穿透折叠层(R-01-002/AC-09,C-064),供待回复卡渲染列表。
563
+ question: isQuestionPreview(item.question) ? item.question : null,
564
+ status: item.status,
565
+ live: item.live === true,
566
+ };
567
+ }
568
+ return {
569
+ cat: "think",
570
+ label: "思考",
571
+ summary: typeof item.summary === "string" ? item.summary : "",
572
+ text: typeof item.detail === "string" ? item.detail : "",
573
+ icon: "assistant",
574
+ status: item.status,
575
+ live: item.live === true,
576
+ };
577
+ }
578
+
579
+ /** 组行派生:镜像 dsh-auto-collapse updateChip 标题/状态优先级(vendor 移植,C-016)——
580
+ * running tool > running think > 运行了命令/编辑了文件 > 已思考,context 连续段独立成组;
581
+ * 含思考的完成组组摘要携带推理文本内容(R-01-017/AC-03、AC-04),纯工具完成组回退末位工具摘要;
582
+ * tool 组行图标统一命令图标 IconApiOutline14(与 auto-collapse 工具 chip 同源,C-016)。 */
583
+ function buildFoldRow(run) {
584
+ const members = run.members;
585
+ const runningTool = run.cat === "work" ? members.find((m) => m.cat === "tool" && m.status === "running") ?? null : null;
586
+ const runningThink = run.cat === "work" ? members.find((m) => m.cat === "think" && m.status === "running") ?? null : null;
587
+ const toolMembers = run.cat === "work" ? members.filter((m) => m.cat === "tool") : [];
588
+ const thinkMembers = run.cat === "work" ? members.filter((m) => m.cat === "think") : [];
589
+ const tools = [...new Set(toolMembers.map((m) => m.label))];
590
+ const hasError = members.some((m) => m.status === "error");
591
+ const hasStopped = members.some((m) => m.status === "stopped");
592
+ const status =
593
+ runningTool !== null || runningThink !== null ? "running" : hasError ? "error" : hasStopped ? "stopped" : "done";
594
+ let label;
595
+ let kind;
596
+ let icon;
597
+ if (run.cat === "context") {
598
+ label = "上下文注入";
599
+ kind = "context";
600
+ icon = "context";
601
+ } else if (runningTool !== null) {
602
+ label = "正在运行";
603
+ kind = "tool";
604
+ icon = "bash";
605
+ } else if (runningThink !== null) {
606
+ label = "正在思考";
607
+ kind = "assistant";
608
+ icon = "assistant";
609
+ } else if (tools.length > 0) {
610
+ label = tools.some((t) => t === "Edit" || t === "Write") ? "编辑了文件" : "运行了命令";
611
+ kind = "tool";
612
+ icon = "bash";
613
+ } else {
614
+ label = "已思考";
615
+ kind = "assistant";
616
+ icon = "assistant";
617
+ }
618
+ let summary = runningTool?.summary ?? runningThink?.summary ?? "";
619
+ if (status !== "running" && run.cat === "work") {
620
+ if (summary === "") {
621
+ // AC-04:含思考分组从最后一条思考向前取首个非空推理文本摘录。
622
+ for (let i = thinkMembers.length - 1; i >= 0 && summary === ""; i -= 1) {
623
+ summary = cleanPreview(thinkMembers[i].text, 88) ?? "";
624
+ }
625
+ }
626
+ if (summary === "" && toolMembers.length > 0) summary = toolMembers[toolMembers.length - 1].summary;
627
+ }
628
+ return {
629
+ // 组 id 只含首成员键:流式期间成员并入不改变 id,渲染层 DOM 复用与脉冲动画保持连续(评审修正)。
630
+ id: `fold:${run.cat}:${run.keys[0] ?? ""}`,
631
+ kind,
632
+ fold: true,
633
+ label,
634
+ text: "",
635
+ summary,
636
+ detail: null,
637
+ // 组内末条提问正文上浮组行(R-01-002/AC-09);无提问成员时为 null。
638
+ question: toolMembers.map((m) => m.question).filter(Boolean).pop() ?? null,
639
+ status,
640
+ live: members.some((member) => member.live === true),
641
+ icon,
642
+ };
643
+ }
644
+
645
+ /** 把扁平工作项序列折叠成分组行(R-01-017):硬边界为用户输入与含正文 assistant 项,
646
+ * 含正文 assistant 的 reasoning 先并入当前分组再闭组(splitThinkByBody 前置语义);
647
+ * 连续 context 独立成组;其余未知项原样透传。最多返回最近 limit 个显示行。 */
648
+ export function foldWorkGroups(items, limit = 4) {
649
+ const max = Math.max(0, limit);
650
+ if (max === 0) return [];
651
+ const rows = [];
652
+ let run = null;
653
+ const flush = () => {
654
+ if (run !== null && run.members.length > 0) rows.push(buildFoldRow(run));
655
+ run = null;
656
+ };
657
+ for (const item of Array.isArray(items) ? items : []) {
658
+ if (!item || typeof item !== "object") continue;
659
+ if (isFoldBoundary(item)) {
660
+ if (item.kind === "assistant" && typeof item.detail === "string" && item.detail.trim() !== "") {
661
+ if (run === null || run.cat !== "work") {
662
+ flush();
663
+ run = { cat: "work", members: [], keys: [] };
664
+ }
665
+ // 正文已流出 ⇒ 本步推理必然结束:拆入组的思考成员落定,不与正文行一起闪烁
666
+ // (真实在飞的 partial/runningCalls 行不受影响,多子代理并发同闪语义保留)。
667
+ const thinkMember = foldMemberOf(item);
668
+ run.members.push(thinkMember.status === "running" ? { ...thinkMember, status: "done" } : thinkMember);
669
+ run.keys.push(String(item.id ?? ""));
670
+ // 推理文本已并入当前组(组摘要承载,AC-04);正文行剥离推理展示并跳过原生行匹配,
671
+ // 避免同一推理文本在组行与下一行重复呈现(R-01-017/AC-02 验收修正)。
672
+ const body = { ...item, label: "助手", summary: item.text, detail: null, stripNative: true };
673
+ flush();
674
+ rows.push(body);
675
+ continue;
676
+ }
677
+ flush();
678
+ rows.push({ ...item });
679
+ continue;
680
+ }
681
+ const cat = item.kind === "context" ? "context" : item.kind === "tool" || item.kind === "assistant" ? "work" : null;
682
+ if (cat === null) {
683
+ flush();
684
+ rows.push({ ...item });
685
+ continue;
686
+ }
687
+ if (run === null || run.cat !== cat) {
688
+ flush();
689
+ run = { cat, members: [], keys: [] };
690
+ }
691
+ run.members.push(foldMemberOf(item));
692
+ run.keys.push(String(item.id ?? ""));
693
+ }
694
+ flush();
695
+ return rows.slice(-max);
696
+ }
697
+
698
+ /** history 事件条目解包:兼容 `{ event }` 包装与裸事件两种形态(尾扫类派生共用)。 */
699
+ function eventOf(entry) {
700
+ return isRecord(entry?.event) ? entry.event : entry;
701
+ }
702
+
703
+ /** 可锚用户行判定:非空文本的用户输入行(R-01-012/AC-12 口径,快照与 history 路径共用)。 */
704
+ export function isAnchorableUserRow(row) {
705
+ return row?.kind === "user" && typeof row.text === "string" && row.text.trim() !== "";
706
+ }
707
+
708
+ /** 当前活动行:优先取最新真实 live running,缺失 live 身份时回退最新 running。 */
709
+ function currentActivityIndex(rows) {
710
+ let runningIndex = -1;
711
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
712
+ const row = rows[i];
713
+ if (row?.status !== "running" || row.kind === "user") continue;
714
+ if (runningIndex < 0) runningIndex = i;
715
+ if (row.live === true) return i;
716
+ }
717
+ return runningIndex;
718
+ }
719
+
720
+ /** 指令锚行与工作行统一选择(R-01-009/AC-11、R-01-012/AC-12~AC-15、C-039):
721
+ * 可锚用户行作为普通显示行参与尾部窗口滚动,滚动至显示第一行时停留为指令锚行
722
+ * (满窗几何为其后显示行数 ≥ max-1;时间线不足一窗时自然窗口首行的可锚用户行直接停留)。
723
+ * 已存在停留锚行(窗口内停留锚行或 fallbackAnchor 充当窗口外停留锚行)时,滚动至显示
724
+ * 第二行的可锚用户行取代旧锚升上首行,其后各行上移、暂减一行,不从窗口之外回填旧行。
725
+ * fallbackAnchor 与窗口内任一可锚用户行同文本时判为同一消息,不充当停留锚行。
726
+ * 工作行选取保留最新真实当前活动行于末行,其余名额按最新顺序填满。 */
727
+ function selectTimelineRows(full, max, fallbackAnchor = null) {
728
+ const list = Array.isArray(full) ? full : [];
729
+ if (max <= 0) return [];
730
+ const workRows = (rows, budget) => {
731
+ if (budget <= 0) return [];
732
+ const activityIndex = currentActivityIndex(rows);
733
+ const activity = activityIndex >= 0 ? rows[activityIndex] : null;
734
+ const history = activityIndex < 0 ? rows : rows.filter((_, index) => index !== activityIndex);
735
+ const picked = history.slice(-Math.max(0, budget - (activity === null ? 0 : 1)));
736
+ if (activity !== null) picked.push(activity);
737
+ return picked;
738
+ };
739
+ const pin = (row) => ({ ...row, anchor: true });
740
+ const after = (index) => list.length - index - 1;
741
+ const userIndex = list.findLastIndex(isAnchorableUserRow);
742
+ if (userIndex < 0) {
743
+ // 窗口内无可锚用户行:history 提取的最近用户消息充当停留锚行(快照窗口外兜底)。
744
+ return isAnchorableUserRow(fallbackAnchor) ? [pin(fallbackAnchor), ...workRows(list, max - 1)] : workRows(list, max);
745
+ }
746
+ // fallbackAnchor 与窗口内可锚用户行同文本时判为同一消息,不充当停留锚行(避免同一指令双行)。
747
+ const fallback = isAnchorableUserRow(fallbackAnchor) && !list.some((row) => isAnchorableUserRow(row) && row.text === fallbackAnchor.text) ? fallbackAnchor : null;
748
+ // 链起点:fallback 充当的窗口外停留锚行;否则最早滚动触顶(after ≥ max-1)的用户行;
749
+ // 均无且时间线不足一窗时,自然窗口首行的可锚用户行直接停留(空时间线首条指令占据第一行)。
750
+ let anchorIndex = -1;
751
+ if (fallback === null) {
752
+ for (let i = 0; i <= userIndex; i += 1) {
753
+ if (isAnchorableUserRow(list[i]) && after(i) >= max - 1) {
754
+ anchorIndex = i;
755
+ break;
756
+ }
757
+ }
758
+ if (anchorIndex < 0 && list.length <= max) {
759
+ const head = workRows(list, max)[0] ?? null;
760
+ anchorIndex = head !== null && isAnchorableUserRow(head) ? list.indexOf(head) : -1;
761
+ }
762
+ if (anchorIndex < 0) return workRows(list, max);
763
+ }
764
+ // 逐次顶替:滚动至显示第二行(停留锚行之后窗口的首行)的可锚用户行取代旧锚成为新停留锚行。
765
+ while (true) {
766
+ const source = anchorIndex >= 0 ? list.slice(anchorIndex + 1) : list;
767
+ if (source.length >= max - 1) {
768
+ // 满窗:到达过第二行(after ≥ max-2)的最近可锚用户行为停留锚行。
769
+ let next = -1;
770
+ for (let i = list.length - 1; i > anchorIndex; i -= 1) {
771
+ if (isAnchorableUserRow(list[i]) && after(i) >= max - 2) {
772
+ next = i;
773
+ break;
774
+ }
775
+ }
776
+ if (next < 0) break;
777
+ anchorIndex = next;
778
+ } else {
779
+ // 短窗:第二行为停留锚行之后的首个历史行;为可锚用户行即顶替。
780
+ const second = workRows(source, max - 1)[0] ?? null;
781
+ if (!isAnchorableUserRow(second)) break;
782
+ const next = list.indexOf(second);
783
+ if (next < 0) break;
784
+ anchorIndex = next;
785
+ }
786
+ }
787
+ return anchorIndex >= 0
788
+ ? [pin(list[anchorIndex]), ...workRows(list.slice(anchorIndex + 1), max - 1)]
789
+ : [pin(fallback), ...workRows(list, max - 1)];
790
+ }
791
+
792
+ /** 折叠分组时间线(R-01-017):渲染层时间线的唯一来源——无条件折叠分组,不做任何探测切换。
793
+ * 指数扩窗收集尾部原始项(分组数不足 limit 时 ×3 → ×8 → 全序)+ live 合并 + 分组 +
794
+ * 尾部提升,长会话典型情况不触碰全序扫描。
795
+ * idle:渲染层判定的非执行呈现(等待响应/暂停,快照为冻结值、pending 不可得)——为 true 时残留 running 全部落定。 */
796
+ export function foldedConversationTimeline(snapshot, limit = 4, cwd = "", descendantActive = false, idle = false, fallbackAnchor = null) {
797
+ const max = Math.max(0, limit);
798
+ if (max === 0) return [];
799
+ // 非执行呈现(渲染层 idle 判定或快照 pending)且非委托周期:落定在分组之前——
800
+ // 组标题/状态由已定案成员派生(避免 done 圆点配「正在思考」标题),尾部提升同时跳过。
801
+ const settle = (idle === true || snapshotIdle(snapshot)) && descendantActive !== true;
802
+ for (const want of [max * 3, max * 8, Number.MAX_SAFE_INTEGER]) {
803
+ const items = rawTailItems(snapshot, want, cwd, true);
804
+ const merged = mergeLiveItems(items, snapshot, Number.MAX_SAFE_INTEGER, cwd);
805
+ const full = foldWorkGroups(settle ? settleWhenIdle(merged, true) : merged, Number.MAX_SAFE_INTEGER);
806
+ if (full.length >= max || want === Number.MAX_SAFE_INTEGER) {
807
+ // 指令锚行窗口选择与冷 history 路径共用(selectTimelineRows,R-01-012/AC-12~AC-15);
808
+ // settle/尾部提升出口归一为 finish。
809
+ const finish = (rows) => (settle ? rows : promoteRunningTail(rows, snapshot, descendantActive));
810
+ return finish(selectTimelineRows(full, max, fallbackAnchor));
811
+ }
812
+ }
813
+ return [];
814
+ }
815
+
816
+ function timelineItemFromEvent(entry, cwd = "") {
817
+ const event = isRecord(entry?.event) ? entry.event : entry;
818
+ const data = isRecord(event?.data) ? event.data : {};
819
+ if (!event || typeof event.type !== "string") return null;
820
+ if (event.type === "user/message" && data.source?.kind === "user") {
821
+ return { id: `user:${event.seq}`, kind: "user", icon: "user", label: "用户", text: contentText(data.content), detail: null, status: "done" };
822
+ }
823
+ if (event.type === "assistant/message") {
824
+ const text = contentText(data.message?.content);
825
+ return text ? { id: `assistant:${event.seq}`, kind: "assistant", icon: "assistant", text, detail: null, status: "done" } : null;
826
+ }
827
+ if (event.type === "tool/call") {
828
+ return timelineToolItem({ kind: "tool-call", callId: data.callId, name: data.name, argsRaw: data.arguments, callView: entry?.view?.for === "call" ? entry.view.view : null }, null, cwd);
829
+ }
830
+ if (event.type === "tool/result") {
831
+ return timelineToolItem(historyToolResultRoot(data, entry?.view?.for === "result" ? entry.view.view : null), null, cwd);
832
+ }
833
+ return null;
834
+ }
835
+
836
+ /** tool/result 事件的结果内容块(canonical message.content 中 type === 'tool-result' 者)。 */
837
+ function toolResultBlockOf(data) {
838
+ const content = isRecord(data?.message) && Array.isArray(data.message.content) ? data.message.content : [];
839
+ return content.find((block) => isRecord(block) && block.type === "tool-result") ?? null;
840
+ }
841
+
842
+ /** tool/result 事件的 callId:canonical 契约保证 message.source.callId 存在(dsh-tool-cordis ToolMessageSource)。 */
843
+ function toolResultCallId(data) {
844
+ const callId = isRecord(data?.message) && isRecord(data.message.source) ? data.message.source.callId : null;
845
+ return typeof callId === "string" && callId !== "" ? callId : undefined;
846
+ }
847
+
848
+ /** tool/result 事件 → timelineToolItem root(canonical 形状单点读取:data = { turn, step, message, error? });
849
+ * result 事件不携带 name/arguments,配对路径经 callInfo 由 call 事件补齐身份与 callView。 */
850
+ function historyToolResultRoot(data, resultView, callInfo = null) {
851
+ const block = toolResultBlockOf(data);
852
+ return {
853
+ kind: "tool-result",
854
+ callId: toolResultCallId(data),
855
+ ...(callInfo === null ? {} : { call: callInfo.call, callView: callInfo.callView ?? undefined }),
856
+ resultView,
857
+ isError: block?.isError === true,
858
+ error: data.error,
859
+ content: block?.content,
860
+ };
861
+ }
862
+
863
+ /** 判断 session window 是否尚未 hydrate,需用 native history 补齐。 */
864
+ export function needsHistorySnapshot(snapshot) {
865
+ return !snapshot || !Array.isArray(snapshot.chat?.order) || snapshot.chat.order.length === 0;
866
+ }
867
+
868
+ /** 冷会话 history 回溯深翻:自尾页起按 beforeSeq 向前翻页,直至命中最近一条用户消息
869
+ * (messagePreviews 的 userPreview 非空,R-01-013/AC-03)、或翻尽
870
+ * (hasMore=false/无更多事件/业务错误 null);requireOpenTurnStart 为 true 时
871
+ * (运行会话开放回合起点兜底,R-01-009/AC-06)命中用户消息后开放回合起点未命中
872
+ * 仍继续深翻直至起点命中或翻尽。maxPages 仅作显式护栏(默认 Infinity 即不设页数
873
+ * 上限——用户消息必然存在于会话最早段,翻尽必终止,无需预置页数界)。fetchPage
874
+ * (beforeSeq) 注入实际读取(返回 `{events, hasMore}` 或 null),便于纯函数单测;
875
+ * 中途异常保留已得事件并以 error 返回。返回 `{ events, error }`(events 按时间
876
+ * 正序,新页在后)。 */
877
+ export async function pagedHistoryEvents({ fetchPage, maxPages = Infinity, requireOpenTurnStart = false }) {
878
+ const allEvents = [];
879
+ let beforeSeq;
880
+ let hasMore = true;
881
+ let error = null;
882
+ for (let pages = 0; pages < maxPages && hasMore; pages += 1) {
883
+ const previews = messagePreviews({ history: allEvents });
884
+ if (previews.userPreview) {
885
+ if (!requireOpenTurnStart || openTurnStartFromEvents(allEvents) !== null) break;
886
+ }
887
+ let events;
888
+ try {
889
+ const value = await fetchPage(beforeSeq);
890
+ if (!value) break;
891
+ events = Array.isArray(value.events) ? value.events : [];
892
+ allEvents.unshift(...events);
893
+ hasMore = value.hasMore === true && events.length > 0;
894
+ } catch (caught) {
895
+ error = caught;
896
+ break;
897
+ }
898
+ const firstSeq = events[0]?.event?.seq;
899
+ if (!Number.isFinite(firstSeq)) break;
900
+ beforeSeq = firstSeq;
901
+ }
902
+ return { events: allEvents, error };
903
+ }
904
+
905
+ /** 冷会话 history 的扁平工作项映射:供没有 ChatSnapshot 的活动/历史会话折叠分组使用。
906
+ * native `sessions.history` 响应只含 `{events, hasMore, projections?}`(in-flight
907
+ * partial 以 chunk 事件携带,不做逐 chunk 折叠),故只从事件流取尾部工作项。
908
+ * tool/result 落定同 callId 的 call 项(原位替换,name/arguments/callView 由 call 事件补齐):
909
+ * history 是冻结过去,call 事件单独留存会成为永久 running 幽灵行(R-01-016/AC-01)。 */
910
+ export function conversationTimelineFromHistory(history, limit = 4, cwd = "") {
911
+ const items = [];
912
+ const inflightCalls = new Map(); // callId → { index, data, callView }:等待结果落定的 tool/call 事件
913
+ for (const entry of Array.isArray(history) ? history : []) {
914
+ const event = eventOf(entry);
915
+ const data = isRecord(event?.data) ? event.data : {};
916
+ if (event?.type === "tool/result") {
917
+ const callId = toolResultCallId(data);
918
+ const pending = callId !== undefined ? inflightCalls.get(callId) : undefined;
919
+ if (pending !== undefined) {
920
+ inflightCalls.delete(callId);
921
+ items[pending.index] = timelineToolItem(
922
+ historyToolResultRoot(data, entry?.view?.for === "result" ? entry.view.view : null, {
923
+ call: { name: typeof pending.data.name === "string" ? pending.data.name : "", argsRaw: typeof pending.data.arguments === "string" ? pending.data.arguments : "" },
924
+ callView: pending.callView,
925
+ }),
926
+ null,
927
+ cwd,
928
+ );
929
+ continue;
930
+ }
931
+ }
932
+ const item = timelineItemFromEvent(entry, cwd);
933
+ if (!item) continue;
934
+ if (event?.type === "tool/call" && item.callId !== "") {
935
+ inflightCalls.set(item.callId, { index: items.length, data, callView: entry?.view?.for === "call" ? entry.view.view : null });
936
+ }
937
+ items.push(item);
938
+ }
939
+ const max = Math.max(0, limit);
940
+ return max === 0 ? [] : items.slice(-max);
941
+ }
942
+
943
+ /** 冷 history 折叠分组时间线(R-01-017、R-01-012/AC-12~AC-15):页内全部事件映射折叠后
944
+ * 套用与快照路径同一窗口/锚行选择(selectTimelineRows),最近用户消息滚动触顶后停留为
945
+ * 首行锚行。 */
946
+ export function foldedHistoryTimeline(history, limit = 4, cwd = "") {
947
+ const max = Math.max(0, limit);
948
+ if (max === 0) return [];
949
+ const items = conversationTimelineFromHistory(history, Number.MAX_SAFE_INTEGER, cwd);
950
+ return selectTimelineRows(foldWorkGroups(items, Number.MAX_SAFE_INTEGER), max);
951
+ }
952
+
953
+ /** history 指令锚行提取(R-01-012/AC-12 快照窗口外兜底):尾部反向取最近一条非空文本的
954
+ * 真实用户消息(source.kind === "user"),归一为带 anchor 标记的用户行;无则返回 null。 */
955
+ export function historyInstructionAnchor(history) {
956
+ const list = Array.isArray(history) ? history : [];
957
+ for (let i = list.length - 1; i >= 0; i -= 1) {
958
+ const event = eventOf(list[i]);
959
+ if (event?.type !== "user/message" || event.data?.source?.kind !== "user") continue;
960
+ const text = contentText(event.data.content);
961
+ if (text.trim() === "") continue;
962
+ return { id: `user:${event.seq}`, kind: "user", icon: "user", label: "用户", text, detail: null, status: "done", anchor: true };
963
+ }
964
+ return null;
965
+ }
966
+
967
+
968
+ /** 开放回合起点缺口判定(R-01-009/AC-06 冷窗口兜底触发口径):快照就绪、宿主判定运行中、
969
+ * 轮内订阅已建立,但快照 turnTimings 无开放回合起点(liveStartTime 为 null)——超长回合
970
+ * 的 turn/start 在尾页窗口之外。等待/空闲会话(非运行或无 liveness 记录)不算缺口,
971
+ * 不触发 history 补读。 */
972
+ export function openTurnStartMissing({ snapshotReady = false, running = false, hasLiveness = false, liveStartTime = null } = {}) {
973
+ return snapshotReady === true && running === true && hasLiveness === true && liveStartTime == null;
974
+ }
975
+
976
+ /** 开放回合起点兜底提取(R-01-009/AC-06):history 事件尾部反向扫描,最近一条边界事件
977
+ * 为 turn/start 即存在开放回合、返回其时刻;为 turn/end 则无开放回合返回 null。
978
+ * minTurn:快照已知的最晚回合号——history 开放回合落后于此(拉取后已切换新回合)时
979
+ * 判为陈旧返回 null。turn/start 时刻非法或输入非数组归一 null。 */
980
+ export function openTurnStartFromEvents(events, minTurn = -Infinity) {
981
+ const list = Array.isArray(events) ? events : [];
982
+ for (let i = list.length - 1; i >= 0; i -= 1) {
983
+ const event = eventOf(list[i]);
984
+ if (event?.type === "turn/end") return null;
985
+ if (event?.type !== "turn/start") continue;
986
+ const turn = Number(event.data?.turn);
987
+ if (Number.isFinite(turn) && turn < minTurn) return null;
988
+ const time = Number(event.time);
989
+ return Number.isFinite(time) ? time : null;
990
+ }
991
+ return null;
992
+ }
993
+
994
+ /** 从 ChatSnapshot/history 取最近用户与 agent reply 的物理首行。
995
+ * 尾部反向扫描:找到最近的用户项与 assistant 项即停,长会话不再全序物化时间线。 */
996
+ export function messagePreviews({ snapshot = null, history = [] } = {}) {
997
+ let user = "";
998
+ // live partial 位于会话尾部:存在即是最新的 agent 文本。
999
+ let agent = firstPhysicalLine(assistantBlockText(snapshot?.partial?.blocks, "text"));
1000
+ const chat = snapshot?.chat;
1001
+ const order = Array.isArray(chat?.order) ? chat.order : [];
1002
+ const nodes = chat?.nodes;
1003
+ for (let i = order.length - 1; i >= 0 && (!user || !agent); i -= 1) {
1004
+ let node;
1005
+ try {
1006
+ node = nodes?.get?.(order[i]) ?? nodes?.[order[i]];
1007
+ } catch {
1008
+ node = undefined;
1009
+ }
1010
+ const item = timelineItemFromChatNode(node);
1011
+ if (!item) continue;
1012
+ if (item.kind === "user" && !user && item.text) user = firstPhysicalLine(item.text);
1013
+ if (item.kind === "assistant" && !agent && item.text) agent = firstPhysicalLine(item.text);
1014
+ }
1015
+ if (!user || !agent) {
1016
+ // history 事件按时间正序(旧→新):尾部反向扫描取最近命中,首个非空即最近
1017
+ // (R-01-013/AC-03、AC-04;深翻多页场景下必须取最近而非最早)。
1018
+ for (let i = (Array.isArray(history) ? history : []).length - 1; i >= 0 && (!user || !agent); i -= 1) {
1019
+ const entry = history[i];
1020
+ const event = entry?.event ?? entry;
1021
+ if (event?.type === "user/message" && event.data?.source?.kind === "user") user = firstPhysicalLine(contentText(event.data.content)) || user;
1022
+ if (event?.type === "assistant/message") agent = firstPhysicalLine(contentText(event.data?.message?.content)) || agent;
1023
+ }
1024
+ }
1025
+ return { userPreview: user, agentPreview: agent };
1026
+ }
1027
+ /** 归一化 native sessions.models 返回的当前模型与 reasoning level。 */
1028
+ export function modelMetadata(models) {
1029
+ const current = isRecord(models?.current) ? models.current : null;
1030
+ if (!current) return { model: "", reasoning: "" };
1031
+ let selected = null;
1032
+ for (const group of Array.isArray(models?.groups) ? models.groups : []) {
1033
+ const found = Array.isArray(group?.models) ? group.models.find((model) => model?.id === current.model) : null;
1034
+ if (found) {
1035
+ selected = found;
1036
+ break;
1037
+ }
1038
+ }
1039
+ const reasoning = selected?.reasoning;
1040
+ const effortId = current.reasoningEffort ?? reasoning?.defaultEffort;
1041
+ const effort = Array.isArray(reasoning?.efforts) ? reasoning.efforts.find((item) => item?.id === effortId) : null;
1042
+ return {
1043
+ model: typeof selected?.name === "string" && selected.name ? selected.name : typeof current.model === "string" ? current.model : "",
1044
+ reasoning: typeof effort?.name === "string" && effort.name ? effort.name : typeof effortId === "string" ? effortId : "",
1045
+ };
1046
+ }
1047
+
1048
+ /** 只提供卡片底部所需的原始统计字段,不拼接当前动作文案。 */
1049
+ export function runtimeStats({ elapsedMs = null, outputTokens = null, rateTokS = null } = {}) {
1050
+ return {
1051
+ elapsedMs: Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs : null,
1052
+ outputTokens: Number.isFinite(outputTokens) && outputTokens >= 0 ? outputTokens : null,
1053
+ rateTokS: Number.isFinite(rateTokS) && rateTokS > 0 ? rateTokS : null,
1054
+ };
1055
+ }
1056
+
1057
+ /** 计费输入与缓存命中率:口径对齐原生统计行——计费输入=未缓存输入+缓存读+缓存写,
1058
+ * 命中率=缓存读÷计费输入(百分比四舍五入);全空归 null,有输入无读桶时命中率未知。 */
1059
+ export function usageSummary({ uncachedInputTokens = null, cacheReadTokens = null, cacheWriteTokens = null } = {}) {
1060
+ const bucket = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
1061
+ const uncached = bucket(uncachedInputTokens);
1062
+ const read = bucket(cacheReadTokens);
1063
+ const write = bucket(cacheWriteTokens);
1064
+ if (uncached === null && read === null && write === null) return { inputTokens: null, cacheHitPct: null };
1065
+ const inputTokens = (uncached ?? 0) + (read ?? 0) + (write ?? 0);
1066
+ const cacheHitPct = read !== null && inputTokens > 0 ? Math.round((read / inputTokens) * 100) : null;
1067
+ return { inputTokens, cacheHitPct };
1068
+ }
1069
+
1070
+ /** 需要用户行动的种类的展示文案。 */
1071
+ export function pendingText(kind) {
1072
+ return PENDING_LABELS[kind] ?? PENDING_UNKNOWN_LABEL;
1073
+ }
1074
+
1075
+ /** 等待卡普通末行提示(R-01-002/AC-09,C-040、C-064):待确认/待审查说明动作
1076
+ * 与后果;待回复在结构化问题不可得时回落动作说明;完成提醒固定引导新指令或移入历史。 */
1077
+ export function awaitNoteText(waitClass, pendingKind) {
1078
+ if (waitClass === "done") return ROUND_DONE_NOTE;
1079
+ return PENDING_NOTES[pendingKind] ?? PENDING_UNKNOWN_NOTE;
1080
+ }
1081
+
1082
+ /** 计数徽标底色跟随等待构成(R-01-002/AC-06,C-040、C-043):按 错误 > 阻塞 > 完成
1083
+ * 的优先级取色——存在任一错误提醒主会话即取 `'error'`(红=最紧迫),否则存在阻塞
1084
+ * 等待主会话取 `'blocked'`(金催促),等待全部为完成提醒时取 `'done'`(绿=已完成
1085
+ * 不急);无等待行动或仅运行卡时返回 null。子代理不计入。 */
1086
+ export function awaitBadgeTone(entries) {
1087
+ let tone = null;
1088
+ for (const entry of Array.isArray(entries) ? entries : []) {
1089
+ if (entry?.kind !== "awaiting") continue;
1090
+ if (entry.waitClass === "error") return "error";
1091
+ if (entry.waitClass === "blocked") tone = "blocked";
1092
+ else if (entry.waitClass === "done" && tone === null) tone = "done";
1093
+ }
1094
+ return tone;
1095
+ }
1096
+
1097
+ /** 时间线末条 ask_user_question 工作项携带的结构化提问预览;折叠组行同样上浮该字段。
1098
+ * 不存在时返回 null(R-01-002/AC-09,C-064)。 */
1099
+ export function timelineQuestionPreview(timeline) {
1100
+ const rows = Array.isArray(timeline) ? timeline : [];
1101
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
1102
+ const question = rows[i]?.question;
1103
+ if (isQuestionPreview(question)) return question;
1104
+ }
1105
+ return null;
1106
+ }
1107
+
1108
+ /** 数量徽标统计:只统计主会话——分子为等待行动(awaiting,含完成提醒)的主会话数,
1109
+ * 分母为其加运行中(running,含委托周期保持运行呈现)主会话之和;子代理不计入
1110
+ * (R-01-001/AC-05)。blocked 为其中阻塞等待(待确认/待审查/待回复)的主会话数,
1111
+ * 仅用于徽标 aria 文案的计数说明;脉冲门控已由 waiting 单参数承载(R-01-002/AC-06,C-037)。
1112
+ * 空列表返回 { waiting: 0, blocked: 0, total: 0 }。 */
1113
+ export function awaitBadgeStats(entries) {
1114
+ let waiting = 0;
1115
+ let blocked = 0;
1116
+ let total = 0;
1117
+ for (const entry of Array.isArray(entries) ? entries : []) {
1118
+ if (entry?.kind !== "running" && entry?.kind !== "awaiting") continue;
1119
+ total += 1;
1120
+ if (entry.kind === "awaiting") {
1121
+ waiting += 1;
1122
+ if (entry.waitClass === "blocked") blocked += 1;
1123
+ }
1124
+ }
1125
+ return { waiting, blocked, total };
1126
+ }
1127
+
1128
+ /** 数量标识呈现态(R-01-014/AC-06):列表在途(loading)时不冒充计数——归一为
1129
+ * loading 呈现(加载指示 + 加载中 aria 文案,不等待、不脉冲);否则归一为 count
1130
+ * 呈现(n/m 文本 + 计数 aria 文案)。awaiting 表达「存在等待行动」——底色经
1131
+ * awaitBadgeTone(错误 > 阻塞 > 完成,红/金/绿)与脉冲门控同一信号:任一等待行动
1132
+ * (阻塞等待、完成提醒或错误提醒)即脉冲(R-01-002/AC-06,C-037、C-043)。
1133
+ * blocked 入参只用于 aria 文案的计数说明,不再驱动门控。错误轴不算在途,维持计数呈现。 */
1134
+ export function countBadgeState(listState, waiting, total, blocked = 0) {
1135
+ if (listState === "loading") return { mode: "loading", text: "", ariaText: "活动会话计数加载中", awaiting: false };
1136
+ const awaiting = waiting > 0;
1137
+ const hasBlocked = blocked > 0;
1138
+ const doneCount = waiting - (hasBlocked ? blocked : 0);
1139
+ return {
1140
+ mode: "count",
1141
+ text: `${waiting}/${total}`,
1142
+ ariaText: hasBlocked
1143
+ ? doneCount > 0
1144
+ ? `${total} 个活动会话,${blocked} 个等待你答复,${doneCount} 个已完成`
1145
+ : `${total} 个活动会话,${blocked} 个等待你答复`
1146
+ : awaiting
1147
+ ? `${total} 个活动会话,${waiting} 个已完成`
1148
+ : `${total} 个活动会话`,
1149
+ awaiting,
1150
+ };
1151
+ }
1152
+
1153
+ /** CSS 字符串字面量转义(用于属性选择器的加引号形式):先反斜杠后引号,再处理 CSS 字符串
1154
+ * 不允许的换行/回车/换页(码位转义)与 NUL(替换字符),顺序不可颠倒。 */
1155
+ export function escapeCssString(value) {
1156
+ return String(value)
1157
+ .replace(/\\/g, "\\\\")
1158
+ .replace(/"/g, '\\"')
1159
+ .replace(/\n/g, "\\a ")
1160
+ .replace(/\r/g, "\\d ")
1161
+ .replace(/\f/g, "\\c ")
1162
+ .replace(/\0/g, "�");
1163
+ }
1164
+
1165
+ /** 冷会话补充数据读取决策(单次渲染内是否发起 models/history 读取)。
1166
+ * 失败路径会写入空 model/history 使决策转为「不读」(可见期内不热重试);
1167
+ * 详情与记账随可见性清理(pruneInvisibleEntries)一起移除后,决策自然恢复为「读取」。
1168
+ * windowComplete(R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底):快照已就绪但窗口缺
1169
+ * 锚点数据(开放回合起点或可锚用户行在窗口外)时为 false——此时仍发起一次 history
1170
+ * 补读,供进度锚点与指令锚行兜底。previewFallbackNeeded 表示最近卡的快照预览
1171
+ * 不完整,同样补读一次 history(R-01-013/AC-03、AC-04)。 */
1172
+ export function detailLoadPlan({
1173
+ detail = {},
1174
+ isSubagent = false,
1175
+ snapshotReady = false,
1176
+ historyNeeded = false,
1177
+ previewFallbackNeeded = false,
1178
+ windowComplete = true,
1179
+ modelInflight = false,
1180
+ historyInflight = false,
1181
+ } = {}) {
1182
+ return {
1183
+ subagent: isSubagent === true,
1184
+ model: !isSubagent && !detail.model && !modelInflight,
1185
+ history:
1186
+ !historyInflight &&
1187
+ ((previewFallbackNeeded && detail.previewFallbackLoaded !== true) ||
1188
+ (!detail.history && ((!snapshotReady && historyNeeded) || (snapshotReady === true && windowComplete === false)))),
1189
+ };
1190
+ }
1191
+
1192
+ /** 打开重试链是否应取消:目标已成为当前会话(已到达),或用户已激活其它卡片(被新意图取代)。 */
1193
+ export function shouldCancelOpenRetry({ targetId, currentId = null, activatedId = null } = {}) {
1194
+ if (targetId === undefined || targetId === null) return true;
1195
+ if (currentId !== null && currentId !== undefined && String(currentId) === String(targetId)) return true;
1196
+ if (activatedId !== null && activatedId !== undefined && String(activatedId) !== String(targetId)) return true;
1197
+ return false;
1198
+ }
1199
+
1200
+ /** 可见性清理:把不在 visibleIds 中的 id 从每张记账 Map 中删除(详情与 loads 记账同生命周期)。 */
1201
+ export function pruneInvisibleEntries(maps, visibleIds) {
1202
+ const visible = visibleIds instanceof Set ? visibleIds : new Set(visibleIds ?? []);
1203
+ for (const map of Array.isArray(maps) ? maps : []) {
1204
+ if (!(map instanceof Map)) continue;
1205
+ for (const id of map.keys())
1206
+ if (!visible.has(id)) map.delete(id);
1207
+ }
1208
+ }
1209
+
1210
+ /** 订阅清理(R-01-012/AC-16):不可见 id 的订阅先 unsubscribe 再除名——监听器不得残留;
1211
+ * 单个 unsubscribe 抛错吞掉,不阻断其余订阅的清理。 */
1212
+ export function pruneSubscriptions(subscriptions, visibleIds) {
1213
+ if (!(subscriptions instanceof Map)) return;
1214
+ const visible = visibleIds instanceof Set ? visibleIds : new Set(visibleIds ?? []);
1215
+ for (const [id, unsubscribe] of subscriptions) {
1216
+ if (visible.has(id)) continue;
1217
+ try {
1218
+ unsubscribe?.();
1219
+ } catch {}
1220
+ subscriptions.delete(id);
1221
+ }
1222
+ }
1223
+
1224
+ /**
1225
+ * 会话的工作区归属归一(R-01-003/AC-08):在 title 归属判定的同一路径上同时
1226
+ * 返回工作区身份 key(路径优先、名称兜底),供徽标色相派生;无归属时两者皆空。
1227
+ */
1228
+ export function workspaceInfoForSession(sessionId, workspaceItems, byId = {}) {
1229
+ const id = String(sessionId);
1230
+ const items = Array.isArray(workspaceItems) ? workspaceItems : [];
1231
+
1232
+ for (const workspace of items) {
1233
+ if (!isRecord(workspace)) continue;
1234
+ const title = cleanText(workspace.title);
1235
+ if (!title || !Array.isArray(workspace.sessionIds)) continue;
1236
+ if (workspace.sessionIds.some((candidate) => String(candidate) === id))
1237
+ return { title, key: cleanText(workspace.path) || title };
1238
+ }
1239
+
1240
+ const cwd = cleanText(byId[id]?.cwd);
1241
+ if (!cwd) return { title: "", key: "" };
1242
+ const workspace = items.find(
1243
+ (candidate) => isRecord(candidate) && cleanText(candidate.path) === cwd,
1244
+ );
1245
+ const title = cleanText(workspace?.title);
1246
+ return { title, key: title ? cleanText(workspace?.path) || title : "" };
1247
+ }
1248
+
1249
+ /**
1250
+ * 工作区徽标色相(R-01-003/AC-08、AC-09):以工作区身份为唯一输入的纯函数——
1251
+ * djb2 哈希经雪崩终混(异或右移 + 乘法,把高位熵折入低位,消除 djb2 低位分布
1252
+ * 聚集;每步 >>> 0 保持无符号,异或结果可能带符号位)后在避开红色警戒区的
1253
+ * 色相弧 [30°,320°] 上均匀取色(30 + hash % 291),输出 [30,320] 整数。
1254
+ * 同一身份恒得同一色相,与工作区列表顺序、会话状态及持久化存储无关,页面
1255
+ * 刷新后不变;空身份返回 null。
1256
+ */
1257
+ export function workspaceHue(key) {
1258
+ const text = cleanText(key);
1259
+ if (!text) return null;
1260
+ let hash = 5381;
1261
+ for (let i = 0; i < text.length; i += 1)
1262
+ hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0;
1263
+ hash = (hash ^ (hash >>> 16)) >>> 0;
1264
+ // 0x45d9f3b:公开流传的 32-bit 整数雪崩终混常数(见 C-029 决策记录),
1265
+ // 乘法扩散后再次异或右移,使低位获得充分混合;必须用 Math.imul——普通
1266
+ // 乘法的乘积(最大约 5×10^18)超出 double 精确整数上限 2^53,低 32 位
1267
+ // 会丢失精度。
1268
+ hash = Math.imul(hash, 0x45d9f3b) >>> 0;
1269
+ hash = (hash ^ (hash >>> 16)) >>> 0;
1270
+ return 30 + (hash % 291);
1271
+ }
1272
+
1273
+ const WORKSPACE_HUE_ANCHORS = [55, 100, 145, 190, 235, 280, 325];
1274
+
1275
+ /**
1276
+ * 同屏工作区色相消解(R-01-003/AC-08、AC-12):身份去重排序后,以稳定基色
1277
+ * 确定七个 OKLCH 感知锚点的起始槽;撞槽时按步进 3 跨色区探测。超过七个
1278
+ * 工作区后选择当前使用次数最少的槽,使复用均衡且确定。
1279
+ */
1280
+ export function resolveWorkspaceHues(keys) {
1281
+ const identities = [...new Set((Array.isArray(keys) ? keys : []).map(cleanText).filter(Boolean))].sort();
1282
+ const uses = WORKSPACE_HUE_ANCHORS.map(() => 0);
1283
+ const resolved = new Map();
1284
+ for (const identity of identities) {
1285
+ const start = (workspaceHue(identity) - 30) % WORKSPACE_HUE_ANCHORS.length;
1286
+ let chosen = start;
1287
+ for (let offset = 0; offset < WORKSPACE_HUE_ANCHORS.length; offset += 1) {
1288
+ const candidate = (start + offset * 3) % WORKSPACE_HUE_ANCHORS.length;
1289
+ if (uses[candidate] < uses[chosen]) chosen = candidate;
1290
+ if (uses[candidate] === 0) {
1291
+ chosen = candidate;
1292
+ break;
1293
+ }
1294
+ }
1295
+ uses[chosen] += 1;
1296
+ resolved.set(identity, WORKSPACE_HUE_ANCHORS[chosen]);
1297
+ }
1298
+ return resolved;
1299
+ }
1300
+
1301
+ /** 主会话按左侧工作区顺序排序的权重;不在任何 workspace 的排在最后保持 lineage 顺序。 */
1302
+ function workspaceRank(workspaceItems) {
1303
+ const wsIndex = new Map();
1304
+ const posIndex = new Map();
1305
+ for (const workspace of workspaceItems ?? []) {
1306
+ if (!isRecord(workspace)) continue;
1307
+ const sessionIds = Array.isArray(workspace.sessionIds)
1308
+ ? workspace.sessionIds
1309
+ : [];
1310
+ sessionIds.forEach((sid, p) => {
1311
+ const key = String(sid);
1312
+ if (!wsIndex.has(key)) {
1313
+ wsIndex.set(key, wsIndex.size);
1314
+ posIndex.set(key, p);
1315
+ }
1316
+ });
1317
+ }
1318
+ return (id) => {
1319
+ const key = String(id);
1320
+ return {
1321
+ ws: wsIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1322
+ pos: posIndex.get(key) ?? Number.MAX_SAFE_INTEGER,
1323
+ };
1324
+ };
1325
+ }
1326
+
1327
+ /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。 */
1328
+ export function subagentTitle(parentId, id, byId, subagentsByParent = {}) {
1329
+ const entries = subagentsByParent[parentId]?.entries;
1330
+ const entry = Array.isArray(entries)
1331
+ ? entries.find((candidate) => String(candidate?.id) === String(id))
1332
+ : undefined;
1333
+ const label = cleanText(entry?.label);
1334
+ if (label) return label;
1335
+ const display = cleanText(byId[id]?.displayTitle);
1336
+ if (display) return display;
1337
+ return "子任务";
1338
+ }
1339
+
1340
+ /** 主会话的展示标题:优先 displayTitle,兜底 "当前会话"。 */
1341
+ export function mainTitle(byId, id) {
1342
+ const display = cleanText(byId[id]?.displayTitle);
1343
+ return display || String(id) || "当前会话";
1344
+ }
1345
+
1346
+ /**
1347
+ * 把 sessions/workspaces 快照构建成窗格条目列表(有序、已含层级与显示过滤)。
1348
+ * 返回数组的每一项:
1349
+ * { id, parentId?, depth, kind: 'running'|'awaiting'|'subagent', title, workspaceTitle, workspaceKey,
1350
+ * isCurrent, pendingText?, descendantActive? }
1351
+ * kind 规则:
1352
+ * - 主会话 running(且无 pending)或处于委托周期(含后代耗尽空窗)→ 'running'
1353
+ * - 主会话 pendingInteraction / completed / errorReminder → 'awaiting'(等待用户行动)
1354
+ * - 子代理 running / pending 或存在活动后代 → 'subagent',自身与后代均不活动则不显示
1355
+ * completions(完成确认与错误提醒,R-01-002/AC-05、AC-13、R-01-010/AC-06):Map id →
1356
+ * { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt },由渲染层从宿主侧 ack 状态
1357
+ * 通道注入;其中完成提醒成立(completionReminder)或错误提醒成立(errorReminder)的
1358
+ * 主会话按 awaiting 保留在活动区。delegatingIds(委托周期集合,渲染层由 progressAnchor
1359
+ * 记账派生):集合内会话视同处于委托周期——后代耗尽至 settle 处理回合启动的
1360
+ * 空窗内仍保持运行呈现、完成/错误提醒不生效;条目的 descendantActive 字段
1361
+ * 始终为当帧原始后代活性(供进度锚点记账判定耗尽),不受 delegatingIds 影响。
1362
+ */
1363
+ export function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null) {
1364
+ const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1365
+ const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1366
+ const current = snapshot?.current ?? null;
1367
+ const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1368
+ ? snapshot.subagentsByParent
1369
+ : {};
1370
+ const rank = workspaceRank(workspaceItems ?? []);
1371
+ const descendantIds = descendantActiveIds(byId);
1372
+ // 第一遍:层级关系 + 显示判定(show = 自身活动 || 委托周期 || 完成提醒,单点实现避免漂移)。
1373
+ const rootIds = [];
1374
+ const childIds = new Map();
1375
+ const meta = new Map();
1376
+ for (const id of ids) {
1377
+ const row = byId[id];
1378
+ if (!isRecord(row)) continue;
1379
+ const hasParent = isSubagentRow(row, byId);
1380
+ if (hasParent) {
1381
+ const list = childIds.get(String(row.parentId)) ?? [];
1382
+ list.push(id);
1383
+ childIds.set(String(row.parentId), list);
1384
+ } else {
1385
+ rootIds.push(id);
1386
+ }
1387
+ const running = row.running === true;
1388
+ const pending = row.pendingInteraction !== undefined;
1389
+ const isSub = hasParent;
1390
+ // 完成确认(R-01-002/AC-03、AC-05、R-01-010/AC-06):未确认的完成提醒按自身活动计入。
1391
+ const done = completionReminder(row, completionFor(id, completions), isSub);
1392
+ // 错误提醒(R-01-002/AC-13,C-043):最近回合以错误结束的按自身活动计入。
1393
+ const err = errorReminder(row, completionFor(id, completions), isSub);
1394
+ // 子代理完成且没有活动后代时消失;主会话完成后保留为"等待打开";母会话在委托周期保持运行呈现(R-01-003/AC-05)。
1395
+ const selfActive = isOwnActiveRow(row, byId);
1396
+ const descendantActive = descendantIds.has(String(id));
1397
+ const delegating = descendantActive || (delegatingIds instanceof Set && delegatingIds.has(String(id)));
1398
+ const show = selfActive || delegating || done || err;
1399
+ meta.set(id, { row, running, pending, isSub, show, done, err, descendantActive, delegating, depth: 0 });
1400
+ }
1401
+
1402
+ // 主会话按 workspace 顺序排序;未归入任何工作区的主会话保持在 lineage 中靠后。
1403
+ rootIds.sort((a, b) => {
1404
+ const ra = rank(a);
1405
+ const rb = rank(b);
1406
+ if (ra.ws !== rb.ws) return ra.ws - rb.ws;
1407
+ if (ra.pos !== rb.pos) return ra.pos - rb.pos;
1408
+ return ids.indexOf(a) - ids.indexOf(b);
1409
+ });
1410
+
1411
+ const entries = [];
1412
+ const visited = new Set();
1413
+ const visit = (id, depth) => {
1414
+ if (visited.has(id)) return;
1415
+ visited.add(id);
1416
+ const m = meta.get(id);
1417
+ if (m === undefined) return;
1418
+ m.depth = depth;
1419
+ if (m.show) {
1420
+ const parentId = m.row.parentId;
1421
+ const details = mapValue(detailsById, id) ?? {};
1422
+ const metadata = details.model ?? modelMetadata(details.models ?? m.row.models);
1423
+ // timeline/previews 不在此推导:渲染层按快照引用 memo 计算(冷会话由 history 一次性写入 detail),
1424
+ // 避免每次渲染对每个可见会话重复全序扫描。
1425
+ const timeline = details.timeline ?? [];
1426
+ const previews = details.previews ?? { userPreview: "", agentPreview: "" };
1427
+ const workspace = m.isSub
1428
+ ? { title: "", key: "" }
1429
+ : workspaceInfoForSession(id, workspaceItems ?? [], byId);
1430
+ // 完成确认判定单点(R-01-002,C-040、C-064):pendingText/waitClass/noteText/questionPreview 共用;
1431
+ // 错误提醒(C-043)优先于完成提醒(同一回合登记只能有一个 lastTurnEndKind)。
1432
+ const doneWait = !m.pending && m.done && !m.running && !m.delegating;
1433
+ const errWait = !m.pending && m.err && !m.running && !m.delegating;
1434
+ const errorNote = entryErrorNote(completionFor(id, completions));
1435
+ const questionPreview =
1436
+ m.pending && m.row.pendingInteraction === "question" ? timelineQuestionPreview(timeline) : undefined;
1437
+ entries.push({
1438
+ id,
1439
+ parentId: m.isSub ? String(parentId) : null,
1440
+ depth,
1441
+ kind: m.isSub
1442
+ ? "subagent"
1443
+ : m.pending
1444
+ ? "awaiting"
1445
+ : m.running || m.delegating
1446
+ ? "running"
1447
+ : "awaiting",
1448
+ descendantActive: m.descendantActive,
1449
+ title: m.isSub
1450
+ ? subagentTitle(parentId, id, byId, subagentsByParent)
1451
+ : mainTitle(byId, id),
1452
+ workspaceTitle: workspace.title,
1453
+ workspaceKey: workspace.key,
1454
+ model: metadata.model ?? "",
1455
+ reasoning: metadata.reasoning ?? "",
1456
+ timeline: timeline ?? [],
1457
+ userPreview: previews.userPreview ?? "",
1458
+ agentPreview: previews.agentPreview ?? "",
1459
+ isCurrent: current !== null && String(current) === String(id),
1460
+ // 完成提醒卡不显示类型徽标(C-040):pendingText 仅为阻塞等待承载。
1461
+ pendingText: m.pending ? pendingText(m.row.pendingInteraction) : undefined,
1462
+ // 等待三类(R-01-002,C-043):blocked=阻塞等待(金色)、error=错误提醒(红色)、
1463
+ // done=完成提醒(绿色);错误提醒优先于完成提醒(同一登记只有一个结束原因)。
1464
+ waitClass: m.pending
1465
+ ? "blocked"
1466
+ : errWait
1467
+ ? "error"
1468
+ : doneWait
1469
+ ? "done"
1470
+ : undefined,
1471
+ pendingKind: m.pending ? m.row.pendingInteraction : undefined,
1472
+ noteText: m.pending
1473
+ ? awaitNoteText("blocked", m.row.pendingInteraction)
1474
+ : errWait
1475
+ ? errorNote
1476
+ : doneWait
1477
+ ? ROUND_DONE_NOTE
1478
+ : undefined,
1479
+ questionPreview: m.pending && m.row.pendingInteraction === "question" ? (questionPreview ?? null) : undefined,
1480
+ });
1481
+ }
1482
+ for (const child of childIds.get(id) ?? []) visit(child, depth + 1);
1483
+ };
1484
+ for (const root of rootIds) visit(root, 0);
1485
+ for (const id of ids) visit(id, 0);
1486
+
1487
+ return entries;
1488
+ }
1489
+ /**
1490
+ * 把活动条目压成母会话轨道运行(R-01-003/AC-04):每个拥有可见直属子代理的
1491
+ * 母会话一条,记录全部可见直属子代理 id(有序,末位即末级)与子级深度,供
1492
+ * 渲染层测量后绘制整条连续轨道与接入横线。条目按 preorder 排列,同一直属
1493
+ * 子代理组天然连续。直属性按「条目深度 = 母会话条目深度 + 1」判定(与条目
1494
+ * 顺序无关);无 id、无母会话条目或非直属的条目一律跳过。
1495
+ */
1496
+ export function trackRuns(entries) {
1497
+ const list = Array.isArray(entries) ? entries : [];
1498
+ const depthById = new Map();
1499
+ for (const entry of list) {
1500
+ if (entry?.id != null) depthById.set(String(entry.id), entry.depth ?? 0);
1501
+ }
1502
+ const runs = new Map();
1503
+ for (const entry of list) {
1504
+ if (entry?.id == null || entry?.parentId == null || (entry.depth ?? 0) < 1) continue;
1505
+ if (entry.kind !== "subagent") continue;
1506
+ const pid = String(entry.parentId);
1507
+ const parentDepth = depthById.get(pid);
1508
+ if (parentDepth === undefined || entry.depth !== parentDepth + 1) continue;
1509
+ const run = runs.get(pid);
1510
+ if (run === undefined) runs.set(pid, { parentId: pid, depth: entry.depth, childIds: [entry.id] });
1511
+ else run.childIds.push(entry.id);
1512
+ }
1513
+ return [...runs.values()];
1514
+ }
1515
+
1516
+ /**
1517
+ * 由测量矩形求一条轨道的全部绘制盒:竖轨(母会话卡片底缘 → 末级子卡中心,
1518
+ * 含收口行)+ 每个子卡一条接入横线(竖轨右缘 → 子卡左缘)。所有坐标取整到
1519
+ * CSS 像素:卡片高度是流式小数,任何一条按小数坐标定位的 1px 线段都会被
1520
+ * 抗锯齿随机摊薄(粗细不一、端点方头错位);统一取整后全部线段同相位,
1521
+ * 粗细一致且端点天然相接(T-033 东家验收发现)。rectOf(id) 返回浮点
1522
+ * { top, height, left } 或 null;读数缺失或高度非正(折叠/隐藏态)返回 null。
1523
+ */
1524
+ export function trackBoxes(run, rectOf, indentPx) {
1525
+ const parent = rectOf(run?.parentId);
1526
+ if (parent == null) return null;
1527
+ const childIds = Array.isArray(run?.childIds) ? run.childIds : [];
1528
+ if (childIds.length === 0) return null;
1529
+ const childRects = [];
1530
+ for (const id of childIds) {
1531
+ const rect = rectOf(id);
1532
+ if (rect == null) return null;
1533
+ childRects.push(rect);
1534
+ }
1535
+ const top = Math.round(parent.top + parent.height);
1536
+ const left = Math.round(parent.left + indentPx / 2 + 1);
1537
+ const lastRect = childRects[childRects.length - 1];
1538
+ const bottom = Math.round(lastRect.top + lastRect.height / 2);
1539
+ if (!(bottom > top)) return null;
1540
+ // 竖轨延伸进收口横线所在行(+1),拐角像素由竖轨绘制,横线从其右缘起笔,互不重叠。
1541
+ const track = { top, left, height: bottom - top + 1 };
1542
+ const stubs = childRects.map((rect) => ({
1543
+ top: Math.round(rect.top + rect.height / 2),
1544
+ left: left + 1,
1545
+ width: Math.round(rect.left) - (left + 1),
1546
+ }));
1547
+ return { track, stubs };
1548
+ }
1549
+ /**
1550
+ * 渲染去重签名:两份条目序列若产出字节一致的可见状态则签名相等,
1551
+ * 因此渲染可跳过全部 DOM 写入,打破 渲染→写 DOM→再次触发渲染 的循环。
1552
+ */
1553
+ export function cardSignature(entries) {
1554
+ return JSON.stringify(
1555
+ entries.map((entry) => [
1556
+ entry.id,
1557
+ entry.parentId ?? null,
1558
+ entry.depth,
1559
+ entry.kind,
1560
+ entry.title,
1561
+ entry.workspaceTitle,
1562
+ entry.workspaceKey ?? "",
1563
+ entry.model ?? "",
1564
+ entry.reasoning ?? "",
1565
+ entry.timeline ?? null,
1566
+ entry.userPreview ?? "",
1567
+ entry.agentPreview ?? "",
1568
+ entry.isCurrent,
1569
+ entry.pendingText ?? null,
1570
+ entry.waitClass ?? null,
1571
+ entry.noteText ?? null,
1572
+ entry.questionPreview ?? null,
1573
+ entry.activityAt ?? null,
1574
+ entry.progress ?? null,
1575
+ entry.loadingModel ?? null,
1576
+ entry.loadingTimeline ?? null,
1577
+ entry.loadingPreviews ?? null,
1578
+ entry.tokenStats ?? [entry.outputTokens ?? null, entry.inputTokens ?? null, entry.cacheHitPct ?? null, entry.rateTokS ?? null, entry.elapsedMs ?? null],
1579
+ ]),
1580
+ );
1581
+ }
1582
+
1583
+ // ---- 列表加载态(R-01-014)、最近历史区(R-01-010)与运行统计(R-01-009) ----
1584
+
1585
+ /** 会话列表加载态:快照缺失或 `phase === "pending"` → "loading"(列表在途,禁止空态冒充);
1586
+ * `state === "error"` 或携带 `error` → "error"(前向兼容带错误轴的快照形态);
1587
+ * 否则 → "ready"(宿主契约:empty-with-ready 才是真的无会话)。 */
1588
+ export function listLoadState(snapshot) {
1589
+ if (!snapshot || snapshot.phase === "pending") return "loading";
1590
+ if (snapshot.state === "error" || snapshot.error != null) return "error";
1591
+ return "ready";
1592
+ }
1593
+
1594
+ /** 历史窗口:会话最后一次活动距现在不超过该毫秒数则视为"最近使用过"。 */
1595
+ export const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
1596
+ /** 历史区最多展示的最近会话条数。 */
1597
+ export const HISTORY_MAX = 20;
1598
+
1599
+ /** 会话行是否为某主会话的直属子代理。 */
1600
+ export function isSubagentRow(row, byId = {}) {
1601
+ const id = row?.parentId;
1602
+ return id !== undefined && id !== null && isRecord(byId[id]);
1603
+ }
1604
+
1605
+ /** 会话行是否满足自身状态的活动判定,不含后代活动继承。
1606
+ * 完成提醒(未确认完成)的显示与否不在此判定:由 buildEntries 经 completionReminder
1607
+ * 单点派生(C-030 唯一口径,不消费宿主 completed 边沿标志)。 */
1608
+ function isOwnActiveRow(row, byId = {}) {
1609
+ if (!isRecord(row)) return false;
1610
+ const running = row.running === true;
1611
+ const pending = row.pendingInteraction !== undefined;
1612
+ if (isSubagentRow(row, byId)) return running || pending;
1613
+ return running || pending;
1614
+ }
1615
+
1616
+ /** 沿自身活动会话的有效 parentId 链上溯收集会话 id:includeSelf 含活动会话自身,
1617
+ * 否则只收祖先(存在活动后代的母会话)。活动区与历史区显示判定的单点实现。 */
1618
+ function lineageActiveIds(byId, includeSelf) {
1619
+ const ids = new Set();
1620
+ for (const [id, row] of Object.entries(byId)) {
1621
+ if (!isOwnActiveRow(row, byId)) continue;
1622
+ const seen = new Set();
1623
+ let currentId = includeSelf ? id : row?.parentId;
1624
+ while (currentId !== undefined && currentId !== null && isRecord(byId[currentId]) && !seen.has(String(currentId))) {
1625
+ seen.add(String(currentId));
1626
+ ids.add(String(currentId));
1627
+ currentId = byId[currentId]?.parentId;
1628
+ }
1629
+ }
1630
+ return ids;
1631
+ }
1632
+
1633
+ /** 沿活动会话的有效 parentId 链补齐活动祖先(含活动会话自身),供历史区显示判定。 */
1634
+ export function activeSessionIds(byId = {}) {
1635
+ return lineageActiveIds(byId, true);
1636
+ }
1637
+
1638
+ /** 「存在活动后代」的母会话集合(不含活动会话自身),供 buildEntries 判定委托周期(R-01-003/AC-05)。 */
1639
+ function descendantActiveIds(byId = {}) {
1640
+ return lineageActiveIds(byId, false);
1641
+ }
1642
+
1643
+ /** 判断活动条目是否需要建立轮内状态订阅:以宿主 running 为准、与呈现 kind 解耦——
1644
+ * 委托周期中保持运行呈现的母会话不建立订阅(R-02-004/AC-01)。 */
1645
+ export function shouldSubscribeToSession(entry, byId = {}) {
1646
+ return entry?.id != null && byId?.[entry.id]?.running === true;
1647
+ }
1648
+
1649
+ /** 会话行是否满足活动区显示判定(自身活动或存在活动后代)。 */
1650
+ export function isActiveRow(row, byId = {}, activeIds = null) {
1651
+ if (isOwnActiveRow(row, byId)) return true;
1652
+ return activeIds instanceof Set && row?.id != null && activeIds.has(String(row.id));
1653
+ }
1654
+
1655
+ /** 取某会话的完成确认记账(R-01-002/AC-03):completions 为 Map id → { lastTurnEnd, ackedAt },
1656
+ * 恒为普通对象或 null;不抛错、不信任入参形状。 */
1657
+ function completionFor(id, completions) {
1658
+ if (!(completions instanceof Map)) return null;
1659
+ return isRecord(completions.get(String(id))) ? completions.get(String(id)) : null;
1660
+ }
1661
+
1662
+ /**
1663
+ * 完成确认判定(R-01-002/AC-03、AC-05、R-01-010/AC-06):会话存在 `lastTurnEnd > ackedAt`
1664
+ * 的未确认完成时成立——解除仅经显式确认(ackedAt 前移)或新回合完成(lastTurnEnd 前移,
1665
+ * 旧提醒被新回合更替);打开会话、切换当前会话与页面刷新均不解除。仅主会话参与;
1666
+ * 成立与否只依赖宿主持久状态,不依赖客户端在线观测。running/阻塞等待/委托周期的
1667
+ * 呈现抑制由调用方(buildEntries 的 doneWait)处理。
1668
+ */
1669
+ export function completionReminder(row, completion, isSub = false) {
1670
+ if (isSub || !isRecord(row)) return false;
1671
+ const record = isRecord(completion) ? completion : null;
1672
+ const lastTurnEnd = record === null ? null : Number(record.lastTurnEnd);
1673
+ const ackedAt = record === null ? null : Number(record.ackedAt);
1674
+ if (!Number.isFinite(lastTurnEnd)) return false; // 无 turn/end 登记即无提醒(升级不回溯补发)
1675
+ if (!Number.isFinite(ackedAt)) return true;
1676
+ return lastTurnEnd > ackedAt;
1677
+ }
1678
+
1679
+ /**
1680
+ * 错误提醒判定(R-01-002/AC-13,C-043):主会话最近一个回合以不可恢复错误结束
1681
+ * (宿主登记的 `lastTurnEndKind === 'error'`)时成立——随新回合结束(kind 被新 reason
1682
+ * 覆盖)解除;不消费 ack 游标、无确认按钮;成立与否只依赖宿主持久状态,刷新/重连恢复。
1683
+ * running/阻塞等待/委托周期的呈现抑制由调用方(buildEntries 的 waitClass 判定)处理。
1684
+ */
1685
+ export function errorReminder(row, completion, isSub = false) {
1686
+ if (isSub || !isRecord(row)) return false;
1687
+ const record = isRecord(completion) ? completion : null;
1688
+ return record !== null && record.lastTurnEndKind === "error";
1689
+ }
1690
+
1691
+ /** 错误提醒正文(R-01-002/AC-09、AC-13,C-043):宿主登记的 lastTurnEndError(截断后
1692
+ * 的错误信息)或回落固定文案;不冒充具体错误。调用方(buildEntries 的 errWait 判定)
1693
+ * 已保证错误提醒成立,本函数只做文本取舍。 */
1694
+ function entryErrorNote(completion) {
1695
+ const record = isRecord(completion) ? completion : null;
1696
+ const message = record?.lastTurnEndError;
1697
+ return typeof message === "string" && message !== "" ? message : ERROR_NOTE_FALLBACK;
1698
+ }
1699
+
1700
+ /**
1701
+ * 活动区→历史区迁移检测(R-01-010/AC-07):上一帧活动区 id 在本帧离开活动区且出现于
1702
+ * 历史区即判定为一次迁移;彻底消失(归档、滑出历史窗口)不判定。prevActiveIds 为上一帧
1703
+ * 已渲染的活动区 id 集合,active/recent 为本帧派生条目。
1704
+ */
1705
+ export function movedToRecentIds(prevActiveIds, active, recent) {
1706
+ if (!(prevActiveIds instanceof Set)) return [];
1707
+ const activeIds = new Set((Array.isArray(active) ? active : []).map((entry) => String(entry?.id)));
1708
+ const recentIds = new Set((Array.isArray(recent) ? recent : []).map((entry) => String(entry?.id)));
1709
+ const moved = [];
1710
+ for (const id of prevActiveIds) {
1711
+ if (!activeIds.has(id) && recentIds.has(id)) moved.push(id);
1712
+ }
1713
+ return moved;
1714
+ }
1715
+
1716
+ /**
1717
+ * 历史区→活动区迁移检测(R-01-010/AC-07):上一帧历史区 id 在本帧离开历史区且出现于
1718
+ * 活动区即判定为一次反向迁移;彻底消失(归档、滑出历史窗口)不判定。prevRecentIds 为上一帧
1719
+ * 已渲染的历史区 id 集合,active/recent 为本帧派生条目。与 movedToRecentIds 镜像对称。
1720
+ */
1721
+ export function movedToActiveIds(prevRecentIds, active, recent) {
1722
+ if (!(prevRecentIds instanceof Set)) return [];
1723
+ const activeIds = new Set((Array.isArray(active) ? active : []).map((entry) => String(entry?.id)));
1724
+ const recentIds = new Set((Array.isArray(recent) ? recent : []).map((entry) => String(entry?.id)));
1725
+ const moved = [];
1726
+ for (const id of prevRecentIds) {
1727
+ if (!recentIds.has(id) && activeIds.has(id)) moved.push(id);
1728
+ }
1729
+ return moved;
1730
+ }
1731
+
1732
+ /** 从 history 事件提取最后回合结束时刻(最后一条 `turn/end` 的有效 time):
1733
+ * 尾部反向扫描,`time` 非有限值的 `turn/end` 跳过继续向前;全部无有效时刻返回 null(R-01-010/AC-08)。 */
1734
+ export function lastTurnEndFromEvents(events) {
1735
+ const list = Array.isArray(events) ? events : [];
1736
+ for (let i = list.length - 1; i >= 0; i -= 1) {
1737
+ const event = list[i]?.event;
1738
+ if (event?.type !== "turn/end") continue;
1739
+ const time = Number(event.time);
1740
+ if (Number.isFinite(time)) return time;
1741
+ }
1742
+ return null;
1743
+ }
1744
+
1745
+ /** 从 ConversationSnapshot.turnTimings 提取最大 endTime;全部回合未结束或无回合返回 null。 */
1746
+ export function lastTurnEndFromTimings(turnTimings) {
1747
+ if (!(turnTimings instanceof Map)) return null;
1748
+ let last = null;
1749
+ for (const timing of turnTimings.values()) {
1750
+ const end = Number(timing?.endTime);
1751
+ if (Number.isFinite(end) && (last === null || end > last)) last = end;
1752
+ }
1753
+ return last;
1754
+ }
1755
+
1756
+ /**
1757
+ * 构建最近历史区条目:当前非活动、且在历史窗口内最后一次活动过的**主会话**
1758
+ * (子代理是临时工作单元,不入最近历史;故需同时排除表白会话与已结束子代理),
1759
+ * 按最后活动时间从新到旧,最多 HISTORY_MAX 条。blank 会话不出现(从未用过);
1760
+ * 归档会话不出现——原生 runtime 会立即清空对归档会话的选中,列出它只会得到
1761
+ * 一张点了回落到新会话界面的死卡。完成确认中的会话留在活动区,不入历史区;
1762
+ * 委托周期(含耗尽空窗)中的会话同样留在活动区(delegatingIds,分区不变量)。
1763
+ * 窗口候选判定用宿主列表时间(下界);turnEnds(id → 已知回合结束时刻)驱动
1764
+ * 时间精化:条目 activityAt 取宿主列表时间与回合结束时刻的较新者(R-01-010/AC-08、AC-09)。
1765
+ */
1766
+ export function buildRecent(snapshot, workspaceItems, now, windowMs = HISTORY_WINDOW_MS, detailsById = {}, archivedIds = [], completions = null, delegatingIds = null, turnEnds = null) {
1767
+ const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1768
+ const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1769
+ const current = snapshot?.current ?? null;
1770
+ const items = Array.isArray(workspaceItems) ? workspaceItems : [];
1771
+ const activeIds = activeSessionIds(byId);
1772
+ const archived = archivedIds instanceof Set ? archivedIds : new Set(archivedIds ?? []);
1773
+ const entries = [];
1774
+
1775
+ for (const id of ids) {
1776
+ const row = byId[id];
1777
+ if (!isRecord(row)) continue;
1778
+ if (row.blank === true) continue;
1779
+ if (archived.has(id)) continue; // 归档会话不可选中,不入最近历史
1780
+ if (isSubagentRow(row, byId)) continue; // 子代理(含已结束)不入最近历史;也无完成提醒语义
1781
+ if (completionReminder(row, completionFor(id, completions), false)) continue; // 完成确认中,留在活动区
1782
+ if (errorReminder(row, completionFor(id, completions), false)) continue; // 错误提醒中,留在活动区
1783
+ if (delegatingIds instanceof Set && delegatingIds.has(String(id))) continue; // 委托周期中(含耗尽空窗),留在活动区
1784
+ if (isActiveRow(row, byId, activeIds)) continue;
1785
+ const updatedAt = Number(row.updatedAt);
1786
+ if (!Number.isFinite(updatedAt)) continue;
1787
+ if (updatedAt > now || now - updatedAt > windowMs) continue;
1788
+ const turnEnd = Number(mapValue(turnEnds, id));
1789
+ const activityAt = Number.isFinite(turnEnd) ? Math.max(updatedAt, turnEnd) : updatedAt;
1790
+ const details = mapValue(detailsById, id) ?? {};
1791
+ const metadata = details.model ?? modelMetadata(details.models ?? row.models);
1792
+ // previews 不在此推导:渲染层按需 memo 计算(冷会话由 history 一次性写入 detail.previews)。
1793
+ const previews = details.previews ?? { userPreview: "", agentPreview: "" };
1794
+ const workspace = workspaceInfoForSession(id, items, byId);
1795
+ entries.push({
1796
+ id,
1797
+ kind: "recent",
1798
+ depth: 0,
1799
+ title: mainTitle(byId, id),
1800
+ workspaceTitle: workspace.title,
1801
+ workspaceKey: workspace.key,
1802
+ model: metadata.model ?? "",
1803
+ reasoning: metadata.reasoning ?? "",
1804
+ userPreview: previews.userPreview ?? "",
1805
+ agentPreview: previews.agentPreview ?? "",
1806
+ isCurrent: current !== null && String(current) === String(id),
1807
+ activityAt,
1808
+ });
1809
+ }
1810
+
1811
+ entries.sort((a, b) => b.activityAt - a.activityAt);
1812
+ return entries.slice(0, HISTORY_MAX);
1813
+ }
1814
+
1815
+ /** 毫秒时长的人性化短格式,例如 "47s"、"3m12s"。 */
1816
+ export function fmtElapsedMs(ms) {
1817
+ if (!Number.isFinite(ms) || ms < 0) return "";
1818
+ const s = Math.round(ms / 1000);
1819
+ if (s < 60) return `${s}s`;
1820
+ return `${Math.floor(s / 60)}m${s % 60}s`;
1821
+ }
1822
+
1823
+ /** token 计数的人性化短格式,例如 "847"、"1.2k";非有限非负时返回 null。 */
1824
+ /** token 计数紧凑缩写,镜像原生统计行 formatTokens:847 / 12.2K / 517K / 2.8M——
1825
+ * 千以下原样;K/M 档缩写值百位以上取整、不足百位保留一位小数;非法输入返回 null 不展示。 */
1826
+ export function fmtTokens(n) {
1827
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null;
1828
+ const scaled = (v) => (v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10));
1829
+ if (n < 1e3) return String(n);
1830
+ if (n < 1e6) return `${scaled(n / 1e3)}K`;
1831
+ return `${scaled(n / 1e6)}M`;
1832
+ }
1833
+
1834
+ /** 回合进度半衰期校准参数(R-01-009/AC-06,C-025、C-044):基准速率 90 tok/s 对应
1835
+ * 基准半衰期 120s;慢模型按比例拉长、快模型缩短,夹取于 [60, 600] 秒。 */
1836
+ export const PROGRESS_HALFLIFE_REF_RATE = 90;
1837
+ export const PROGRESS_HALFLIFE_BASE_S = 120;
1838
+ /** 无可用速率保守起步半衰期(C-044):东家实测供应商最低速率 20 tok/s 对应的
1839
+ * 120×90÷20=540s——新会话起步期进度慢爬,速率实测后由最新值无缝接续校准。 */
1840
+ export const PROGRESS_HALFLIFE_DEFAULT_S = 540;
1841
+ export const PROGRESS_HALFLIFE_MIN_S = 60;
1842
+ export const PROGRESS_HALFLIFE_MAX_S = 600;
1843
+
1844
+ /**
1845
+ * 回合进度半衰期速率校准(R-01-009/AC-06,C-025、C-044):任务产出 token 量与模型速度
1846
+ * 无关、回合墙钟时长与速率成反比,故 k = 120×90÷r 秒并夹取 [60, 600](r 为会话
1847
+ * 实测累计输出速率 tok/s);无可用速率(缺省/非法/非正)回退保守默认 540s
1848
+ * (20 tok/s 起步基准,C-044)。返回整数秒。
1849
+ */
1850
+ export function progressHalfLifeSec({ rateTokS = null } = {}) {
1851
+ if (!Number.isFinite(rateTokS) || rateTokS <= 0) return PROGRESS_HALFLIFE_DEFAULT_S;
1852
+ const k = Math.round((PROGRESS_HALFLIFE_BASE_S * PROGRESS_HALFLIFE_REF_RATE) / rateTokS);
1853
+ return Math.min(PROGRESS_HALFLIFE_MAX_S, Math.max(PROGRESS_HALFLIFE_MIN_S, k));
1854
+ }
1855
+
1856
+ /**
1857
+ * 回合进度估计(0–100):纯时间驱动,y = t/(t+k)(t 为本回合已耗秒数、k 为半衰期
1858
+ * 秒数)。过原点、先快后慢、渐近 100 永不到达;不区分 think/stream/tool 阶段,固定
1859
+ * k 下单调不减。半衰期按该会话最新实测输出速率现算(progressHalfLifeSec,C-044)——
1860
+ * 每次更新用最新累计平均速率重新校准,进度作为对完成度的实时估计允许随速率回落而
1861
+ * 回退(同回合单调承诺已撤销)。非法/缺失已耗时归一为 0;非法/缺失半衰期回退保守
1862
+ * 默认 540s。
1863
+ */
1864
+ export function progressOf({ elapsedMs = 0, halfLifeSec = null } = {}) {
1865
+ const sec =
1866
+ Math.max(
1867
+ 0,
1868
+ (Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs : 0) / 1000,
1869
+ );
1870
+ const k = Number.isFinite(halfLifeSec) && halfLifeSec > 0 ? halfLifeSec : PROGRESS_HALFLIFE_DEFAULT_S;
1871
+ return Math.round((100 * sec) / (sec + k) * 10) / 10;
1872
+ }
1873
+
1874
+ /** 委托耗尽归属宽限(R-01-009/AC-06):后代全部结束后、在该毫秒数内开始的新回合
1875
+ * 视为处理后代结果的回合(委托周期锚点连续);超时开始的新回合视为委托周期外的
1876
+ * 全新回合(归零重计)。 */
1877
+ export const SETTLE_TURN_GRACE_MS = 60_000;
1878
+
1879
+ /**
1880
+ * 委托周期进度锚点(R-01-009/AC-06):三态状态机。
1881
+ * - idle:无活动后代且无开放回合,锚点为空。
1882
+ * - turn:无活动后代、自身回合在飞;锚点 = 本回合起点,新回合开始即归零重计。
1883
+ * - delegating:委托周期——自首个活动后代出现起,至后代全部结束且处理其结果的
1884
+ * 回合完成止;锚点在周期内连续,不随自身回合结束或新回合开始而归零;进入周期
1885
+ * 时取最近已知回合起点,无已知起点时以进入周期时刻(now)为起点。后代耗尽且
1886
+ * 无开放回合时记 drainedAt;耗尽后 SETTLE_TURN_GRACE_MS 内开始的新回合归属本
1887
+ * 周期(锚点连续),超时开始的新回合归零重计并退出周期。
1888
+ * 本状态机只管锚点记账、不承载半衰期(C-044):进度 k 由渲染层每帧按最新实测输出
1889
+ * 速率现算(progressHalfLifeSec),不随锚点捕获冻结、允许进度随速率回落而回退。
1890
+ * prev 为上一帧状态(null 视同 idle);返回新状态对象,渲染层按会话 id 记账。
1891
+ */
1892
+ export function progressAnchor(prev, { descendantActive = false, hostStartTime = null, now = null } = {}) {
1893
+ const hs = Number.isFinite(hostStartTime) ? hostStartTime : null;
1894
+ const da = descendantActive === true;
1895
+ const mode = prev?.mode === "turn" || prev?.mode === "delegating" ? prev.mode : "idle";
1896
+ if (da) {
1897
+ if (mode === "delegating") {
1898
+ const turnStart = hs !== null && hs !== prev.turnStart ? hs : prev.turnStart;
1899
+ if (turnStart === prev.turnStart && prev.drainedAt == null) return prev;
1900
+ return { mode: "delegating", anchor: prev.anchor, turnStart, drainedAt: null };
1901
+ }
1902
+ const anchor = hs ?? (mode === "turn" ? prev.anchor : Number.isFinite(now) ? now : 0);
1903
+ return {
1904
+ mode: "delegating",
1905
+ anchor,
1906
+ turnStart: hs ?? (mode === "turn" ? prev.turnStart : null),
1907
+ drainedAt: null,
1908
+ };
1909
+ }
1910
+ if (hs === null) {
1911
+ if (mode !== "delegating") return { mode: "idle", anchor: null, turnStart: null, drainedAt: null };
1912
+ if (prev.drainedAt != null) return prev;
1913
+ return { ...prev, drainedAt: Number.isFinite(now) ? now : 0 };
1914
+ }
1915
+ if (mode === "delegating") {
1916
+ if (hs === prev.turnStart) return { mode: "turn", anchor: prev.anchor, turnStart: hs, drainedAt: null };
1917
+ const withinGrace = prev.drainedAt != null && hs - prev.drainedAt <= SETTLE_TURN_GRACE_MS;
1918
+ return withinGrace
1919
+ ? { mode: "turn", anchor: prev.anchor, turnStart: hs, drainedAt: null }
1920
+ : { mode: "turn", anchor: hs, turnStart: hs, drainedAt: null };
1921
+ }
1922
+ if (mode === "turn" && hs === prev.turnStart) return prev;
1923
+ return { mode: "turn", anchor: hs, turnStart: hs, drainedAt: null };
1924
+ }
1925
+
1926
+ /** 会话是否处于委托周期(含后代耗尽空窗):delegating 态且未耗尽,或耗尽时刻距
1927
+ * now 不超过 SETTLE_TURN_GRACE_MS(空窗内等待 settle 处理回合启动)。供渲染层
1928
+ * 派生 delegatingIds 注入 buildEntries/buildRecent(R-01-003/AC-05、R-01-009/AC-06)。 */
1929
+ export function delegationActive(state, now) {
1930
+ if (state?.mode !== "delegating") return false;
1931
+ if (state.drainedAt == null) return true;
1932
+ return Number.isFinite(now) && now - state.drainedAt <= SETTLE_TURN_GRACE_MS;
1933
+ }