@migor/cli 0.10.8 → 0.10.10

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.
@@ -62,22 +62,71 @@
62
62
  aside { display: flex; flex-direction: column; overflow: hidden; }
63
63
  #tabs, #chat-bar { flex: none; }
64
64
  #runs, #chat { flex: 1 1 auto; min-height: 0; overflow: auto; }
65
- /* 右栏分层:`#trace` 是**唯一**无界增长的部分(它随 span 数长)⇒ 只有它 flex 撑开并滚;
66
- 回复 / 能力排行 / 用量固定在下方,各自有上限(回复很长时在**它自己那块**里滚,
67
- 而不是把树推出屏幕 —— 这与「看树」和「读回复」两个动作各自独立是一致的)。 */
65
+ /* 右栏分层:三块(调用树 / 能力排行 / 回复)**分同一个剩余高度**,每块自己滚。
66
+ 为什么不再「只有树撑开、其余写死 38vh」:写死的上限在只有一块要看时是**浪费**
67
+ (回复长时它自己那块滚,树反而只有半屏),在块数变多时又是**挤**。折叠是用户能
68
+ 直接操作的那个旋钮 —— 收起不要的,剩下的自然变大。 */
68
69
  section {
69
70
  padding: 14px; font-family: var(--mono); font-size: 12px;
70
71
  display: flex; flex-direction: column; overflow: hidden;
71
72
  }
72
73
  #ttl { flex: none; }
73
- #trace { flex: 1 1 auto; min-height: 140px; overflow: auto; }
74
- #summary, #reply, .usage { flex: none; max-height: 38vh; overflow: auto; }
75
- /* 并排 A/B:两棵树各占一半、**各自独立滚**(一棵树很长时不该把另一棵推出屏幕 ——
76
- 与「树是唯一无界部分」那条纪律同款,只是现在无界的部分有两块)。
77
- `#trace2` 只在并排时存在(单run 时 hidden),此时 `#trace` 让出 50%。 */
74
+ #panes { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
75
+ .pane { display: flex; flex-direction: column; min-height: 0; }
76
+ .pane[hidden] { display: none; }
77
+ .pane + .pane { border-top: 1px solid var(--border); padding-top: 6px; }
78
+ /* 展开的各块**按份**分剩余高度(收掉一块,另一块自然变大);
79
+ 收起的只留标题栏 —— `flex: 0 0 auto` 是关键:它不许参与剩余空间的分配。 */
80
+ .pane[data-open='1'] { flex: 1 1 0; }
81
+ /* 回复按 1.5 份:它是这次 run 的**结论**,而树是过程 —— 平分的话跑完只剩四行正文,
82
+ 等于逼用户先折掉别的块才发现内容被裁了。比例是弹性的(basis 为 0 ⇒ 不会撑破容器),
83
+ 窗口再矮也只是各自更小,不会溢出到下面的用量行。 */
84
+ #pane-reply[data-open='1'] { flex-grow: 1.5; }
85
+ /* 树按 2 份。三块**都开着**时平分的话它只有 134px ≈ 三行(1440×900 实测:23 行里
86
+ 只看得到 3 行),而树里还塞着工具条。树是这个页面存在的理由,亏待它会让人以为
87
+ 「这条 run 就这么点东西」。取 2 而不是更极端:回复仍要比它大(结论优先)。 */
88
+ #pane-trace[data-open='1'] { flex-grow: 2; }
89
+ /* 排行是一张**有界**的表:按自己内容的高度放,长过 45% 才在它自己那块里滚。
90
+ 给它也按份分剩余高度的话,短表下面会空出一片 —— 那块空白本该属于树或回复。 */
91
+ #pane-summary[data-open='1'] { flex: 0 1 auto; max-height: 45%; }
92
+ .pane[data-open='0'] { flex: 0 0 auto; }
93
+ .pane-head {
94
+ flex: none; display: flex; align-items: baseline; gap: 6px; width: 100%;
95
+ background: none; border: 0; padding: 0 0 4px; color: var(--muted);
96
+ font: inherit; font-family: var(--mono); font-size: 11px; text-align: left; cursor: pointer;
97
+ }
98
+ .pane-head:hover { color: var(--text); }
99
+ .pane-head:focus-visible { outline: 1px solid var(--ice); outline-offset: 2px; }
100
+ /* caret 走 CSS 生成内容(不是在 JS 里改 textContent):它是**纯装饰**,
101
+ 与 `data-open` 这个唯一状态同源,就不会出现「箭头说开着、内容收着」。 */
102
+ .pane-caret { flex: none; width: 9px; color: var(--faint); }
103
+ .pane[data-open='1'] > .pane-head .pane-caret::before { content: '▾'; }
104
+ .pane[data-open='0'] > .pane-head .pane-caret::before { content: '▸'; }
105
+ .pane-name { color: var(--text); letter-spacing: .2px; }
106
+ .pane-meta { margin-left: auto; color: var(--faint); }
107
+ .pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
108
+ .pane[data-open='0'] > .pane-body { display: none; }
109
+ /* 调用树这一块的 body **自己不滚** —— 滚动交给 `#trace` / `#trace2`
110
+ (并排时是两棵各自滚的树,body 滚会把它们的表头一起带走)。
111
+ ⚠️ 这条带 id 的那一段**必须**跟着 `[data-open='1']` 走:id 选择器压过下面那条
112
+ `.pane[data-open='0'] > .pane-body { display: none }`(1,1,0 vs 0,3,0)——
113
+ 第一版没加,结果折叠后树照样铺满(面板里"收了等于没收",比不折叠更糟)。 */
114
+ #pane-trace[data-open='1'] > .pane-body { display: flex; flex-direction: column; overflow: hidden; }
115
+ #trace { flex: 1 1 auto; min-height: 120px; overflow: auto; }
116
+ /* 树工具条(树内检索 + 时间轴开关):不塞进 `.pane-head`(那是个 button,嵌输入框点一下
117
+ 就折叠了)。⚠️ 基础规则必须排在 `.cmp-on #tree-bar` **之前** —— 低特异性的规则写在后面
118
+ 会被规则引擎判成「可能不生效」(`noDescendingSpecificity`),而那条告警在本仓是失败。 */
119
+ #tree-bar { flex: none; display: flex; gap: 6px; align-items: center; padding-bottom: 6px; }
120
+ .usage { flex: none; }
121
+ /* 并排 A/B:两棵树各占一半、**各自独立滚**(一棵树很长时不该把另一棵推出屏幕)。
122
+ `#trace2` 只在并排时存在(单run 时 hidden)。 */
123
+ .cmp-on #pane-trace > .pane-body { flex-direction: row; flex-wrap: wrap; }
124
+ /* 并排时 body 是 row ⇒ 树工具条必须自己占满一整行,否则它会挤在两棵树左边。
125
+ 单栏时 body 是 column,`flex: none` 就够(写成 100% 会变成「占满高度」)。 */
126
+ .cmp-on #tree-bar { flex: 0 0 100%; }
78
127
  .cmp-on #trace { flex: 1 1 50%; }
79
128
  .tr-compare {
80
- flex: 1 1 50%; min-height: 140px; overflow: auto;
129
+ flex: 1 1 50%; min-height: 120px; overflow: auto;
81
130
  border-left: 1px solid var(--border);
82
131
  }
83
132
  #diffbar { flex: none; max-height: 30vh; overflow: auto; padding-bottom: 6px; }
@@ -94,7 +143,9 @@
94
143
  两者可以不同 —— 正在看的可能不在对比里)。用左边框双线区分,不靠颜色。 */
95
144
  .run.cmp { box-shadow: inset 2px 0 0 var(--warn); }
96
145
  .run.cmp.active { box-shadow: inset 2px 0 0 var(--ice), inset 4px 0 0 var(--warn); }
97
- /* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态) */
146
+ /* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态)。
147
+ 三块也回到「各自自然高度」—— 屏幕本来就装不下,再按比例分剩余高度只会让每块都变成
148
+ 一条缝;此时折叠仍然有效(收起的照样只剩标题栏),但它不再是唯一的高度旋钮。 */
98
149
  @media (max-width: 860px) {
99
150
  html, body { height: auto; }
100
151
  body { overflow: visible; }
@@ -102,9 +153,11 @@
102
153
  aside { overflow: visible; }
103
154
  section { overflow: visible; }
104
155
  #runs, #chat, #trace, .tr-compare { overflow: visible; }
156
+ #panes { flex: none; }
157
+ .pane[data-open='1'] { flex: none; }
105
158
  #trace, .tr-compare { min-height: 320px; }
106
159
  .cmp-on #trace { flex: 1 1 auto; }
107
- #summary, #reply, .usage, #diffbar { max-height: none; }
160
+ .usage, #diffbar { max-height: none; }
108
161
  }
109
162
  .run {
110
163
  display: block; width: 100%; text-align: left; cursor: pointer;
@@ -119,10 +172,47 @@
119
172
  /* 被中止的 run 不是失败:给它一个中性偏警告的颜色,不跟失败共用红色 ——
120
173
  否则用户会把「我自己按了中止」读成「我把工程改坏了」 */
121
174
  .run.aborted .dot { background: var(--warn); }
175
+ /* 等审批的 run(`suspended`):**空心点**。它既没跑完也没失败,实心点怎么上色都是
176
+ 在替用户下结论;空心 = 「这一轮还悬着」,与右栏那句「停在审批」同一件事 */
177
+ .run.suspended .dot { background: transparent; box-shadow: inset 0 0 0 1px var(--warn); }
122
178
  .run .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
123
179
  .run .r2, .run .r3 { margin-top: 3px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
124
- .run .r3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
180
+ .run .r2, .run .r3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
125
181
  .empty { color: var(--faint); padding: 16px; }
182
+ /* —— 列表检索(2026-11-13):环里 50 条 run,此前只能靠肉眼扫 —— */
183
+ #list-bar {
184
+ flex: none; display: flex; gap: 6px; padding: 6px 8px;
185
+ border-bottom: 1px solid var(--border);
186
+ }
187
+ #list-q {
188
+ flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border);
189
+ border-radius: 4px; color: var(--text); padding: 4px 7px; font: inherit; font-size: 11.5px;
190
+ }
191
+ #list-q:focus { outline: 1px solid var(--ice); border-color: var(--ice); }
192
+ /* 「只看异常」是**开关**,用 aria-pressed 表示按下(同时给读屏软件) */
193
+ #list-abn[aria-pressed='true'] { border-color: var(--warn); color: var(--warn); }
194
+ /* —— run 级总账(2026-11-13):打开一条 run,先给「这一趟的账」,再给树 ——
195
+ 此前页面上只有 footer 的两个数(in / out),而 cache 命中率与成本 —— 最直接对应
196
+ 账单的两个数 —— 一个都没有。 */
197
+ #ledger {
198
+ flex: none; display: flex; align-items: baseline; gap: 10px;
199
+ margin-bottom: 8px; font-size: 11.5px; color: var(--muted); flex-wrap: wrap;
200
+ }
201
+ #ledger[hidden] { display: none; }
202
+ #ledger-items { display: flex; flex-wrap: wrap; gap: 4px 12px; }
203
+ #ledger-items .lg { color: var(--faint); }
204
+ #ledger-items .lg b { color: var(--text); font-weight: 500; margin-left: 4px; }
205
+ #ledger-items .lg.err b { color: var(--err); }
206
+ #ledger .acts { margin-left: auto; display: flex; gap: 6px; }
207
+ /* —— 树工具条:树内检索 + 时间轴开关 ——
208
+ 基础规则在上面的 `#trace` 旁边(要排在 `.cmp-on` 那条之前)。 */
209
+ #tree-q {
210
+ flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border);
211
+ border-radius: 4px; color: var(--text); padding: 3px 7px; font: inherit; font-size: 11px;
212
+ }
213
+ #tree-q:focus { outline: 1px solid var(--ice); border-color: var(--ice); }
214
+ #tree-hit { color: var(--faint); font-size: 10.5px; min-width: 3ch; }
215
+ #tl-toggle[aria-pressed='false'] { color: var(--faint); }
126
216
  #ttl { color: var(--faint); margin-bottom: 10px; font-family: var(--mono); font-size: 11.5px; }
127
217
  .usage { display: flex; gap: 16px; margin-top: 12px; color: var(--muted); font-family: var(--mono); font-size: 12px; }
128
218
  /* biome-ignore lint/style/noDescendingSpecificity: 本行只颜色/字重,对手是 33 行的 header .live b —— 两者祖先容器不同(.usage 在面板区、.live 在 header),结构上不可能命中同一元素,是规则「只比末段标签」的误报。 */
@@ -154,11 +244,12 @@
154
244
  #chat-bar[hidden] { display: none; }
155
245
  #chat-sid { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
156
246
 
247
+ /* 回复正文:它现在住在「回复」这一块里(上面有自己的标题栏),
248
+ 所以不再要外边距,也不再自带 max-height —— 高度由块分、滚动由 `.pane-body` 出。 */
157
249
  #reply {
158
- margin: 14px 0 0; padding: 10px; border: 1px solid var(--border); border-radius: 4px;
250
+ padding: 8px 10px; border: 1px solid var(--border); border-radius: 4px;
159
251
  background: #0e0e10; word-break: break-word; color: var(--muted);
160
252
  }
161
- #reply[hidden] { display: none; }
162
253
 
163
254
  /* —— 模型正文:Markdown 渲染 ——
164
255
  `.md` 里所有元素都是 createElement 造出来的(解析器不产生 HTML,渲染器不用 innerHTML),
@@ -180,6 +271,20 @@
180
271
  .md blockquote { margin: 0 0 6px; padding-left: 9px; border-left: 2px solid var(--border); color: var(--muted); }
181
272
  .md a { color: var(--ice); }
182
273
  .md hr { border: 0; border-top: 1px solid var(--border); margin: 8px 0; }
274
+ /* 表格(2026-11-12):右栏只有 ~400px,内边距压到 3px 6px,列多时还能读;
275
+ 对齐用类名,不用 inline style(与「innerHTML 只许一处」同类纪律)。 */
276
+ .md-table { border-collapse: collapse; margin: 0 0 6px; font-size: 11.5px; }
277
+ .md-table:last-child { margin-bottom: 0; }
278
+ .md-table th, .md-table td { border: 1px solid var(--border); padding: 3px 6px; }
279
+ .md-table th { background: var(--card); color: var(--text); font-weight: 600; }
280
+ .md-table .md-right { text-align: right; }
281
+ .md-table .md-center { text-align: center; }
282
+ .md-table .md-left { text-align: left; }
283
+ /* trace 树里的 Markdown 正文(2026-11-12):树本身是 11px,正文块跟着收紧一档;
284
+ 字体不换 —— 一块正文突然换成无衬线,会和相邻的行错开视觉基线。 */
285
+ .md-trace { font-size: 11.5px; line-height: 1.55; }
286
+ .md-trace .md-h { font-size: 12px; margin: 8px 0 3px; }
287
+ .md-trace .md-table { font-size: 11px; }
183
288
  /* 正文用无衬线正文栈(模型输出是散文,等宽栈读它更累);码块与行内码仍是等宽。
184
289
  挂在 `.md-wrap`(渲染器的包装节点)而不是 `#reply .md, .turn .md`:单一类选择器
185
290
  既不用管特异性排序,也让「哪块是模型正文」只由一处决定。 */
@@ -268,6 +373,14 @@
268
373
  <button class="tab active" id="tab-runs" type="button">run 列表</button>
269
374
  <button class="tab" id="tab-chat" type="button">对话</button>
270
375
  </div>
376
+ <!--
377
+ 列表检索(2026-11-13):`/` 聚焦。「异常」= 失败 / 中止 / 等审批 —— 挂起的 run
378
+ 在 trace 上状态是 ok,只过滤 ok=false 会把它漏掉(规则在 panel-logic 的 filterRuns)。
379
+ -->
380
+ <div id="list-bar">
381
+ <input id="list-q" type="search" spellcheck="false" placeholder="搜 prompt / 目录 / 收尾原因(按 /)" />
382
+ <button id="list-abn" class="mini" type="button" aria-pressed="false" title="只看失败 / 中止 / 等审批的 run">异常</button>
383
+ </div>
271
384
  <div id="runs"><div class="empty">还没有 run。触发一次 agent 调用即可。</div></div>
272
385
  <!--
273
386
  清空对话(§6 待定 3):清空 = **换一个 sessionId**(不动 session.json ——
@@ -282,6 +395,18 @@
282
395
  </aside>
283
396
  <section>
284
397
  <div id="ttl"></div>
398
+ <!--
399
+ run 级总账(2026-11-13):轮次 / 工具 / 失败 / in-out / cache 命中率 / 成本(带预算)
400
+ / 模型。判据与文案全在 panel-logic 的 `runLedger`(带单测),这里只填 DOM。
401
+ 右侧两个动作:重跑(回填四个输入,**不自动跑**)与复制 JSONL(喂给 `agentia view`)。
402
+ -->
403
+ <div id="ledger" hidden>
404
+ <div id="ledger-items"></div>
405
+ <div class="acts">
406
+ <button class="mini" id="rerun" type="button" title="把这条 run 的 prompt / 目录 / 能力 / 多轮回填到输入条(不会自动开跑)">↻ 回填重跑</button>
407
+ <button class="mini" id="copy-run" type="button" title="复制这一条 trace 的 JSONL(存成 .jsonl 后可用 agentia view 打开)">复制 JSONL</button>
408
+ </div>
409
+ </div>
285
410
  <!--
286
411
  并排 A/B 的差异摘要(档 B)。默认 hidden,只有选满两条 run 才出现。
287
412
  三段结构:可比性提示(不可比时才有 —— 可比时**不显示**,见 comparability 的注释)、
@@ -292,14 +417,56 @@
292
417
  <div id="diff-body"></div>
293
418
  </div>
294
419
  <!--
295
- 双栏容器:并排时这里是**两个** trace 视图,单run 时只有一个。
296
- ⚠️ 不用「同一棵树改CSS 成两半」—— trace-view 的树是自绘的,两棵树必须两个容器。
420
+ 右栏三块:调用树 / 能力排行 / 回复。**三块都能折叠**(2026-11-12 ref-panel-panes)。
421
+ 为什么:这块栏位的高度是三者抢的 —— 三块同时展开时调用树只剩十几行、回复只剩两段,
422
+ 读谁都不够;而多数时刻用户只在看其中一块。折叠掉不要的那两块,剩下那块自动铺满
423
+ (`flex` 分的是同一个剩余空间,不是三份写死的 vh)。
424
+ ⚠️ 折叠态在标题栏留一行**规模读数**(N span / N 项 / N 行)—— 收起不等于失明。
297
425
  -->
298
- <div id="trace" class="tr-single"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
299
- <div id="trace2" class="tr-compare" hidden></div>
300
- <!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
301
- <div id="summary"></div>
302
- <pre id="reply" hidden></pre>
426
+ <div id="panes">
427
+ <div class="pane" id="pane-trace" data-open="1">
428
+ <button class="pane-head" id="head-trace" type="button" aria-expanded="true" aria-controls="body-trace">
429
+ <span class="pane-caret" aria-hidden="true"></span>
430
+ <span class="pane-name">调用树</span>
431
+ <span class="pane-meta" id="meta-trace"></span>
432
+ </button>
433
+ <div class="pane-body" id="body-trace">
434
+ <!--
435
+ 树工具条(2026-11-13):树内检索 + 时间轴开关。
436
+ 检索**只高亮 + 跳转,不改结构**(裁剪节点会让「谁挂在谁下面」失真);
437
+ 判定与跳转都在 trace-view 里(`setQuery` / `nextHit`),这里只接线。
438
+ -->
439
+ <div id="tree-bar">
440
+ <input id="tree-q" type="search" spellcheck="false" placeholder="树里找…(Enter 下一处 / Esc 清空)" />
441
+ <span id="tree-hit"></span>
442
+ <button class="mini" id="tl-toggle" type="button" aria-pressed="true" title="按 run 总时长归一的时间轴(关掉可以给它让出 72px)">时间轴</button>
443
+ </div>
444
+ <!--
445
+ 双栏容器:并排时这里是**两个** trace 视图,单run 时只有一个。
446
+ ⚠️ 不用「同一棵树改CSS 成两半」—— trace-view 的树是自绘的,两棵树必须两个容器。
447
+ -->
448
+ <div id="trace" class="tr-single"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
449
+ <div id="trace2" class="tr-compare" hidden></div>
450
+ </div>
451
+ </div>
452
+ <!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
453
+ <div class="pane" id="pane-summary" data-open="1" hidden>
454
+ <button class="pane-head" id="head-summary" type="button" aria-expanded="true" aria-controls="body-summary">
455
+ <span class="pane-caret" aria-hidden="true"></span>
456
+ <span class="pane-name">能力排行</span>
457
+ <span class="pane-meta" id="meta-summary"></span>
458
+ </button>
459
+ <div class="pane-body" id="body-summary"><div id="summary"></div></div>
460
+ </div>
461
+ <div class="pane" id="pane-reply" data-open="1" hidden>
462
+ <button class="pane-head" id="head-reply" type="button" aria-expanded="true" aria-controls="body-reply">
463
+ <span class="pane-caret" aria-hidden="true"></span>
464
+ <span class="pane-name">回复</span>
465
+ <span class="pane-meta" id="meta-reply"></span>
466
+ </button>
467
+ <div class="pane-body" id="body-reply"><div id="reply"></div></div>
468
+ </div>
469
+ </div>
303
470
  <div class="usage" id="usage" hidden>
304
471
  <span>in <b id="u-in">0</b></span>
305
472
  <span>out <b id="u-out">0</b></span>
@@ -352,7 +519,7 @@
352
519
 
353
520
  <script type="module">
354
521
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
355
- import { parseMarkdown } from './markdown.js';
522
+ import { parseMarkdown, looksLikeMarkdown } from './markdown.js';
356
523
  import {
357
524
  HISTORY_DRAFT,
358
525
  applyTraceEvent,
@@ -368,6 +535,7 @@
368
535
  diffValueText,
369
536
  diffFetchErrorText,
370
537
  emptyTraceAccumulator,
538
+ filterRuns,
371
539
  filterSelected,
372
540
  formatToolSources,
373
541
  historyStep,
@@ -380,9 +548,16 @@
380
548
  promptAfterFilePick,
381
549
  pushPromptHistory,
382
550
  replyBelongsTo,
551
+ rerunFromNote,
383
552
  runDoneNotice,
384
- runIsFailure,
553
+ runIdFromSearch,
554
+ runLedger,
385
555
  shortenPath,
556
+ replyMeta,
557
+ stopReasonClass,
558
+ stopReasonText,
559
+ summaryMeta,
560
+ traceMeta,
386
561
  toggleCompare,
387
562
  turnKey,
388
563
  } from './panel-logic.js';
@@ -419,16 +594,30 @@
419
594
  const fsEl = $('fs');
420
595
  const fsListEl = $('fs-list');
421
596
  const fsPathEl = $('fs-path');
597
+ const listQEl = $('list-q');
598
+ const listAbnEl = $('list-abn');
599
+ const ledgerEl = $('ledger');
600
+ const ledgerItemsEl = $('ledger-items');
601
+ const rerunEl = $('rerun');
602
+ const copyRunEl = $('copy-run');
603
+ const treeQEl = $('tree-q');
604
+ const treeHitEl = $('tree-hit');
605
+ const tlToggleEl = $('tl-toggle');
422
606
 
423
607
  const view = createTraceView(traceEl, {
424
608
  // 无公开单价 → 不估算成本;只展示 token
425
609
  usage: { in: $('u-in'), out: $('u-out') },
610
+ // 展开的工具出参若是 Markdown(SKILL.md / 报告),渲染成正文而不是源文;
611
+ // 判据与解析器只有一份(markdown.ts),见 traceTextBody 的口径注
612
+ textBody: traceTextBody,
426
613
  });
427
614
 
428
615
  // 并排 A/B 的**第二棵**树。刻意不共用 `view`:`createTraceView` 绑一个容器 +
429
616
  // 一个 usage 出口,两棵树塞进同一容器会互相清空(trace-view 的 `playTrace` 是
430
617
  // 整容器替换式的)。共用 `u-in`/`u-out` 也无意义 —— 那两个读数属于「正在看的那条」。
431
- const view2 = createTraceView(trace2El);
618
+ // `textBody` 与主树用**同一个**:同一份 trace 在两栏里必须长成同一个样子,
619
+ // 否则「并排看差异」会先看见渲染差异。
620
+ const view2 = createTraceView(trace2El, { textBody: traceTextBody });
432
621
 
433
622
  // ---------- 并排 A/B(档 B) ----------
434
623
 
@@ -498,6 +687,9 @@
498
687
  const t = await res.json();
499
688
  el.replaceChildren();
500
689
  playTrace(v, t);
690
+ // 树内检索是**跨重画**的状态(它在 view 里),但 playTrace 只是重画;
691
+ // 这里补一次是为了「并排时新打开的那一条也按当前关键词高亮」(否则两栏不同步)
692
+ if (treeQEl.value.trim()) v.setQuery(treeQEl.value);
501
693
  }
502
694
  await loadDiff(a, b);
503
695
  }
@@ -622,6 +814,11 @@
622
814
  live: null,
623
815
  notice: '', // 面板自己发的操作反馈(已接受 / 重启了 runner / 出错)
624
816
  noticeErr: false,
817
+ /** 列表检索(2026-11-13):关键词 **且** 「只看异常」—— 两者都与列表同生共死 */
818
+ q: '',
819
+ onlyAbnormal: false,
820
+ /** 当前打开的那条 trace 本体(总账 / 回填重跑 / 导出都用它;`state.current` 只有 id) */
821
+ trace: null,
625
822
  };
626
823
 
627
824
  const fmtMs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : ms + 'ms');
@@ -749,18 +946,42 @@
749
946
  runsEl.appendChild(d);
750
947
  return;
751
948
  }
949
+ // 检索(2026-11-13):关键词 **且** 「只看异常」。判定在 panel-logic(带单测)——
950
+ // 这里的「异常」包含挂起(它的 trace 状态是 ok,只过滤 ok=false 会漏掉)。
951
+ const shown = filterRuns(state.list, state.q, { onlyAbnormal: state.onlyAbnormal });
752
952
  runsEl.replaceChildren();
753
- for (const r of state.list) {
953
+ if (shown.length === 0) {
954
+ // ⚠️ 「过滤掉了全部」与「一条都没有」是两件事,必须说清是哪种 ——
955
+ // 一个错误的空态比一句错误更难发现(用户会以为 run 丢了)
956
+ const d = document.createElement('div');
957
+ d.className = 'empty';
958
+ const why = state.onlyAbnormal ? '只看异常' : '无筛选';
959
+ d.textContent = `没有匹配的 run(共 ${state.list.length} 条 · ${why}${state.q ? ` · 关键词「${state.q}」` : ''})`;
960
+ runsEl.appendChild(d);
961
+ return;
962
+ }
963
+ for (const r of shown) {
754
964
  const b = document.createElement('button');
755
- // 三态:失败 / **被中止** / 正常。中止不是失败(判别走 panel-logic 的
756
- // runIsFailure,与通知条同一条语义)—— 给中止的 run 标红会让用户以为
757
- // 是自己把工程改坏了,而那正是「aborted 也带 error」这条引擎约定造成的误读。
758
- const tone = runIsFailure(r) ? ' err' : r.stopReason === 'aborted' ? ' aborted' : '';
965
+ // 四态:失败 / **被中止** / **等审批** / 正常。判别全走 panel-logic 的
966
+ // `stopReasonClass`(与通知条同一条语义)—— 各处自己读 `ok` 会出现「列表说失败、
967
+ // 通知条说已中止」这种同一件事两个结论。
968
+ // 为什么等审批要单独一态:`suspended` 的 trace 状态是 ok(挂起段本身没出错)
969
+ // ⇒ 只看 ok 它是一个**正常跑完的蓝点**,而那一轮其实还挂着等人。
970
+ const cls = stopReasonClass(r);
971
+ const tone =
972
+ cls === 'failed'
973
+ ? ' err'
974
+ : cls === 'aborted'
975
+ ? ' aborted'
976
+ : cls === 'suspended'
977
+ ? ' suspended'
978
+ : '';
759
979
  b.className =
760
980
  'run' +
761
981
  tone +
762
982
  (r.traceId === state.current ? ' active' : '') +
763
983
  (state.compare.includes(r.traceId) ? ' cmp' : '');
984
+
764
985
  b.type = 'button';
765
986
 
766
987
  const r1 = document.createElement('span');
@@ -769,12 +990,22 @@
769
990
  dot.className = 'dot';
770
991
  const name = document.createElement('span');
771
992
  name.className = 'name';
772
- name.textContent = r.name; // 模型可控内容 ⇒ 只能 textContent
993
+ // 首行给**这次 run 到底问了什么**。此前这里是 `r.name`,而 dev 环起的 run
994
+ // 一律叫 `agent.run`(`engine/loop.ts` 的缺省,dev-runner 不传 runName)
995
+ // ⇒ 五十条一模一样的条目,只能靠第二行的时间戳认人。
996
+ // prompt 是模型不可控的**用户输入**,仍走 textContent;多行只取首行(CSS 再省略)。
997
+ const prompt = (r.note && typeof r.note.prompt === 'string' ? r.note.prompt : '').trim();
998
+ name.textContent = prompt ? prompt.split('\n')[0] : r.name;
999
+ // 全文进 title:列表里被省略的那半句常常正是区分两次 run 的部分
1000
+ b.title = prompt || r.name;
773
1001
  r1.append(dot, name);
774
1002
 
775
1003
  const r2 = document.createElement('span');
776
1004
  r2.className = 'r2';
777
- r2.textContent = `${fmtMs(r.ms)} · ${fmtNum(r.tokens)} tok · ${ago(r.startedAt)}`;
1005
+ // 非正常收尾把**原因**写在第二行:`budget_exceeded` / `max_iterations` 的 run
1006
+ // 与 `end_turn` 的 run 在旧版看起来一模一样(同样蓝点亮着),而前者根本没跑完。
1007
+ const why = cls === 'ok' ? '' : ` · ${stopReasonText(r.stopReason)}`;
1008
+ r2.textContent = `${fmtMs(r.ms)} · ${fmtNum(r.tokens)} tok · ${ago(r.startedAt)}${why}`;
778
1009
 
779
1010
  b.append(r1, r2);
780
1011
 
@@ -807,6 +1038,150 @@
807
1038
  }
808
1039
  }
809
1040
 
1041
+ // ---------- run 级总账(2026-11-13) ----------
1042
+
1043
+ /**
1044
+ * 打开一条 run,先给「这一趟的账」:轮次 / 工具 / 失败 / in-out / cache 命中率 /
1045
+ * 成本(带上限)/ 模型。判据与文案全在 `runLedger`(带单测),这里只填 DOM。
1046
+ *
1047
+ * 一条可显示项都没有时**整行不出现**(不做空壳)—— 一个只有两个按钮的空账行
1048
+ * 会让人以为数据没读到。
1049
+ */
1050
+ function renderLedger(trace) {
1051
+ ledgerItemsEl.replaceChildren();
1052
+ const items = runLedger(trace, state.dev ? state.dev.budget : null);
1053
+ ledgerEl.hidden = items.length === 0;
1054
+ for (const it of items) {
1055
+ const s = document.createElement('span');
1056
+ s.className = 'lg' + (it.tone === 'err' ? ' err' : '');
1057
+ if (it.title) s.title = it.title;
1058
+ s.appendChild(document.createTextNode(it.key));
1059
+ const b = document.createElement('b');
1060
+ b.textContent = it.value;
1061
+ s.appendChild(b);
1062
+ ledgerItemsEl.appendChild(s);
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * 「回填重跑」:把这条 run 的四个输入写回输入条,**不自动提交**。
1068
+ * 自动跑会在误触时花钱,而「回填 + 我按运行」多的只是一次点击。
1069
+ */
1070
+ rerunEl.addEventListener('click', () => {
1071
+ const fill = rerunFromNote(
1072
+ state.trace ? state.trace.note : null,
1073
+ state.dev ? state.dev.capabilities : [],
1074
+ );
1075
+ if (!fill) {
1076
+ setNotice('这条 run 没有开发期元信息(不是 agentia dev 起的,或记账已被淘汰)⇒ 没法回填', true);
1077
+ return;
1078
+ }
1079
+ promptEl.value = fill.prompt;
1080
+ if (fill.workdir) {
1081
+ workdirEl.value = fill.workdir;
1082
+ state.workdir = fill.workdir;
1083
+ }
1084
+ state.selected = fill.toolSources;
1085
+ state.multiTurn = fill.multiTurn;
1086
+ renderControls();
1087
+ renderChat();
1088
+ // banner 是 textContent(不跑 Markdown)⇒ 别写 `**没有**`,那会原样露出星号
1089
+ setNotice('已回填这条 run 的四个输入(没有自动运行)—— 确认后按「▶ 运行」', false);
1090
+ promptEl.focus();
1091
+ });
1092
+
1093
+ /**
1094
+ * 复制这一条 trace 的 JSONL。去掉 CLI 侧的 `note`:它不是 trace 的一部分,
1095
+ * 而产物的去处是 `agentia view <file>`(那份只认 trace 形状)。
1096
+ */
1097
+ copyRunEl.addEventListener('click', async () => {
1098
+ if (!state.current) return;
1099
+ try {
1100
+ const res = await fetch('/api/runs/' + encodeURIComponent(state.current));
1101
+ if (!res.ok) {
1102
+ setNotice(`读 trace 失败:HTTP ${res.status}`, true);
1103
+ return;
1104
+ }
1105
+ const trace = await res.json();
1106
+ const bare = { ...trace };
1107
+ delete bare.note;
1108
+ const text = JSON.stringify(bare) + '\n';
1109
+ const clip = navigator.clipboard;
1110
+ if (!clip || typeof clip.writeText !== 'function') {
1111
+ setNotice('这个浏览器没有剪贴板接口 —— 可以直接用 agentia view 读 .agentia/traces.jsonl', true);
1112
+ return;
1113
+ }
1114
+ await clip.writeText(text);
1115
+ setNotice(`已复制 ${text.length} 字符。存成 .jsonl 后:agentia view <文件>`, false);
1116
+ } catch (e) {
1117
+ setNotice(`复制失败:${e.message}`, true);
1118
+ }
1119
+ });
1120
+
1121
+ // ---------- 右栏三块的折叠(收起不要的,剩下的自动变大) ----------
1122
+
1123
+ /**
1124
+ * 折叠态是**布局偏好**,不是数据:切 run / 重新打开页面都不该把它重置
1125
+ * (每次回来都「三块全开、谁都不够高」等于没做)。所以它进 localStorage。
1126
+ * 存不下(隐私模式 / 配额满 / 被禁用)就只活这一次会话 —— 面板不因为记不住布局而白屏,
1127
+ * 这也是下面那个 `try` 存在的唯一理由。
1128
+ */
1129
+ const PANES = ['trace', 'summary', 'reply'];
1130
+ const PANE_KEY = 'agentia.panes';
1131
+ const paneEls = {};
1132
+ for (const id of PANES) {
1133
+ paneEls[id] = { box: $(`pane-${id}`), head: $(`head-${id}`), meta: $(`meta-${id}`) };
1134
+ }
1135
+ const openPanes = (() => {
1136
+ try {
1137
+ const raw = JSON.parse(localStorage.getItem(PANE_KEY) || 'null');
1138
+ return new Set(Array.isArray(raw) ? raw.filter((id) => PANES.includes(id)) : PANES);
1139
+ } catch {
1140
+ return new Set(PANES);
1141
+ }
1142
+ })();
1143
+
1144
+ /**
1145
+ * 写折叠态的**唯一**入口(含初始化)。
1146
+ *
1147
+ * `data-open`(视觉)与 `aria-expanded`(语义)必须一起改 —— 只改前者,屏幕阅读器读到的
1148
+ * 还是「展开」;只改后者,屏幕上是收着的。两条同源的守卫在 `test/structure.test.mjs`
1149
+ * (W6:`dataset.open =` 全文件只许出现一次)。
1150
+ */
1151
+ function applyPane(id, open) {
1152
+ const p = paneEls[id];
1153
+ p.box.dataset.open = open ? '1' : '0';
1154
+ p.head.setAttribute('aria-expanded', open ? 'true' : 'false');
1155
+ if (open) openPanes.add(id);
1156
+ else openPanes.delete(id);
1157
+ }
1158
+
1159
+ /** 用户点击(或初始化之后)才落盘:`applyPane` 在初始化时也会跑,不该顺手写一遍盘 */
1160
+ function persistPanes() {
1161
+ try {
1162
+ localStorage.setItem(PANE_KEY, JSON.stringify([...openPanes]));
1163
+ } catch {
1164
+ /* 存不下就算了:这一次会话内折叠仍然有效 */
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * 标题栏读数。**只在真的变了时**写 —— 在飞时每一帧都会调它,无谓的赋值会白触发重排
1170
+ * (调用树那块的读数是随 span 数增长的,帧率下这个差别指的是真的会掉帧)。
1171
+ */
1172
+ function setPaneMeta(id, text) {
1173
+ const el = paneEls[id].meta;
1174
+ if (el.textContent !== text) el.textContent = text;
1175
+ }
1176
+
1177
+ for (const id of PANES) {
1178
+ paneEls[id].head.addEventListener('click', () => {
1179
+ applyPane(id, paneEls[id].box.dataset.open !== '1');
1180
+ persistPanes();
1181
+ });
1182
+ applyPane(id, openPanes.has(id));
1183
+ }
1184
+
810
1185
  // ---------- 模型正文的渲染(Markdown + 长了就折叠) ----------
811
1186
 
812
1187
  /**
@@ -871,6 +1246,37 @@
871
1246
  list.appendChild(li);
872
1247
  }
873
1248
  el.appendChild(list);
1249
+ } else if (b.type === 'table') {
1250
+ /* 表格(2026-11-12):错误汇总 / 逐项统计天然成表,一律渲染。
1251
+ 对齐走类名(`md-left` / `md-right` / `md-center`),不写 inline style ——
1252
+ 全页对 style 属性的写入为 0,这条和「innerHTML 只许一处」同一类纪律。 */
1253
+ const table = document.createElement('table');
1254
+ table.className = 'md-table';
1255
+ const thead = document.createElement('thead');
1256
+ const htr = document.createElement('tr');
1257
+ b.header.forEach((cell, ci) => {
1258
+ const th = document.createElement('th');
1259
+ const al = b.align[ci];
1260
+ if (al) th.className = 'md-' + al;
1261
+ appendInline(th, cell);
1262
+ htr.appendChild(th);
1263
+ });
1264
+ thead.appendChild(htr);
1265
+ table.appendChild(thead);
1266
+ const tbody = document.createElement('tbody');
1267
+ for (const row of b.rows) {
1268
+ const tr = document.createElement('tr');
1269
+ row.forEach((cell, ci) => {
1270
+ const td = document.createElement('td');
1271
+ const al = b.align[ci];
1272
+ if (al) td.className = 'md-' + al;
1273
+ appendInline(td, cell);
1274
+ tr.appendChild(td);
1275
+ });
1276
+ tbody.appendChild(tr);
1277
+ }
1278
+ table.appendChild(tbody);
1279
+ el.appendChild(table);
874
1280
  } else if (b.type === 'quote') {
875
1281
  const q = document.createElement('blockquote');
876
1282
  appendInline(q, b.inline);
@@ -885,23 +1291,47 @@
885
1291
  }
886
1292
  }
887
1293
 
1294
+ /**
1295
+ * trace 树里「一整篇 Markdown」的字符串正文 —— 挂给 trace-view 的 `textBody` 钩子。
1296
+ *
1297
+ * 工具出参的另一种常见形态就是一份 SKILL.md / 错误汇总报告:类型是**字符串** ⇒
1298
+ * 之前只能走 `.tr-io` 的原文,展开后是 `<!-- 迁移自 … -->`、`# 标题`、`|---|---|`
1299
+ * 的源文。这里把它渲染成正文(`<!-- -->` 已在解析层丢弃)。
1300
+ *
1301
+ * 返回 `null` 表示「这不是 Markdown,保持原文」。判据与解析器都只有一份,在
1302
+ * `markdown.ts`:trace-view 是**独立发布包**,不能反向依赖 CLI,所以走**注入**
1303
+ * 而不是让它自己再带一份解析器 —— 两份必然漂移。
1304
+ */
1305
+ function traceTextBody(text) {
1306
+ if (!looksLikeMarkdown(text)) return null;
1307
+ const box = document.createElement('div');
1308
+ box.className = 'md md-trace';
1309
+ renderMarkdownInto(box, text);
1310
+ return box;
1311
+ }
1312
+
888
1313
  /**
889
1314
  * 一段**模型正文**(最终回复 / 助手轮)→ 填进 `wrap`。
890
1315
  *
891
1316
  * 折叠控件放在正文**下方、左对齐**,不在行尾悬 caret:trace-view 那边 caret 靠右是因为
892
1317
  * 行是 nowrap 的表格状布局,「右端」本来就是「这一行结束了」的位置;正文块不是那个形态,
893
1318
  * 一个 10px 的箭头挂在右上角没人会去找。判定走 `collapseDecision`(纯逻辑、带单测)。
1319
+ *
1320
+ * `opts.clamp === false` 关掉**内层**的高度夹(回复那一块用):那块自己已经能折叠、
1321
+ * 自己会滚,再在里面夹一刀就出现两个「展开」—— 一个收整块、一个收正文,点错哪个都
1322
+ * 不像自己期望的。对话轮仍要夹:它长在一列滚滚条里,一条长回复能把整列顶掉。
894
1323
  */
895
- function renderMessage(wrap, text, key) {
1324
+ function renderMessage(wrap, text, key, opts = {}) {
896
1325
  wrap.classList.add('md-wrap');
897
1326
  const body = document.createElement('div');
898
1327
  body.className = 'md';
899
1328
  const d = collapseDecision(text);
1329
+ const clamp = opts.clamp !== false;
900
1330
  const open = expandedMsgs.has(key);
901
- if (d.collapsed && !open) body.classList.add('md-clamped');
1331
+ if (clamp && d.collapsed && !open) body.classList.add('md-clamped');
902
1332
  renderMarkdownInto(body, text);
903
1333
  wrap.appendChild(body);
904
- if (!d.collapsed) return; // 短正文不挂控件(挂一个「展开」是噪声)
1334
+ if (!clamp || !d.collapsed) return; // 短正文不挂控件(挂一个「展开」是噪声)
905
1335
  const btn = document.createElement('button');
906
1336
  btn.type = 'button';
907
1337
  btn.className = 'md-toggle';
@@ -915,15 +1345,19 @@
915
1345
  wrap.appendChild(btn);
916
1346
  }
917
1347
 
918
- /** 右栏底部那块回复正文。key 带 traceId ⇒ 换一轮就是一个新的折叠态 */
1348
+ /**
1349
+ * 右栏「回复」那一块。key 带 traceId ⇒ 换一轮就是一个新的折叠态。
1350
+ *
1351
+ * 没有回复时**整块藏起来**(不是留个空的标题栏):不做空壳是这块面板的既有口径,
1352
+ * 而一个永远挂在下面的「回复 ▸」会让人以为回复被自己折起来了。
1353
+ */
919
1354
  function renderReply() {
920
1355
  replyEl.replaceChildren();
921
- if (!state.reply) {
922
- replyEl.hidden = true;
923
- return;
924
- }
925
- replyEl.hidden = false;
926
- renderMessage(replyEl, state.reply, `reply:${state.replyFor ?? ''}`);
1356
+ const shown = Boolean(state.reply);
1357
+ paneEls.reply.box.hidden = !shown;
1358
+ if (!shown) return;
1359
+ renderMessage(replyEl, state.reply, `reply:${state.replyFor ?? ''}`, { clamp: false });
1360
+ setPaneMeta('reply', replyMeta(state.reply));
927
1361
  }
928
1362
 
929
1363
  // ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
@@ -1023,8 +1457,17 @@
1023
1457
  state.replyFor = null;
1024
1458
  renderReply();
1025
1459
  summaryEl.replaceChildren();
1460
+ // 排行与回复这两块在「这一轮还没产出」时**整块不出现**(不做空壳),
1461
+ // 但折叠态留着 —— 下一轮跑完它们按用户原来的布局回来
1462
+ paneEls.summary.box.hidden = true;
1463
+ setPaneMeta('summary', '');
1464
+ setPaneMeta('trace', '');
1026
1465
  usageEl.hidden = true;
1027
1466
  ttlEl.textContent = '';
1467
+ // 总账也是**上一轮**的账 ⇒ 与回复 / 排行一起清掉(收尾后由 open() 重新填)
1468
+ state.trace = null;
1469
+ ledgerEl.hidden = true;
1470
+ ledgerItemsEl.replaceChildren();
1028
1471
  const ph = document.createElement('div');
1029
1472
  ph.className = 'tr-empty';
1030
1473
  ph.textContent = '// 这一轮在飞 —— span 会一个个长出来(收尾时用整棵 trace 覆盖)';
@@ -1050,6 +1493,7 @@
1050
1493
  if (!playTrace(view, t)) return;
1051
1494
  ttlEl.textContent = `${t.traceId}(在飞)`;
1052
1495
  usageEl.hidden = false;
1496
+ setPaneMeta('trace', traceMeta(t.spans.length));
1053
1497
  });
1054
1498
  }
1055
1499
 
@@ -1124,26 +1568,74 @@
1124
1568
  if (!res.ok) return;
1125
1569
  const trace = await res.json();
1126
1570
  state.current = id;
1127
- ttlEl.textContent = trace.traceId;
1571
+ // 总账要的是 trace 本体(`state.current` 只有 id)—— 重跑 / 导出也靠它
1572
+ state.trace = trace;
1573
+ renderLedger(trace);
1574
+ /* 深链(2026-11-13):刷新页面回到**这一条**,而不是最新那条。
1575
+ 用 `replaceState` 不堆历史 —— 切 run 是同一页的不同视图,push 会让后退键
1576
+ 按十几次才离开面板(那不是用户对「后退」的预期)。 */
1577
+ try {
1578
+ const u = new URL(location.href);
1579
+ u.searchParams.set('run', id);
1580
+ history.replaceState(null, '', u);
1581
+ } catch {
1582
+ /* 老浏览器 / file://:没有深链不影响其它任何功能 */
1583
+ }
1584
+ // 标题带**收尾原因**(非正常收尾时)。打开一条历史 run 时最容易犯的误读是
1585
+ // 「树画完了 ⇒ 这一轮跑完成了」,而它可能是撞预算停的、被中止的、或正挂着等人。
1586
+ // 原因取自列表的 summary(`stop_reason` 已在那里解析过,别在页面里再挖 attributes)。
1587
+ const sum = state.list.find((x) => x.traceId === id);
1588
+ const cls = sum ? stopReasonClass(sum) : 'ok';
1589
+ ttlEl.textContent =
1590
+ sum && cls !== 'ok'
1591
+ ? `${trace.traceId} · ${stopReasonText(sum.stopReason)}`
1592
+ : trace.traceId;
1128
1593
  usageEl.hidden = false;
1129
1594
  playTrace(view, trace);
1130
- summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
1595
+ // 排行算一次、两处消费(表格 + 标题栏读数):标题说「3 项」而表里有 4 行是
1596
+ // 最难查的一类不一致 —— 同源就不会有这问题
1597
+ const rows = summarizeTrace(trace);
1598
+ summaryEl.innerHTML = renderSummary(rows);
1599
+ paneEls.summary.box.hidden = false;
1600
+ setPaneMeta('summary', summaryMeta(rows));
1601
+ setPaneMeta('trace', traceMeta(trace.spans ? trace.spans.length : 0));
1131
1602
  // 回复正文只对**刚跑完的那一轮**有意义(trace 上没有它 —— 它是 run-done 事件带的)
1132
1603
  // ⇒ 打开**别的** run 时必须清掉,否则它挂在别人的 trace 底下(张冠李戴)。
1133
1604
  //
1134
1605
  // ⚠️ 但不能**无条件**清:`run-done` 与「这条 trace 的收尾帧」几乎同毫秒到达,
1135
1606
  // 而这条 handler 的链更长(refresh → refreshSession → renderChat → open)——
1136
1607
  // 无条件清会把刚写上去的那句回复擦掉(端到端实测 17 ms,现象是「跑完了但没回复」)。
1608
+ //
1609
+ // 清完之后**立刻从 CLI 侧记账取回**:`finalText` 不在 trace 上,但它跟着 note 一起
1610
+ // 存着(`/api/runs/:id` 的顶层 `note`)。不取的话,切到别的 run 再切回来 ——
1611
+ // 甚至只是刷新一次页面 —— 那一轮的结论就永远看不到了。
1137
1612
  if (!replyBelongsTo(id, state.replyFor)) {
1138
- state.reply = '';
1139
- state.replyFor = null;
1613
+ const saved = trace.note && typeof trace.note.finalText === 'string' ? trace.note.finalText : '';
1614
+ state.reply = saved;
1615
+ state.replyFor = saved ? id : null;
1140
1616
  renderReply();
1141
1617
  }
1142
1618
  renderList();
1143
1619
  }
1144
1620
 
1145
- async function openLatest() {
1621
+ /**
1622
+ * 打开一条 run:优先 `?run=<id>`(深链 / 刷新),否则最新一条。
1623
+ *
1624
+ * 深链指向的那条**已不在内存 ring 里**时必须出声:静默回落最新那条会让用户以为
1625
+ * 「链接打开了,就是这条」—— 而他在看别的 run 的树(本仓最忌的张冠李戴)。
1626
+ */
1627
+ async function openLatest(want) {
1146
1628
  await refresh();
1629
+ if (want) {
1630
+ if (state.list.some((r) => r.traceId === want)) {
1631
+ await open(want);
1632
+ return;
1633
+ }
1634
+ setNotice(
1635
+ `打不开 ?run=${want}:它不在内存的 run 列表里(可能已被淘汰,或不是这个面板起的)。已回到最新一条`,
1636
+ true,
1637
+ );
1638
+ }
1147
1639
  if (state.list.length && !state.current) await open(state.list[0].traceId);
1148
1640
  }
1149
1641
 
@@ -1286,6 +1778,92 @@
1286
1778
  }
1287
1779
  });
1288
1780
 
1781
+ // ---------- 列表检索 / 树内检索 / 时间轴(2026-11-13) ----------
1782
+
1783
+ listQEl.addEventListener('input', () => {
1784
+ state.q = listQEl.value;
1785
+ renderList();
1786
+ });
1787
+ listQEl.addEventListener('keydown', (e) => {
1788
+ if (e.key === 'Escape') {
1789
+ listQEl.value = '';
1790
+ state.q = '';
1791
+ renderList();
1792
+ }
1793
+ });
1794
+ listAbnEl.addEventListener('click', () => {
1795
+ state.onlyAbnormal = !state.onlyAbnormal;
1796
+ listAbnEl.setAttribute('aria-pressed', state.onlyAbnormal ? 'true' : 'false');
1797
+ renderList();
1798
+ });
1799
+
1800
+ /**
1801
+ * 一个输入框同时作用于两棵树:并排时两栏必须**同一处**高亮 —— 否则「差异」里会混进
1802
+ * 「搜索状态不同」这个人为差异。判定与跳转都在 trace-view(`setQuery` / `nextHit`)。
1803
+ */
1804
+ function applyTreeQuery() {
1805
+ const n = view.setQuery(treeQEl.value);
1806
+ if (!trace2El.hidden) view2.setQuery(treeQEl.value);
1807
+ treeHitEl.textContent = treeQEl.value.trim() ? `${n} 处` : '';
1808
+ }
1809
+ treeQEl.addEventListener('input', applyTreeQuery);
1810
+ treeQEl.addEventListener('keydown', (e) => {
1811
+ if (e.key === 'Enter') {
1812
+ e.preventDefault();
1813
+ const total = view.setQuery(treeQEl.value);
1814
+ if (total === 0) {
1815
+ treeHitEl.textContent = treeQEl.value.trim() ? '无匹配' : '';
1816
+ return;
1817
+ }
1818
+ const i = view.nextHit(e.shiftKey ? -1 : 1);
1819
+ if (!trace2El.hidden) view2.nextHit(e.shiftKey ? -1 : 1);
1820
+ treeHitEl.textContent = `${i}/${total}`;
1821
+ } else if (e.key === 'Escape') {
1822
+ treeQEl.value = '';
1823
+ applyTreeQuery();
1824
+ }
1825
+ });
1826
+
1827
+ /**
1828
+ * 时间轴开关:**布局偏好**,与折叠态同一类(进 localStorage,切 run 不重置)。
1829
+ * 存不下就只活这一次会话 —— 不因为记不住而白屏(与 PANE_KEY 同一口径)。
1830
+ */
1831
+ const TL_KEY = 'agentia.timeline';
1832
+ let timelineOn = (() => {
1833
+ try {
1834
+ return localStorage.getItem(TL_KEY) !== '0';
1835
+ } catch {
1836
+ return true;
1837
+ }
1838
+ })();
1839
+ function applyTimeline(on) {
1840
+ timelineOn = on;
1841
+ tlToggleEl.setAttribute('aria-pressed', on ? 'true' : 'false');
1842
+ view.setTimeline(on);
1843
+ view2.setTimeline(on);
1844
+ }
1845
+ tlToggleEl.addEventListener('click', () => {
1846
+ applyTimeline(!timelineOn);
1847
+ try {
1848
+ localStorage.setItem(TL_KEY, timelineOn ? '1' : '0');
1849
+ } catch {
1850
+ /* 同上:这一次会话内仍然有效 */
1851
+ }
1852
+ });
1853
+ applyTimeline(timelineOn);
1854
+
1855
+ // `/` 聚焦列表搜索(不在输入框里时)。这是「迅速找回刚才那条」的入口,
1856
+ // 而列表是这个面板唯一有几十条内容的地方。
1857
+ document.addEventListener('keydown', (e) => {
1858
+ if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
1859
+ // `??`(不是 `||`):tagName 只可能缺省或非空,用 `||` 与 `??` 在这里同义 —— 但 `??`
1860
+ // 不会把将来的空字符串值也替换掉,意图更直白
1861
+ const tag = e.target?.tagName ?? '';
1862
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
1863
+ e.preventDefault();
1864
+ listQEl.focus();
1865
+ });
1866
+
1289
1867
  // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
1290
1868
  workdirEl.addEventListener('input', () => {
1291
1869
  state.workdir = workdirEl.value;
@@ -1486,7 +2064,8 @@
1486
2064
 
1487
2065
  await refreshDev();
1488
2066
  await refreshSession();
1489
- await openLatest();
2067
+ // 深链优先(`?run=<id>`):刷新 / 贴链接回到那条,而不是最新那条
2068
+ await openLatest(runIdFromSearch(location.search));
1490
2069
  renderChat();
1491
2070
  </script>
1492
2071
  </body>