magic-agent 1.0.2 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +260 -8
  55. package/internal/agent/custom_models_test.go +204 -7
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +78 -2
  66. package/internal/agent/env_test.go +37 -0
  67. package/internal/agent/itemtracker.go +271 -0
  68. package/internal/agent/launch.go +192 -0
  69. package/internal/agent/launch_test.go +206 -0
  70. package/internal/agent/launch_unix.go +15 -0
  71. package/internal/agent/launch_windows.go +24 -0
  72. package/internal/agent/limitnotice.go +103 -0
  73. package/internal/agent/limitnotice_test.go +211 -0
  74. package/internal/agent/llmengine.go +2 -2
  75. package/internal/agent/models.go +3 -2
  76. package/internal/agent/openclaw.go +4 -3
  77. package/internal/agent/openclaw_acp.go +7 -0
  78. package/internal/agent/openclaw_test.go +2 -2
  79. package/internal/agent/permission.go +100 -11
  80. package/internal/agent/permission_test.go +100 -36
  81. package/internal/agent/repair_models_test.go +194 -0
  82. package/internal/agent/runcmd.go +13 -3
  83. package/internal/agent/runner.go +7 -1
  84. package/internal/agent/runner_test.go +1 -1
  85. package/internal/agent/stream.go +777 -43
  86. package/internal/agent/stream_test.go +3 -2
  87. package/internal/agent/stream_timeline_test.go +905 -0
  88. package/internal/agent/streamjson.go +36 -2
  89. package/internal/agent/toolkind.go +20 -1
  90. package/internal/agent/toolkind_test.go +7 -1
  91. package/internal/agent/tools_suffix_test.go +10 -3
  92. package/internal/agent/trae.go +5 -3
  93. package/internal/agent/workspace_test.go +52 -0
  94. package/internal/cli/ask.go +282 -51
  95. package/internal/cli/ask_test.go +15 -11
  96. package/internal/cli/attach_test.go +32 -20
  97. package/internal/cli/clientcmd.go +156 -0
  98. package/internal/cli/events.go +53 -8
  99. package/internal/cli/events_test.go +230 -0
  100. package/internal/cli/keepalive_test.go +101 -6
  101. package/internal/cli/login.go +61 -4
  102. package/internal/cli/login_test.go +99 -0
  103. package/internal/cli/permission_test.go +5 -1
  104. package/internal/cli/repair.go +60 -0
  105. package/internal/cli/repair_test.go +51 -0
  106. package/internal/cli/root.go +195 -32
  107. package/internal/cli/session.go +183 -29
  108. package/internal/cli/session_test.go +144 -5
  109. package/internal/cli/sessionlog.go +385 -38
  110. package/internal/cli/sessionlog_test.go +708 -0
  111. package/internal/cli/sessionlog_user_test.go +142 -1
  112. package/internal/cli/sessiontitle.go +141 -0
  113. package/internal/client/browse.go +456 -0
  114. package/internal/client/browse_test.go +199 -0
  115. package/internal/client/cors_test.go +35 -0
  116. package/internal/client/daemon.go +525 -0
  117. package/internal/client/daemon_test.go +212 -0
  118. package/internal/client/detach_unix.go +19 -0
  119. package/internal/client/detach_windows.go +27 -0
  120. package/internal/client/pair.go +3 -3
  121. package/internal/client/plugins.go +376 -0
  122. package/internal/client/plugins_test.go +223 -0
  123. package/internal/client/proxy.go +374 -0
  124. package/internal/client/proxy_test.go +212 -0
  125. package/internal/client/service.go +1464 -48
  126. package/internal/client/service_test.go +649 -206
  127. package/internal/client/session_events_test.go +130 -0
  128. package/internal/client/wsproxy.go +113 -0
  129. package/internal/client/wsproxy_test.go +102 -0
  130. package/internal/session/delete.go +151 -0
  131. package/internal/session/delete_test.go +103 -0
  132. package/internal/session/session.go +155 -20
  133. package/internal/session/session_append.go +79 -21
  134. package/internal/session/session_test.go +124 -10
  135. package/internal/session/sessionlog.go +572 -6
  136. package/internal/session/sessionlog_read_test.go +410 -0
  137. package/internal/session/title.go +486 -0
  138. package/internal/session/title_test.go +298 -0
  139. package/npm/bin/magic-agent.js +78 -10
  140. package/npm/bin/magic-client.js +100 -0
  141. package/npm/dist/darwin-arm64/magic-agent +0 -0
  142. package/npm/dist/darwin-arm64/magic-client +0 -0
  143. package/npm/dist/darwin-x64/magic-agent +0 -0
  144. package/npm/dist/darwin-x64/magic-client +0 -0
  145. package/npm/dist/linux-arm64/magic-agent +0 -0
  146. package/npm/dist/linux-arm64/magic-client +0 -0
  147. package/npm/dist/linux-x64/magic-agent +0 -0
  148. package/npm/dist/linux-x64/magic-client +0 -0
  149. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  150. package/npm/dist/win32-x64/magic-client.exe +0 -0
  151. package/npm/install.js +36 -13
  152. package/npm/lib/platform.js +27 -5
  153. package/package.json +8 -3
  154. package/client-ui/pc/js/03-chat-message.js +0 -880
  155. package/client-ui/pc/js/06-ask-answer.js +0 -204
  156. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  157. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  158. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  159. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -0,0 +1,381 @@
1
+ /* ---------- 对话消息台账 + 历史找回(2026-09-22)----------
2
+ 用户三句话定的口径(**以最后一句为准**):
3
+ ① 「改成对话存在 sqllite 里 只存消息 不存显示样式」——
4
+ 持久层存的是**消息**(谁说的 + 正文 + 时间),不是渲染好的 HTML 快照;
5
+ 打开历史时按**当前** UI 重画(版式永远跟得上界面改动,也不会因为改样式而失效)。
6
+ ② 「不需要兜底 就是历史读 sqllite」——不要那一串「引擎会话文件 / JSON 消息文件」的兜底,
7
+ 历史就读**一张表**(`tasks.db` 的 `messages` 表,见 desktop/tasks-db.js 建表处)。
8
+ ③ 「按回话id还不行 是按任务id才行吧 一个任务可能有多个agent的会话」——
9
+ **按任务 id 归档**,不是按会话 id:一个任务可能跨多个引擎会话(模型链降级重试、
10
+ 干活轮与审查轮用不同引擎、返工重开一轮),`session_id` 只记得住最后一个。
11
+
12
+ 为什么非改不可(本次报障的根因):
13
+ 原来唯一的历史来源是 localStorage 里的 HTML 快照(`qaTasks[].snapshot`),
14
+ 受 `QA_TK_MAX = 20` 约束、且 `qaTaskPersist` 只落**最新 20 条**(其中大多数恰恰没有快照)
15
+ —— 重启后普通对话的历史就永久没了;而需求派发的会话有 `qaTaskRebuildSnap` 兜底、
16
+ 普通对话没有 → 点开一片空白(用户:「任务中的对话历史现在不显示了」)。
17
+
18
+ 打开一条会话时的三级(**没有第四级、没有兜底**):
19
+ ① 本会话内存里的画面(`t.snapshot`,同一次运行内切来切去 —— 最快,且保留工具卡 / 思考区 / 截图)
20
+ ② **SQLite 消息表**(`desk:conv-list`,按 task_id 读)→ 重画
21
+ · 需求派发的会话:历史由需求表(`runs[]` 的 prompt/output 文本)现拼 —— 那份也在 SQLite 里,
22
+ 且本来就是「消息不是样式」,所以没有另存一份消息(见 qaTaskRebuildSnap)
23
+ ③ 都没有 → 画一条**说明**(说清为什么看不到、还能怎么接着用),不再是「描述你要做的事」
24
+ 那种新任务空态(那会让用户以为历史被清空了)。
25
+
26
+ ⚠️ 写入是**追加**(不是覆盖):一条任务的历史是逐轮长出来的,每轮收尾把这一轮的消息 append 进表。
27
+ ⚠️ 台账(`qaMsgLog`)是内存缓存,只为「本轮渲染连贯 + 攒够一轮再落库」;文件/SQLite 才是持久层。 */
28
+
29
+ /** 一条任务的台账(惰性建):`msgs` = [{who,text,ts}],who ∈ mine|ai|review|engine
30
+ * `loaded` = 是否已经从 SQLite 读过一次(读一次就够,之后内存台账即权威)
31
+ * `pending` = **还没落库**的那几条(每轮收尾攒在这里,落库成功后清空)——
32
+ * 用「待写队列」而不是「整体覆盖」,与 SQLite 的追加语义对齐:一条任务可能被多个引擎会话
33
+ * 轮流写,覆盖写会把别的会话写进去的那几轮抹掉。 */
34
+ const qaMsgLogs = new Map();
35
+ function qaMsgLog(taskId){
36
+ const id = String(taskId || "");
37
+ if(!id) return null;
38
+ let e = qaMsgLogs.get(id);
39
+ if(!e){ e = { msgs: [], loaded: false, pending: [], loading: null }; qaMsgLogs.set(id, e); }
40
+ return e;
41
+ }
42
+ /** 记一条消息(**幂等**:与末尾同人同文不重复记;同一人的正文是**扩展**(流式增量)时改写末尾那条)。
43
+ * 这条规则是「收尾点可能被调用多次」与「中途切走只拿到半截正文」两种情况的共同兜底。 */
44
+ function qaMsgLogPush(taskId, who, text){
45
+ const e = qaMsgLog(taskId); if(!e) return false;
46
+ const w = (who === "mine" || who === "review" || who === "engine") ? who : "ai";
47
+ const s = String(text == null ? "" : text).trim();
48
+ if(!s) return false;
49
+ const last = e.msgs[e.msgs.length - 1] || null;
50
+ if(last && last.who === w && (last.text === s || s.startsWith(last.text))){
51
+ if(last.text !== s) last.text = s; // 增量长出来了 → 改写末尾那条(不新增)
52
+ return true;
53
+ }
54
+ e.msgs.push({ who: w, text: s, ts: Date.now() });
55
+ return true;
56
+ }
57
+ /** 把台账里**还没落库**的那几条追加进 SQLite(`desk:conv-append`)。
58
+ * 失败只记日志、**不清 pending**(下一轮收尾会连同这次一起再试),绝不打断对话。
59
+ * ⚠️ **需求派发的会话不写这张表**:它的历史在需求表里(逐轮的 prompt/output + 审核气泡),
60
+ * 而且**只有一份**(`qaTaskHistoryRecover` 对 reqId 优先走需求现拼)。要是在这里也追加,
61
+ * 表里就只会攒下「用户在这条会话里追问的那几句」—— 打开时按它重画,反倒把派发的整段历史盖掉。 */
62
+ function qaMsgLogFlush(taskId){
63
+ const e = qaMsgLog(taskId); if(!e || !e.pending.length) return;
64
+ const t = qaTasks.find(x=>x.id === taskId);
65
+ if(t && t.reqId){ e.pending.length = 0; return; } // 派发会话:需求表就是它的持久层
66
+ const api = (window.desk && typeof window.desk.convAppend === "function") ? window.desk.convAppend : null;
67
+ if(!api) return; // 浏览器预览态:没有桥,内存台账照常工作
68
+ const batch = e.pending.slice();
69
+ try{
70
+ api(String(taskId), batch).then((r)=>{
71
+ if(r && r.ok === false){ console.warn("[conv] 消息没落库:", r.error); return; }
72
+ /* 成功才把这一批从 pending 里摘掉(按引用摘,避免这期间又攒进新的几条) */
73
+ e.pending = e.pending.filter((m)=> batch.indexOf(m) < 0);
74
+ }).catch((err)=>{ console.warn("[conv] 消息没落库:", (err && err.message) || err); });
75
+ }catch(err){ console.warn("[conv] 消息没落库:", (err && err.message) || err); }
76
+ }
77
+ /** 记一条**过程步骤**(思考块 / 工具卡)进台账(2026-09-23)。
78
+ * · thinking:相邻流式段**拼接**成一块(与末尾条同为思考块就接上去),历史里不会碎成一地;
79
+ * 被正文隔开的两段思考各自成块(末尾条是正文时新开一条)—— 与实时画面同构。
80
+ * · tool:一条 = 一次完整调用(结果已由 onStep 按 tid 配好对);payload 存 name/args/result/ok,
81
+ * text 存一行可读摘要 —— payload 万一解析不了,历史里至少还剩这行字。 */
82
+ function qaMsgLogPushStep(taskId, st){
83
+ const e = qaMsgLog(taskId); if(!e || !st) return false;
84
+ if(st.type === "thinking"){
85
+ const s = String(st.text == null ? "" : st.text);
86
+ if(!s.trim()) return false;
87
+ const last = e.msgs[e.msgs.length - 1];
88
+ if(last && last.kind === "thinking"){
89
+ if(last.text === s) return true; // 同文重复(重复收尾的兜底)
90
+ last.text = last.text ? last.text + "\n\n" + s : s;
91
+ return true;
92
+ }
93
+ e.msgs.push({ who: "engine", kind: "thinking", text: s, ts: Date.now() });
94
+ return true;
95
+ }
96
+ if(st.type === "tool"){
97
+ const name = String(st.name || "工具").trim() || "工具";
98
+ const args = String(st.args || "").trim();
99
+ const result = String(st.result == null ? "" : st.result);
100
+ e.msgs.push({
101
+ who: "engine", kind: "tool",
102
+ text: name + (args ? " · " + args.slice(0, 120) : ""),
103
+ payload: { name, args: args.slice(0, 2000), result: result.slice(0, 8000), ok: st.ok !== false },
104
+ ts: Date.now(),
105
+ });
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+ /** 一轮收尾时把这一轮的消息并进台账并落库(收尾点统一走它,见 qaSaveSnapshotFor)。
111
+ * `userText` = 这一轮用户说的话(`run.userText`);`aiText` = 这一轮引擎的正文(`run.msgText`);
112
+ * `steps` = 这一轮的**过程步骤**(思考块/工具卡,`run.logSteps` 的增量,2026-09-23 起随轮落库)。
113
+ * 台账顺序 = 用户这句 → 过程块(想了什么/干了什么)→ 引擎正文,与真实时间线一致。 */
114
+ function qaConvTurnSync(taskId, userText, aiText, steps){
115
+ if(!taskId) return;
116
+ const e = qaMsgLog(taskId); if(!e) return;
117
+ const n0 = e.msgs.length;
118
+ const t0 = e.msgs.length ? e.msgs[e.msgs.length - 1].text : null;
119
+ qaMsgLogPush(taskId, "mine", userText); // 用户这句 = 轮的起点(隔断上一轮思考块的拼接)
120
+ (Array.isArray(steps) ? steps : []).forEach((st)=>{ qaMsgLogPushStep(taskId, st); });
121
+ qaMsgLogPush(taskId, "ai", aiText);
122
+ /* 新增 / 被改写的那几条进待写队列(改写的也重写一次:正文变长是流式增量,末尾那条要更新)。
123
+ 末尾条文本没变就不重推 —— 否则重复收尾会把同一条(尤其思考块)重复落库。
124
+ 步骤条目的增量由调用方保证(run.stepsSaved,见 qaSaveSnapshotFor),不依赖这里的幂等。 */
125
+ if(e.msgs.length > n0){
126
+ e.pending.push(...e.msgs.slice(n0));
127
+ }else if(e.msgs.length && (userText || aiText) && e.msgs[e.msgs.length - 1].text !== t0){
128
+ const last = e.msgs[e.msgs.length - 1];
129
+ if(!e.pending.includes(last)) e.pending.push(last);
130
+ }
131
+ qaMsgLogFlush(taskId);
132
+ }
133
+ /** 从 SQLite 读回台账(**只读一次**;之后内存台账即权威)。
134
+ * 读回的内容与本次已经记下的合并 —— 「打开历史晚了半拍,刚记的那条被库里的内容顶掉」那种事不发生。 */
135
+ function qaHistThinkingHtml(m){
136
+ const s = String((m && m.text) || "").trim();
137
+ if(!s) return "";
138
+ return '<details class="qa-hist-step qa-hist-step--think">'
139
+ + '<summary><span class="qa-hist-step-tag">思考</span>思考过程</summary>'
140
+ + '<div class="qa-hist-step-body">' + escHtml(s) + '</div></details>';
141
+ }
142
+ /** 历史里的**工具步骤**(2026-09-23):标题 = 工具名(+ 参数摘要小字),展开 = 参数 / 结果全文;
143
+ * 失败的调用标红。payload 缺失时退回 text 摘要那一行(老库 / 解析失败都不至于空白)。 */
144
+ function qaHistToolHtml(m){
145
+ const p = (m && m.payload) || {};
146
+ const ok = p.ok !== false;
147
+ const name = String(p.name || "").trim() || String((m && m.text) || "").trim() || "工具";
148
+ const args = String(p.args || "");
149
+ let body = "";
150
+ if(args) body += '<div class="qa-hist-step-k">参数</div><pre class="qa-hist-step-pre">' + escHtml(args) + '</pre>';
151
+ /* 结果可能极大(实测有单条 1.6MB 的工具输出):显示上限与实时渲染同一口径,
152
+ 否则一次历史回放要往 DOM 里塞几十 MB(见 09-stream-apply.js 的 qaToolOutForDisplay)。 */
153
+ if(p.result != null && String(p.result) !== "")
154
+ body += '<div class="qa-hist-step-k">' + (ok ? "结果" : "错误") + '</div><pre class="qa-hist-step-pre">' + escHtml(qaToolOutForDisplay(String(p.result))) + '</pre>';
155
+ if(!body) body = '<div class="qa-hist-step-body">' + escHtml(String((m && m.text) || "")) + '</div>';
156
+ return '<details class="qa-hist-step qa-hist-step--tool' + (ok ? "" : " is-err") + '">'
157
+ + '<summary><span class="qa-hist-step-tag">' + (ok ? "工具" : "出错") + '</span>' + escHtml(name)
158
+ + (args ? '<span class="qa-hist-step-hint">' + escHtml(args.slice(0, 80)) + '</span>' : "")
159
+ + '</summary>' + body + '</details>';
160
+ }
161
+ /** 消息列表 → 画布 HTML(**复用对话气泡那条渲染管线**,与实时渲染逐项同形)——
162
+ * 这是「只存消息、不存样式」的兑现处:样式永远由当前 UI 现画。
163
+ * 2026-09-23 起按 kind 分流:思考块 / 工具步骤画折叠卡(默认收起),普通消息照旧走气泡。 */
164
+ function qaConvMsgsHtml(msgs){
165
+ const list = Array.isArray(msgs) ? msgs : [];
166
+ let html = "";
167
+ list.forEach((m)=>{
168
+ try{
169
+ /* forEach 回调里不能用 continue,用 return 提前结束本次迭代 */
170
+ if(m && m.kind === "thinking"){ html += qaHistThinkingHtml(m); return; }
171
+ if(m && m.kind === "tool"){ html += qaHistToolHtml(m); return; }
172
+ html += qaReqConvMsg(m.who, m.text);
173
+ }
174
+ catch(e){ /* 单条画不出来不影响其余 */ }
175
+ });
176
+ return html;
177
+ }
178
+ /** 画布顶部那条**说明**(不是消息、也不是新任务空态):说清「为什么看不到历史」。
179
+ * 带 `.qa-hist-note` 类 → 参与 `qaHasContent`(有它就算「这条对话里有东西」,
180
+ * 于是不会退回「描述你要做的事」那种新任务引导)。 */
181
+ function qaConvNoticeHtml(text, kind){
182
+ return `<div class="qa-hist-note${kind ? " " + kind : ""}">${escHtml(text)}</div>`;
183
+ }
184
+ /** 一条会话的历史 → 画布 HTML(没有历史时返回空串,调用方据此画那条「说明」)。
185
+ * ⚠️ 调用方负责「本会话内存画面」那一级(`t.snapshot`,最便宜,且保真度最高)。
186
+ *
187
+ * 【2026-09-29 换过实现】首选改成 **magic-agent 的会话事件日志**(`magic-agent --session-log`,
188
+ * 经 `desk.agentSessionLog` 桥)—— 用户要的是「左侧边栏的对话对接 magic-agent 的查询命令」。
189
+ * 它给的是**归一化事件**(kind: user / thinking / text / tool_use / tool_result / ask /
190
+ * result / error / turn_end),与渲染层的事件**同名词表** → 回放出来就是「这一轮当时的样子」
191
+ * (工具卡 / 思考区 / 提问卡都在),且覆盖**本机所有入口**起的会话,不限于这本工作台发的。
192
+ * ⚠️ 磁盘上那份 `<session_id>.jsonl` 是内部格式,**不直接解析**:CLI 命令是唯一接口。
193
+ *
194
+ * 兜底那一级留给「需求派发的会话」:它的逐轮 prompt/审核气泡在**本工程自己的库**里,
195
+ * CLI 的事件日志里没有审核轮那部分(用户 2026-09-20 定稿的两表一致靠的就是它)。
196
+ * ⚠️ 画出来就是画出来:**不附任何「内容来自哪里」的声明**(用户 2026-09-22 点名不要那条)。 */
197
+ function qaMsgRowsToEvents(rows){
198
+ const out = [];
199
+ const at = (m)=>{ const t = Number((m && m.ts) || 0); return t ? new Date(t).toISOString() : ""; };
200
+ (Array.isArray(rows) ? rows : []).forEach((m, i)=>{
201
+ const who = String((m && m.who) || "ai");
202
+ const kind = String((m && m.kind) || "msg");
203
+ const text = String((m && m.text) || "");
204
+ if(who === "mine"){ out.push({ kind: "user", text, at: at(m) }); return; }
205
+ if(kind === "thinking"){ if(text.trim()) out.push({ kind: "thinking", text, at: at(m) }); return; }
206
+ if(kind === "tool"){
207
+ const p = (m && m.payload) || {};
208
+ const tid = "mt-" + i; // 造一个 id:tool_use ↔ tool_result 配对用
209
+ out.push({ kind: "tool_use", id: tid, tid, tool_use_id: tid,
210
+ name: String(p.name || "tool"), text: String(p.args || ""), at: at(m) });
211
+ out.push({ kind: "tool_result", id: tid, tid, tool_use_id: tid,
212
+ text: String(p.result == null ? "" : p.result), ok: p.ok !== false, at: at(m) });
213
+ return;
214
+ }
215
+ if(text.trim()) out.push({ kind: "text", text, at: at(m) });
216
+ });
217
+ return out;
218
+ }
219
+
220
+ /** 把 magic-agent 的会话事件回放进**当前画布**(#qa-stream)。
221
+ * 与实时同一条渲染管线(qaStreamApply):kind 与渲染层事件同名词表,逐条喂即可;
222
+ * `user` 那条不是渲染层事件(它是 core 补记的用户提问),走 qaRenderMine 画成用户气泡。
223
+ * ⚠️ 回放是**只读**的:台账 / 会话绑定 / 收尾动作一律不落笔(历史不该改写现在)。
224
+ *
225
+ * ⚠️ **一轮一条消息**(2026-10-12 用户:「历史的应该合并成一行大组显示」):
226
+ * 原来整段历史共用一个渲染句柄 `h` → 只有**第一条** `turn_end` 会触发 `wrapTurn`,
227
+ * 把那一轮的历史过程并成「任务耗时 X · N 项过程」一枚组;后面每一轮的思考块 / 工具组
228
+ * 全都散在组外面(实测那条会话:只有 1 枚组 + 166 块思考散落 —— 用户截图里的样子)。
229
+ * 现在按 `user` 事件切轮(它是轮与轮之间天然的分界):上一轮在下一个 `user` 到来时封口,
230
+ * 下面的事件另起一条消息 → **每一轮都得到自己那枚大组**,与实时跑出来的那一轮同形。 */
231
+ function qaSessionPlayer(){
232
+ /* 一轮的终点:这几个 kind 之后这一轮就不再长了(渲染层同一份词表,见 09-ask-event) */
233
+ const TURN_END = { turn_end:1, result:1, error:1, turn_failed:1, interrupted:1, stopped:1 };
234
+ /* 日志里的事件自带 `at`(ISO 墙钟,如 2026-10-12T08:09:40.761187Z)→ 真实时间戳(取不到回 0) */
235
+ const atMs = (ev)=>{ const t = Date.parse(String((ev && ev.at) || "")); return Number.isFinite(t) ? t : 0; };
236
+ let h = null;
237
+ let t0 = 0; // 这一轮的第一条事件时间(给「任务耗时」算真实间隔)
238
+ /* ⚠️ `tools` / `agents` 两张表要**跨事件**活着(2026-10-12):它们是 tool_use ↔ tool_result
239
+ 的配对凭据(见 09-ask-event 的 `ctx.tools.get(tid)`)。原来每条事件都新建一个空 Map
240
+ → 244 条 tool_result 一条都配不上 → 全落成「未配对结果 · call_xxx」标记行,
241
+ 历史里于是多出两百多行噪音(用户截图里那一片)。实时那轮是同一次 run 共用一个 ctx
242
+ (bindHomeAsk),这里照它来:整段回放共用一份。 */
243
+ const tools = new Map(), agents = new Map();
244
+ /* 「本轮产物」(2026-10-20 方案 A):回放把 ctx.onStep 桩掉了 → 实时那条链的产物收集不会发生,
245
+ 这里直接吃事件、按轮收,封口时补挂到该轮消息末尾(与实时那张同形)。 */
246
+ const arts = (typeof qaTurnArtsTrack === "function") ? qaTurnArtsTrack() : null;
247
+ return {
248
+ /** 喂一批事件(可反复调用,h / tools / agents / t0 跨批存活)。 */
249
+ feed(events){ qaSessionPlayerFeed(Array.isArray(events) ? events : []); },
250
+ /** 等已排队的「本轮产物」卡画完(探针是异步的;要取 innerHTML 存 __convReplay 时才需要)。 */
251
+ arts(){ return arts ? arts.settled() : Promise.resolve([]); },
252
+ /** 封口:日志尾部没有终态事件时,最后一条也收干净。
253
+ * ⚠️ 必须走 `qaRunEnd`(= finish + 摘「生成中」胶囊),不能只 `h.finish()`:
254
+ * 回放一次就新建一条 `.qa-msg` 和一枚 `.qa-run` 胶囊行,而日志里**没有终态事件**时
255
+ * (会话还在跑 / 一页只切到一半)胶囊永远不会被摘 —— 每翻一页历史就多一枚
256
+ * 「xxx · 生成中」,用户看到的正是「一轮变多次了」(2026-10-20 报障)。 */
257
+ finish(){
258
+ if(!h) return;
259
+ if(arts) arts.flush(h); // 最后一轮没有终态事件也要补挂产物(在 finish 拆卡之前)
260
+ try{
261
+ if(typeof qaRunEnd === "function") qaRunEnd(h);
262
+ else h.finish();
263
+ }catch(e){ /* 收尾失败不影响回放 */ }
264
+ h = null;
265
+ },
266
+ };
267
+ /* 拆成内层函数:feed 里原来的 `return`(qaRenderAnswer 失败)只退出这一批,语义与改造前一致 */
268
+ function qaSessionPlayerFeed(list){
269
+ for(let i = 0; i < list.length; i++){
270
+ let ev = list[i];
271
+ const kind = String((ev && ev.kind) || "");
272
+ if(!kind) continue;
273
+ /* ⚠️ 相邻的思考增量先**并成一段**再喂(2026-10-20 用户:「读取有点太慢了」)。
274
+ 事件日志是按流式增量落盘的:实测一条长会话里 95838 条事件有 94015 条是
275
+ thinking 分片,逐条走 qaStreamApply 要 20s、还把主线程整段占死。
276
+ 这里只做相邻合并(中间夹了别的 kind 就断开,与 `qaMsgLogPushStep` 的拼接口径一致),
277
+ 画出来的内容与逐条喂**完全相同**,只是省掉了几万次 DOM 追加。 */
278
+ if(kind === "thinking"){
279
+ let j = i + 1;
280
+ if(j < list.length && String((list[j] && list[j].kind) || "") === "thinking"){
281
+ /* 用**数组攒起来再 join**,不要 `text +=` 逐片拼 —— 九万多片做 += 是二次复杂度
282
+ (实测反而比不合并还慢:20s → 30s)。 */
283
+ const parts = [String(ev.text == null ? "" : ev.text)];
284
+ while(j < list.length && String((list[j] && list[j].kind) || "") === "thinking"){
285
+ parts.push(String(list[j].text == null ? "" : list[j].text));
286
+ j++;
287
+ }
288
+ ev = Object.assign({}, ev, { text: parts.join("") });
289
+ i = j - 1;
290
+ }
291
+ }
292
+ if(kind === "user"){
293
+ /* 轮到轮的分界:上一轮封口(补齐没走终态那条路的轮),下一轮另起一条消息。
294
+ ⚠️ 同样要走 `qaRunEnd`:用户气泡下面那枚「生成中」胶囊属于上一轮,不摘就留在画布上
295
+ (回放历史时每轮都留一枚 → 「一轮变多次」,见 finish 的注释)。 */
296
+ if(h){
297
+ if(arts) arts.flush(h); // 上一轮的产物挂在上一轮卡上(封口前 el 还在树上)
298
+ try{
299
+ if(typeof qaRunEnd === "function") qaRunEnd(h);
300
+ else h.finish();
301
+ }catch(e){ /* 收尾失败不影响回放 */ }
302
+ h = null;
303
+ }else if(arts){ arts.flush(null); } // 没有答复卡的一轮:清掉残留,别串到下一轮
304
+ qaRenderMine(String(ev.text || ""), []);
305
+ t0 = atMs(ev);
306
+ continue;
307
+ }
308
+ if(!h){ h = qaRenderAnswer(); if(!h) return; if(!t0) t0 = atMs(ev); }
309
+ /* 产物照收(写类工具参数 / 工具输出 / notice)—— 与实时那条 ctx.onStep 上的口径一致 */
310
+ if(arts) arts.note(ev);
311
+ /* ⚠️ 回放标记(2026-10-12):这一轮不是刚跑出来的,`finish()` 里那个
312
+ 「任务耗时」分组头**不能按墙上时钟算**(回放发生在几毫秒内,会写成「0.0s」)——
313
+ 打了这个标就改用它自己的 `__qaMs`(见下一条:cli 的 turn_end 不带 latency_ms,
314
+ 但自带 `at`,用它算「本轮首条事件 → 收尾」的真实间隔;两者都拿不到就不写时间)。 */
315
+ if(h.el) h.el.__qaReplay = true;
316
+ /* ⚠️ 必须在喂事件**之前**写好 `__qaMs`:turn_end 那条在 qaStreamApply 里就会 finish → wrapTurn,
317
+ 那时它已经把组头文案定死了(喂完再改就来不及)。 */
318
+ let turnMs = 0;
319
+ if(TURN_END[kind] && h.el){
320
+ const t1 = atMs(ev);
321
+ if(t0 && t1 && t1 >= t0){ turnMs = t1 - t0; h.el.__qaMs = turnMs; }
322
+ }
323
+ try{
324
+ qaStreamApply(Object.assign({}, ev, { type: kind }), {
325
+ h, tools, agents, rid:0, taskId:qaActiveId,
326
+ setMs(ms){ if(h.el && ms != null) h.el.__qaMs = Number(ms) || 0; },
327
+ onText(){}, onResult(){}, onStep(){}, onSession(){}, onError(){}, onDone(){},
328
+ });
329
+ }catch(e){ console.warn("[rail] 回放事件失败 " + kind + ":", (e && e.message) || e); }
330
+ if(TURN_END[kind]){
331
+ /* 收尾行(2026-10-12):实时的收尾行是 `onDone` 里画的(见 09-ask-event),
332
+ 而回放把 onDone 桩掉了 → 少了这一行,回放出来的画面与**实时那张**就不一样
333
+ (没有「已完成 · 总耗时 X」),而且 `qaConvLogCatchUp` 是靠**数收尾行**对账的:
334
+ 画布上一条都没有时它认不出「这份画面是回放画的」。所以这里照着实时那条落笔。 */
335
+ const failed = (kind === "error" || kind === "turn_failed");
336
+ const stopped = (kind === "interrupted" || kind === "stopped");
337
+ qaRenderDone(!failed && !stopped,
338
+ failed ? "调用失败 · " + String(ev.reason || ev.error || "未知错误").slice(0, 200)
339
+ : stopped ? "已停止" : "已完成",
340
+ null, turnMs || null);
341
+ if(arts) arts.flush(h); // 终态之后这一轮的产物补在消息末尾(与实时 onDone 同位)
342
+ t0 = 0; // 这一轮收尾了:下一轮(若没有 user 事件)重新起算
343
+ }
344
+ }
345
+ }
346
+ }
347
+ /** 整段回放(同步):一次性喂完再封口;增量场景用 qaSessionPlayer(见上)。 */
348
+ function qaSessionReplay(events){
349
+ const p = qaSessionPlayer();
350
+ p.feed(events);
351
+ p.finish();
352
+ }
353
+ /** 「画布之外又长出来的轮次」接回来(2026-10-12)----------
354
+ * 用户报障原话:「有一个在跑 但是看不到过程」。
355
+ * 场景:这一轮的引擎进程是**在画布之外**跑的(重载前起的进程还活着、或别的入口起的会话)——
356
+ * 渲染层没有它的 run,后到的流式事件按 rid 找不到归属就被当过期事件丢掉(见 07 的 handle 分支),
357
+ * 画布上只剩一条「调用失败」;而引擎那边其实把整轮跑完了(思考 / 工具 / 正文都在会话日志里)。
358
+ * 用户要的第二条口径:「打开会话时,若会话日志里有『快照里没画过的轮次』,就把它回放补上。」
359
+ *
360
+ * 做法:**打开会话时对一次账** —— 数引擎日志里的轮次与画布上的收尾行(`.qa-done`):
361
+ * · 日志更多 → 画布上确实有从没画过的轮次 → 按日志**整份重画**(同一条渲染管线,qaSessionReplay),
362
+ * 结果记进 `t.__convReplay`(切走再切回来看到的仍是补好的这份,见 qaTaskPaint 的画布来源);
363
+ * · 一样多 → 什么都不做(画布那份更保真:附件缩略图 / 截图这类回放不出来)。
364
+ *
365
+ * ⚠️ 为什么**不是每次打开都去问引擎**:`desk.agentSessionLog` 背后是一次
366
+ * `magic-agent --session-log` 进程调用,每条正常会话打开都跑一次太贵。
367
+ * 只在画布与库里**明显对不上**时才对账(三个判据都在本地、零成本):
368
+ * ① 画布上最后一条收尾行是失败 —— 跑完的那一轮不会留失败行;
369
+ * ② 库里说这条在跑(`status === "running"`)而 UI 里没有在飞的 run —— 「有一个在跑」却看不到过程;
370
+ * ③ 画布上**一条收尾行都没有** —— 跑过至少一轮的画布必有「已完成 · 总耗时」那行,
371
+ * 没有就说明这份画面不是本版回放着画出来的(旧版回放不画收尾行,或快照早于收尾);
372
+ * 补画一次后画布上就有了,所以它不会反复触发(自限)。
373
+ * ⚠️ 落笔前两道闸(与 qaTaskHistoryRecover 那条同规矩):**用户还停在这条会话上**、
374
+ * **这条没有在飞的 run**(实时画布永远更新,不能被回放盖掉)。
375
+ * ⚠️ 只读:不改任务状态、不写回 `t.snapshot`(口径见本文件头:那是「真正渲染过的东西」的缓存);
376
+ * 补画的那一份是**另一个字段**(`__convReplay`,只在内存里,重启即弃)。 */
377
+ function qaConvLostText(t){
378
+ const hasSid = !!(t && t.sessionId);
379
+ return "这条会话没有可回放的历史(magic-agent 的会话日志里还没有它的事件)。"
380
+ + (hasSid ? "会话本身还在 —— 直接在这里追问,引擎会接着原来的上下文继续。" : "这一轮没有拿到会话 id,追问会新起一段上下文。");
381
+ }
@@ -47,6 +47,12 @@ const QA_V_ICONS = {
47
47
  sep: '<svg viewBox="0 0 24 24"><path d="M4 12h16M7.5 8.6 12 4.1l4.5 4.5M16.5 15.4 12 19.9l-4.5-4.5"/></svg>',
48
48
  chev: '<svg class="chev" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>',
49
49
  copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2.2"/><path d="M15 6.2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.2"/></svg>',
50
+ /* 重试箭头(消息操作行用,2026-10-09):缺口在右上、箭头回到起点 —— 与全站内联 SVG
51
+ 同一形制(24 视框 / currentColor / 描边)。用「回转箭头」而不是刷新双箭头:
52
+ 这一枚说的是「把这一轮再来一次」,不是「同步 / 刷新」。 */
53
+ retry: '<svg viewBox="0 0 24 24"><path d="M20.2 12a8.2 8.2 0 1 1-2.5-5.9"/><path d="M20.6 4.4v4.2h-4.2"/></svg>',
54
+ // TodoWrite / update_plan 清单 → ListChecks(三行勾选)
55
+ listcheck: '<svg viewBox="0 0 24 24"><path d="m3.5 5.5 1.6 1.6 2.5-2.8M3.5 12.1l1.6 1.6 2.5-2.8M3.5 18.7l1.6 1.6 2.5-2.8M11.5 6.2h9M11.5 12.8h9M11.5 19.4h9"/></svg>',
50
56
  ok: '<svg viewBox="0 0 24 24"><path d="M4 12.5l5 5L20 6.5"/></svg>',
51
57
  };
52
58
  /* 代码面板/明细段通用的复制按钮。**要复制的原文的落位是固定的**:
@@ -278,6 +284,7 @@ const QA_TOOL_RULES = [
278
284
  { kind: "read", re: /^(read|notebookread|notebook_read|view|cat)$/i },
279
285
  { kind: "search", re: /^(grep|glob|search|find|ls|list_dir|listdir)$/i },
280
286
  { kind: "command", re: /^(bash|shell|zsh|sh|exec|execute|run|terminal|command|cmd|powershell)$/i },
287
+ { kind: "todo", re: /^(todowrite|todo_write|todo__write|update_plan|plan_write|write_todos|writetodos)$/i },
281
288
  ];
282
289
  function qaToolKindOf(name, argsObj){
283
290
  const n = String(name || "").trim();
@@ -291,11 +298,14 @@ function qaToolKindOf(name, argsObj){
291
298
  if(o.query != null) return "web_search";
292
299
  if(o.url != null) return "web_fetch";
293
300
  if(o.pattern != null) return "search";
301
+ /* 待办清单(本仓库在服务端补的一档,internal/agent/toolkind.go 的 ToolKindTodo):
302
+ 名字表没见过的引擎仍由这里按参数兜底 —— 两侧词表一致,别只改一边。 */
303
+ if(Array.isArray(o.todos) || Array.isArray(o.plan)) return "todo";
294
304
  }
295
305
  return "tool";
296
306
  }
297
307
 
298
- /* 形态合并(2026-09-29):magic-agent 的归一化层会随 tool_use 事件给出 `tool_kind`
308
+ /* 形态合并(2026-09-29):magic-agent 的归一化层现在会随 tool_use 事件给出 `tool_kind`
299
309
  *(command / file_change / web_search / mcp / agent_call / input_request / permission /
300
310
  * tool_call / unknown,词表对齐 agents-anywhere 的 ToolContentKind)。
301
311
  *
@@ -304,13 +314,14 @@ function qaToolKindOf(name, argsObj){
304
314
  * 掉回通用卡(实测 Read 会从「文件」图标变回通用锤子)。
305
315
  *
306
316
  * 规则:**本地认出来就听本地的;本地只认得通用 `tool` 时,采用服务端给的形态**。
307
- * 这样既不丢精度,又能覆盖本地名字表没见过的引擎(那是这份字段真正的价值所在)。
308
- * ⚠️ 与 magic-test 仓库的同名函数逐字一致:两个界面共用一份词表,别各改各的。 */
317
+ * 这样既不丢精度,又能覆盖本地名字表没见过的引擎(那是这份字段真正的价值所在)。 */
309
318
  function qaToolKindMerge(name, argsObj, serverKind){
310
319
  const local = qaToolKindOf(name, argsObj);
311
320
  if(local !== "tool") return local;
312
321
  const m = { command:"command", file_change:"file_change", web_search:"web_search",
313
322
  mcp:"mcp", agent_call:"agent_call",
323
+ /* 待办清单:服务端按**名字**判定(ToolKindTodo),本地名字表没见过的引擎靠它兜底 */
324
+ todo:"todo",
314
325
  /* 服务端更粗的那几档在本地没有对应形态 → 落到通用卡(不硬造新形态) */
315
326
  tool_call:"tool", input_request:"tool", permission:"tool", unknown:"tool" };
316
327
  return m[String(serverKind || "")] || "tool";
@@ -322,6 +333,7 @@ function qaToolIconFor(kind){
322
333
  if(kind === "web_search") return QA_V_ICONS.search;
323
334
  if(kind === "web_fetch") return QA_V_ICONS.search;
324
335
  if(kind === "mcp") return QA_V_ICONS.mcp;
336
+ if(kind === "todo") return QA_V_ICONS.listcheck;
325
337
  if(kind === "read" || kind === "search") return QA_V_ICONS.filepen;
326
338
  return QA_V_ICONS.hammer;
327
339
  }
@@ -362,6 +374,11 @@ function qaToolHumanTitle(name, argsObj, argsText){
362
374
  const d = qaClip(o && (o.description || o.subagent_type), 64);
363
375
  return d ? "委派:" + d : "委派子代理";
364
376
  }
377
+ if(kind === "todo"){
378
+ const arr = o && Array.isArray(o.todos) ? o.todos : o && Array.isArray(o.plan) ? o.plan : [];
379
+ const done = arr.filter(t=>/complet|done|success|finish/i.test(String((t && (t.status || t.state)) || ""))).length;
380
+ return arr.length ? "更新待办 " + done + "/" + arr.length + " 完成" : "更新待办";
381
+ }
365
382
  if(kind === "mcp"){
366
383
  const parts = String(name || "").split(/__|_/).filter(Boolean);
367
384
  return parts.length >= 2 ? parts[0] + " / " + parts.slice(1).join(" ") : String(name || "mcp");
@@ -421,6 +438,98 @@ function qaFileChangeNode(change){
421
438
  return wrap;
422
439
  }
423
440
 
441
+ /* ---------- TodoWrite 待办清单(dsh todo-row;跨引擎通用)----------
442
+ 与文件变更同理:没有任何引擎会单发「待办」事件,清单只装在**工具参数**里 ——
443
+ Claude 系 TodoWrite 带 {todos:[{content,status,activeForm}]},Codex 系 update_plan
444
+ 带 {plan:[{step,status}]}。所以从参数推导一份「状态圆点 + 文案」清单贴进明细:
445
+ completed 划线、in_progress 加粗换 activeForm(「正在…」)、pending 留空圈。
446
+ ⚠️ 状态只落 class(is-*),文案只落 textContent —— 快照重建后原样还原。 */
447
+ function qaTodoOf(name, argsObj){
448
+ const o = argsObj && typeof argsObj === "object" ? argsObj : null;
449
+ if(!o) return null;
450
+ const arr = Array.isArray(o.todos) ? o.todos : Array.isArray(o.plan) ? o.plan : null;
451
+ if(!arr || !arr.length) return null;
452
+ const rows = [];
453
+ for(const t of arr){
454
+ if(!t || typeof t !== "object") continue;
455
+ const text = String(t.content || t.text || t.subject || t.task || t.title || t.step || "").trim();
456
+ if(!text) continue;
457
+ const raw = String(t.status || t.state || "pending").toLowerCase();
458
+ rows.push({ text: text,
459
+ status: /complet|done|success|finish/.test(raw) ? "completed"
460
+ : /progress|active|running|doing/.test(raw) ? "in_progress" : "pending",
461
+ active: String(t.activeForm || t.active_form || "").trim() });
462
+ }
463
+ return rows.length ? rows : null;
464
+ }
465
+ /** 待办清单的**落笔处**(清单 → 汇总头 + 每行状态圆点与文案)。
466
+ * ⚠️ 原地更新:同一个节点被反复 fill,**不新建节点** ——
467
+ * 一条应答里的任务列表只有一块,每次 TodoWrite 重发整份清单时换的是这块的内容,
468
+ * 而不是「一次调用一张卡、同一份清单重复出现 N 次」(见 03-chat-message 的 todoList)。
469
+ * 清空只清子节点(`textContent = ""`):`open` 这类**状态类**留在 host 上,重画不丢开合态。 */
470
+ function qaTodoFill(host, rows, opts){
471
+ if(!host) return null;
472
+ const o = opts || {}, arr = Array.isArray(rows) ? rows : [];
473
+ host.textContent = "";
474
+ const done = arr.filter(t=>t.status === "completed").length;
475
+ const prog = arr.filter(t=>t.status === "in_progress");
476
+ const hd = document.createElement("div");
477
+ hd.className = "qa-todo-hd";
478
+ /* 带标题的形态 = 「任务列表」面板(见下方 qaTodoNode 的 opts.title):
479
+ 标题在左;开合箭头放最右(`margin-left:auto`,见 app.css)。 */
480
+ if(o.title){
481
+ const tt = document.createElement("span");
482
+ tt.className = "qa-todo-tt";
483
+ tt.textContent = String(o.title);
484
+ hd.appendChild(tt);
485
+ }
486
+ const sum = document.createElement("span");
487
+ sum.className = "qa-todo-sum";
488
+ sum.textContent = "待办 " + done + "/" + arr.length
489
+ + (prog.length ? " · " + qaClip(prog[0].active || prog[0].text, 40) : "");
490
+ hd.appendChild(sum);
491
+ if(o.title){
492
+ /* ⚠️ 开合态只落**类**(.qa-todo-panel 上的 open),不写 data-* / 不写行内样式:
493
+ 快照恢复走 `innerHTML = snapshot`,类会跟着还原,状态不会「恢复后又弹开」
494
+ (与 qaFoldMaybe 同一条口径)。点击由 #qa-stream 上的事件委托处理
495
+ (见 07-chat-stream-render 的 bindQaStreamDelegation)。 */
496
+ const tg = document.createElement("span");
497
+ tg.className = "qa-todo-toggle";
498
+ tg.setAttribute("role", "button");
499
+ tg.setAttribute("aria-label", "展开或收起任务列表");
500
+ tg.innerHTML = QA_V_ICONS.chev;
501
+ hd.appendChild(tg);
502
+ }
503
+ host.appendChild(hd);
504
+ const list = document.createElement("div");
505
+ list.className = "qa-todo-list";
506
+ arr.forEach(t=>{
507
+ const r = document.createElement("div");
508
+ r.className = "qa-todo-row is-" + t.status;
509
+ const ico = document.createElement("span");
510
+ ico.className = "qa-todo-ico";
511
+ if(t.status === "completed") ico.innerHTML = QA_V_ICONS.ok;
512
+ else if(t.status === "in_progress") ico.innerHTML = QA_V_ICONS.clock;
513
+ const txt = document.createElement("span");
514
+ txt.className = "qa-todo-t";
515
+ txt.textContent = (t.status === "in_progress" && t.active) ? t.active : t.text;
516
+ r.appendChild(ico);
517
+ r.appendChild(txt);
518
+ list.appendChild(r);
519
+ });
520
+ host.appendChild(list);
521
+ return host;
522
+ }
523
+ /** 待办清单节点(新建一份,内容由 qaTodoFill 落笔)。
524
+ * · 不传 opts.title → 贴进工具卡明细的那一段(旧行为,逐字保留:旧句柄回落用);
525
+ * · 传 opts.title → 「任务列表」面板形态(带标题 + 可开合),由应答句柄的 todoList 使用。 */
526
+ function qaTodoNode(rows, opts){
527
+ const wrap = document.createElement("div");
528
+ wrap.className = "qa-todo" + ((opts && opts.title) ? " qa-todo-panel" : "");
529
+ qaTodoFill(wrap, rows, opts);
530
+ return wrap;
531
+ }
532
+
424
533
  /* ---------- 长消息折叠(anywhere CollapsibleUserMessage)----------
425
534
  *
426
535
  * 这套机制本项目**早就有了**,但它只长在一条路径上:审核引擎的报告(12-req-board 的