@migor/cli 0.8.3 → 0.9.1

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.
@@ -14,6 +14,9 @@
14
14
  --border: #27272a;
15
15
  --bg: #0b0b0d;
16
16
  --card: #111113;
17
+ --pop: #17171a;
18
+ --warn: #e0af68;
19
+ --err: #f7768e;
17
20
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
18
21
  }
19
22
  * { box-sizing: border-box; }
@@ -31,29 +34,131 @@
31
34
  header .sub { color: var(--faint); font-size: 12px; }
32
35
  header .live { margin-left: auto; color: var(--faint); font-size: 12px; }
33
36
  header .live b { color: var(--ice); font-weight: 500; }
34
- main { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px; padding: 14px 20px; }
37
+
38
+ /* 告警条:dev 环的「能跑但有东西不对劲」全在这里,不静默降级 */
39
+ #banner {
40
+ margin: 0 20px; padding: 9px 12px; border: 1px solid var(--warn); border-radius: 6px;
41
+ background: #1c1a14; color: var(--warn); font-family: var(--mono); font-size: 12px;
42
+ white-space: pre-wrap; word-break: break-word;
43
+ }
44
+ #banner.err { border-color: var(--err); background: #1d1416; color: var(--err); }
45
+
46
+ main {
47
+ display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px;
48
+ padding: 14px 20px;
49
+ }
35
50
  aside, section {
36
51
  background: var(--card); border: 1px solid var(--border); border-radius: 6px;
37
52
  }
38
- aside { overflow: hidden auto; max-height: calc(100vh - 90px); }
53
+ aside { overflow: hidden auto; max-height: calc(100vh - 200px); }
39
54
  section { padding: 14px; min-height: 240px; font-family: var(--mono); font-size: 12px; overflow: auto; }
40
55
  .run {
41
56
  display: block; width: 100%; text-align: left; cursor: pointer;
42
57
  background: none; border: 0; border-bottom: 1px solid var(--border);
43
58
  color: var(--text); padding: 10px 12px; font: inherit;
44
59
  }
45
- .run:hover { background: #17171a; }
46
- .run.active { background: #17171a; box-shadow: inset 2px 0 0 var(--ice); }
60
+ .run:hover { background: var(--pop); }
61
+ .run.active { background: var(--pop); box-shadow: inset 2px 0 0 var(--ice); }
47
62
  .run .r1 { display: flex; align-items: center; gap: 6px; }
48
63
  .run .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ice); flex: none; }
49
- .run.err .dot { background: #f7768e; }
64
+ .run.err .dot { background: var(--err); }
65
+ /* 被中止的 run 不是失败:给它一个中性偏警告的颜色,不跟失败共用红色 ——
66
+ 否则用户会把「我自己按了中止」读成「我把工程改坏了」 */
67
+ .run.aborted .dot { background: var(--warn); }
50
68
  .run .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
- .run .r2 { margin-top: 3px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
69
+ .run .r2, .run .r3 { margin-top: 3px; color: var(--faint); font-size: 11px; font-family: var(--mono); }
70
+ .run .r3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
52
71
  .empty { color: var(--faint); padding: 16px; }
53
72
  #ttl { color: var(--faint); margin-bottom: 10px; font-family: var(--mono); font-size: 11.5px; }
54
73
  .usage { display: flex; gap: 16px; margin-top: 12px; color: var(--muted); font-family: var(--mono); font-size: 12px; }
55
74
  /* biome-ignore lint/style/noDescendingSpecificity: 本行只颜色/字重,对手是 33 行的 header .live b —— 两者祖先容器不同(.usage 在面板区、.live 在 header),结构上不可能命中同一元素,是规则「只比末段标签」的误报。 */
56
75
  .usage b { color: var(--text); font-weight: 500; }
76
+
77
+ /* 页签:对话视图按需出现(多轮开 + 有会话文件),不做空壳 */
78
+ .tabs { display: flex; border-bottom: 1px solid var(--border); }
79
+ .tab {
80
+ flex: 1; background: none; border: 0; border-bottom: 2px solid transparent;
81
+ color: var(--faint); padding: 9px 6px; font: inherit; font-size: 12px; cursor: pointer;
82
+ }
83
+ .tab.active { color: var(--text); border-bottom-color: var(--ice); }
84
+
85
+ /* 对话流 */
86
+ .turn { padding: 10px 12px; border-bottom: 1px solid var(--border); }
87
+ .turn .who { color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; }
88
+ .turn .msg { margin-top: 3px; white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 11.5px; }
89
+ .turn.failed { border-left: 2px solid var(--err); }
90
+ .turn.failed .msg { color: var(--err); }
91
+ /* 中止轮:左边线用警告色而不是错误色(它不是失败,只是没跑完) */
92
+ .turn.aborted { border-left: 2px solid var(--warn); }
93
+ .turn.aborted .msg { color: var(--muted); }
94
+
95
+ /* 清空对话条:只在对话视图出现时显示(不做空壳) */
96
+ #chat-bar {
97
+ display: flex; align-items: center; gap: 8px;
98
+ padding: 7px 12px; border-bottom: 1px solid var(--border);
99
+ }
100
+ #chat-bar[hidden] { display: none; }
101
+ #chat-sid { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
102
+
103
+ #reply {
104
+ margin: 14px 0 0; padding: 10px; border: 1px solid var(--border); border-radius: 4px;
105
+ background: #0e0e10; white-space: pre-wrap; word-break: break-word; color: var(--muted);
106
+ }
107
+ #reply[hidden] { display: none; }
108
+
109
+ /* 输入条:固定在底部、横跨两栏、分两行(四个控件挤一行会太窄) */
110
+ footer {
111
+ position: sticky; bottom: 0; margin-top: 4px;
112
+ border-top: 1px solid var(--border); background: var(--bg);
113
+ padding: 10px 20px 14px; display: grid; gap: 8px;
114
+ }
115
+ footer[hidden] { display: none; }
116
+ .row { display: flex; gap: 8px; align-items: center; }
117
+ .ctl {
118
+ background: var(--card); border: 1px solid var(--border); border-radius: 5px;
119
+ color: var(--text); padding: 7px 10px; font: inherit; font-size: 12px;
120
+ }
121
+ .ctl:focus { outline: 1px solid var(--ice); border-color: var(--ice); }
122
+ .ctl:disabled { opacity: .55; cursor: default; }
123
+ /* 中止:唯一带破坏性的按钮,用告警色区分(只在有在飞 run 时出现) */
124
+ #abort { color: var(--err); border-color: var(--err); }
125
+ #abort[hidden] { display: none; }
126
+ #caps-sum { cursor: pointer; list-style: none; user-select: none; font-family: var(--mono); }
127
+ #caps-sum::-webkit-details-marker { display: none; }
128
+ details { position: relative; }
129
+ .pop {
130
+ position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 10;
131
+ min-width: 260px; max-height: 320px; overflow: auto;
132
+ background: var(--pop); border: 1px solid var(--border); border-radius: 6px;
133
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .5); padding: 8px;
134
+ }
135
+ .pop[hidden] { display: none; }
136
+ .cap {
137
+ display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 4px;
138
+ cursor: pointer; font-family: var(--mono); font-size: 12px;
139
+ }
140
+ .cap:hover { background: #202024; }
141
+ #workdir { flex: 1; min-width: 0; font-family: var(--mono); }
142
+ #prompt { flex: 1; min-width: 0; }
143
+ #run {
144
+ background: var(--ice); border-color: var(--ice); color: #04222a;
145
+ font-weight: 600; min-width: 88px;
146
+ }
147
+ #run:disabled { opacity: .45; cursor: not-allowed; }
148
+ .mt { display: flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; }
149
+ .hint { color: var(--faint); font-size: 11px; }
150
+ .fs-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 8px; font-family: var(--mono); font-size: 11px; }
151
+ .fs-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); }
152
+ .fs-item { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--text); padding: 4px 6px; border-radius: 4px; font: inherit; font-family: var(--mono); font-size: 12px; cursor: pointer; }
153
+ .fs-item:hover { background: #202024; }
154
+ /* 隐藏目录:可达但不可眼(旧实现直接过滤掉,于是 .y 这类目录根本点不进去) */
155
+ .fs-dot { color: var(--faint); }
156
+ /* 文件行:点它 = 选它所在的目录(不是选文件本身当工作目录 —— 工作目录只能是目录) */
157
+ .fs-file { color: var(--text); }
158
+ .fs-sep { padding: 8px 6px 2px; margin-top: 6px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
159
+ .fs-foot { padding-top: 8px; border-top: 1px solid var(--border); margin-top: 6px; }
160
+ .mini { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 5px 9px; font: inherit; font-size: 11.5px; cursor: pointer; }
161
+ .mini:hover { border-color: var(--ice); }
57
162
  </style>
58
163
  </head>
59
164
  <body>
@@ -62,13 +167,31 @@
62
167
  <span class="sub">每次 run 的调用树与能力执行</span>
63
168
  <span class="live" id="live">等待 run…</span>
64
169
  </header>
170
+ <div id="banner" hidden></div>
65
171
  <main>
66
- <aside id="runs"><div class="empty">还没有 run。触发一次 agent 调用即可。</div></aside>
172
+ <aside>
173
+ <div class="tabs" id="tabs" hidden>
174
+ <button class="tab active" id="tab-runs" type="button">run 列表</button>
175
+ <button class="tab" id="tab-chat" type="button">对话</button>
176
+ </div>
177
+ <div id="runs"><div class="empty">还没有 run。触发一次 agent 调用即可。</div></div>
178
+ <!--
179
+ 清空对话(§6 待定 3):清空 = **换一个 sessionId**(不动 session.json ——
180
+ 那是 store 的账,面板对它只读)。所以旧对话还在盘上、run 列表里也还看得到,
181
+ 只是模型不再带着它跑了。只在对话视图出现时才显示(不做空壳)。
182
+ -->
183
+ <div id="chat-bar" hidden>
184
+ <span id="chat-sid" class="hint"></span>
185
+ <button class="mini" id="chat-clear" type="button">清空对话</button>
186
+ </div>
187
+ <div id="chat" hidden></div>
188
+ </aside>
67
189
  <section>
68
190
  <div id="ttl"></div>
69
191
  <div id="trace"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
70
192
  <!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
71
193
  <div id="summary"></div>
194
+ <pre id="reply" hidden></pre>
72
195
  <div class="usage" id="usage" hidden>
73
196
  <span>in <b id="u-in">0</b></span>
74
197
  <span>out <b id="u-out">0</b></span>
@@ -76,22 +199,133 @@
76
199
  </section>
77
200
  </main>
78
201
 
202
+ <!--
203
+ 输入条(D7):固定在底部、横跨两栏、分两行。
204
+ 第一行 = 这次 run 的配置(能力多选 / 工作目录 / 多轮开关);第二行 = 主操作(prompt + 运行)。
205
+ 只在 dev 环在场时出现(GET /api/dev 的 available)—— 不做空壳。
206
+ -->
207
+ <footer id="bar" hidden>
208
+ <div class="row">
209
+ <details id="caps">
210
+ <summary class="ctl" id="caps-sum">能力</summary>
211
+ <div class="pop" id="caps-pop"></div>
212
+ </details>
213
+ <input id="workdir" class="ctl" type="text" spellcheck="false" placeholder="工作目录(绝对路径)" />
214
+ <button id="browse" class="ctl mini" type="button">浏览…</button>
215
+ <label class="mt ctl" title="多轮的初始值 = 所选能力声明的 OR;可在这里覆盖">
216
+ <input type="checkbox" id="mt" /> <span id="mt-label">单轮</span>
217
+ </label>
218
+ </div>
219
+ <div class="row">
220
+ <input id="prompt" class="ctl" type="text" spellcheck="false" placeholder="输入 prompt,回车运行(↑/↓ 调回历史)" />
221
+ <button id="run" class="ctl" type="button">▶ 运行</button>
222
+ <!--
223
+ 中止(§6 待定 5 的 kill 按钮):只在有在飞 run 时出现。
224
+ 没有它的话,一个卡住的 run 会让「运行」永远 409 —— 面板锁死。
225
+ -->
226
+ <button id="abort" class="ctl" type="button" hidden>■ 中止</button>
227
+ </div>
228
+ <div class="hint" id="bar-hint"></div>
229
+ <div class="pop" id="fs" hidden>
230
+ <div class="fs-head">
231
+ <button class="mini" id="fs-up" type="button">..</button>
232
+ <span id="fs-path"></span>
233
+ </div>
234
+ <div id="fs-list"></div>
235
+ <div class="fs-foot">
236
+ <button class="mini" id="fs-use" type="button">选这个目录</button>
237
+ <span class="hint"> 点文件名 = 选它所在的目录(prompt 为空时填文件名)</span>
238
+ </div>
239
+ </div>
240
+ </footer>
241
+
79
242
  <script type="module">
80
243
  import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
244
+ import {
245
+ HISTORY_DRAFT,
246
+ applyTraceEvent,
247
+ browseTarget,
248
+ capabilityBadge,
249
+ emptyTraceAccumulator,
250
+ historyStep,
251
+ historyText,
252
+ mergeConversation,
253
+ multiTurnDefault,
254
+ multiTurnLabel,
255
+ normalizeToolSources,
256
+ partialTrace,
257
+ promptAfterFilePick,
258
+ pushPromptHistory,
259
+ replyBelongsTo,
260
+ runDoneNotice,
261
+ runIsFailure,
262
+ shortenPath,
263
+ } from './panel-logic.js';
264
+
265
+ const $ = (id) => document.getElementById(id);
266
+ const runsEl = $('runs');
267
+ const chatEl = $('chat');
268
+ const chatBarEl = $('chat-bar');
269
+ const chatSidEl = $('chat-sid');
270
+ const chatClearEl = $('chat-clear');
271
+ const traceEl = $('trace');
272
+ const summaryEl = $('summary');
273
+ const ttlEl = $('ttl');
274
+ const liveEl = $('live');
275
+ const usageEl = $('usage');
276
+ const replyEl = $('reply');
277
+ const bannerEl = $('banner');
278
+ const barEl = $('bar');
279
+ const tabsEl = $('tabs');
280
+ const capsEl = $('caps');
281
+ const capsSumEl = $('caps-sum');
282
+ const capsPopEl = $('caps-pop');
283
+ const workdirEl = $('workdir');
284
+ const mtEl = $('mt');
285
+ const mtLabelEl = $('mt-label');
286
+ const promptEl = $('prompt');
287
+ const runEl = $('run');
288
+ const abortEl = $('abort');
289
+ const hintEl = $('bar-hint');
290
+ const fsEl = $('fs');
291
+ const fsListEl = $('fs-list');
292
+ const fsPathEl = $('fs-path');
81
293
 
82
- const runsEl = document.getElementById('runs');
83
- const traceEl = document.getElementById('trace');
84
- const summaryEl = document.getElementById('summary');
85
- const ttlEl = document.getElementById('ttl');
86
- const liveEl = document.getElementById('live');
87
- const usageEl = document.getElementById('usage');
88
294
  const view = createTraceView(traceEl, {
89
295
  // 无公开单价 → 不估算成本;只展示 token
90
- usage: { in: document.getElementById('u-in'), out: document.getElementById('u-out') },
296
+ usage: { in: $('u-in'), out: $('u-out') },
91
297
  });
92
298
 
93
- let current = null;
94
- let list = [];
299
+ // ---------- 状态 ----------
300
+ // 只放「渲染要用到的东西」;所有判断都走 panel-logic(那份有单测,这份没有)
301
+ const state = {
302
+ dev: null, // GET /api/dev 的结果;null = 还没拿到
303
+ sessionId: '', // 当前会话 id(来自 dev.sessionId)—— **不写死**:清空对话会换它
304
+ selected: [], // 选中的能力 token
305
+ multiTurn: null, // 用户显式覆盖后的值;null = 跟随默认
306
+ workdir: '',
307
+ history: [],
308
+ cursor: HISTORY_DRAFT,
309
+ draft: '',
310
+ session: null, // { messages } | null
311
+ tab: 'runs',
312
+ list: [],
313
+ current: null,
314
+ reply: '',
315
+ /**
316
+ * 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
317
+ * 「现在打开的就是这条回复的主人」⇒ 不清。见 panel-logic 的 `replyBelongsTo`。
318
+ */
319
+ replyFor: null,
320
+ /**
321
+ * 在飞 run 的增量 trace 累加器(null = 没有在飞的 run)。
322
+ * 收到 `trace-event` 就折回并重画;收尾那份整棵 trace 回来时把它清掉
323
+ * (**收尾那份永远覆盖在飞的这份**,缺的 span 由它补齐)。
324
+ */
325
+ live: null,
326
+ notice: '', // 面板自己发的操作反馈(已接受 / 重启了 runner / 出错)
327
+ noticeErr: false,
328
+ };
95
329
 
96
330
  const fmtMs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : ms + 'ms');
97
331
  const fmtNum = (n) => Number(n || 0).toLocaleString('en-US');
@@ -99,50 +333,553 @@
99
333
  const s = Math.max(0, Math.round((Date.now() - t) / 1000));
100
334
  return s < 60 ? s + 's 前' : Math.round(s / 60) + 'm 前';
101
335
  };
336
+ const short = (p) => shortenPath(p, { home: state.dev?.home ?? '', cwd: state.dev?.projectRoot ?? '' });
337
+
338
+ // ---------- 告警条 ----------
339
+ // dev 环的 warning / lastError、以及面板自己的操作反馈,都在这里显示 ——
340
+ // 一律 textContent(内容含用户路径与模型输出)
341
+ function renderBanner() {
342
+ const dev = state.dev;
343
+ const parts = [];
344
+ if (state.notice) parts.push(state.notice);
345
+ if (dev?.warning) parts.push('⚠ ' + dev.warning);
346
+ if (dev?.lastError) parts.push('✖ ' + dev.lastError);
347
+ if (parts.length === 0) {
348
+ bannerEl.hidden = true;
349
+ bannerEl.textContent = '';
350
+ return;
351
+ }
352
+ bannerEl.hidden = false;
353
+ bannerEl.textContent = parts.join('\n');
354
+ bannerEl.className = state.noticeErr ? 'err' : '';
355
+ }
356
+
357
+ // ---------- 输入条 ----------
358
+ function renderControls() {
359
+ const dev = state.dev;
360
+ if (!dev?.available) {
361
+ barEl.hidden = true;
362
+ return;
363
+ }
364
+ barEl.hidden = false;
365
+
366
+ // 能力多选(默认全选)。全选时 badge 显示 N/N —— 一眼看出这次是全量还是收窄
367
+ const all = dev.capabilities;
368
+ if (state.selected.length === 0 && all.length > 0) state.selected = [...all];
369
+ capsSumEl.textContent = `能力 ${capabilityBadge(state.selected, all)} ▾`;
370
+ capsPopEl.replaceChildren();
371
+ if (all.length === 0) {
372
+ const d = document.createElement('div');
373
+ d.className = 'hint';
374
+ d.textContent = '没发现能力(app.ts 是否导出了 CAPABILITY_DIRS?)';
375
+ capsPopEl.appendChild(d);
376
+ }
377
+ for (const name of all) {
378
+ const label = document.createElement('label');
379
+ label.className = 'cap';
380
+ const box = document.createElement('input');
381
+ box.type = 'checkbox';
382
+ box.checked = state.selected.includes(name);
383
+ box.addEventListener('change', () => {
384
+ const next = box.checked
385
+ ? [...state.selected, name]
386
+ : state.selected.filter((x) => x !== name);
387
+ // 不许取消到空:空集与「全量」在语义上打架(normalizeToolSources 会兜成全量,
388
+ // 但那样勾选态与实发参数就不一致了)—— 在 UI 层就挡住
389
+ if (next.length === 0) {
390
+ box.checked = true;
391
+ setNotice('至少留一个能力(全不选与全选同义,直接用全选)', true);
392
+ return;
393
+ }
394
+ state.selected = next;
395
+ state.multiTurn = null; // 选择变了 ⇒ 回到默认位(默认值本身跟着选择走)
396
+ renderControls();
397
+ renderBanner();
398
+ });
399
+ const text = document.createElement('span');
400
+ text.textContent = name;
401
+ const star = document.createElement('span');
402
+ star.className = 'hint';
403
+ star.textContent = dev.multiTurn.includes(name) ? '多轮' : '';
404
+ label.append(box, text, star);
405
+ capsPopEl.appendChild(label);
406
+ }
407
+
408
+ // 多轮开关:初始值 = 所选能力声明的 OR,**并把来源标出来**(混选时默认有歧义)
409
+ const def = multiTurnDefault(state.selected, dev.multiTurn);
410
+ const value = state.multiTurn ?? def.value;
411
+ mtEl.checked = value;
412
+ mtLabelEl.textContent = multiTurnLabel(value, def.sources, state.multiTurn === null);
413
+
414
+ // 工作目录:默认值来自 dev.config / 项目根;这里是 per-run 的输入
415
+ if (workdirEl.value === '') workdirEl.value = dev.defaultWorkdir;
416
+ state.workdir = workdirEl.value;
417
+ runEl.disabled = dev.running;
418
+ runEl.textContent = dev.running ? '运行中…' : '▶ 运行';
419
+ // 中止(§6 待定 5 的 kill 按钮):只在有在飞 run 时出现。
420
+ // 没有它,一个卡住的 run 会让「运行」永远 409 —— 面板锁死(这正是它存在的理由)。
421
+ abortEl.hidden = !dev.running;
422
+ abortEl.disabled = !dev.running;
423
+ }
424
+
425
+ function setNotice(text, isError) {
426
+ state.notice = text;
427
+ state.noticeErr = !!isError;
428
+ renderBanner();
429
+ }
102
430
 
431
+ function readControls() {
432
+ const dev = state.dev;
433
+ const all = dev ? dev.capabilities : [];
434
+ return {
435
+ prompt: promptEl.value.trim(),
436
+ workdir: workdirEl.value.trim(),
437
+ toolSources: normalizeToolSources(state.selected, all),
438
+ multiTurn: state.multiTurn ?? multiTurnDefault(state.selected, dev ? dev.multiTurn : []).value,
439
+ };
440
+ }
441
+
442
+ // ---------- run 列表 ----------
103
443
  function renderList() {
104
- if (!list.length) {
105
- runsEl.innerHTML = '<div class="empty">还没有 run。触发一次 agent 调用即可。</div>';
444
+ if (!state.list.length) {
445
+ runsEl.replaceChildren();
446
+ const d = document.createElement('div');
447
+ d.className = 'empty';
448
+ d.textContent = '还没有 run。触发一次 agent 调用即可。';
449
+ runsEl.appendChild(d);
106
450
  return;
107
451
  }
108
- runsEl.innerHTML = '';
109
- for (const r of list) {
452
+ runsEl.replaceChildren();
453
+ for (const r of state.list) {
110
454
  const b = document.createElement('button');
111
- b.className = 'run' + (r.ok ? '' : ' err') + (r.traceId === current ? ' active' : '');
112
- b.innerHTML =
113
- `<span class="r1"><span class="dot"></span><span class="name"></span></span>` +
114
- `<span class="r2">${fmtMs(r.ms)} · ${fmtNum(r.tokens)} tok · ${ago(r.startedAt)}</span>`;
115
- b.querySelector('.name').textContent = r.name;
455
+ // 三态:失败 / **被中止** / 正常。中止不是失败(判别走 panel-logic 的
456
+ // runIsFailure,与通知条同一条语义)—— 给中止的 run 标红会让用户以为
457
+ // 是自己把工程改坏了,而那正是「aborted 也带 error」这条引擎约定造成的误读。
458
+ const tone = runIsFailure(r) ? ' err' : r.stopReason === 'aborted' ? ' aborted' : '';
459
+ b.className = 'run' + tone + (r.traceId === state.current ? ' active' : '');
460
+ b.type = 'button';
461
+
462
+ const r1 = document.createElement('span');
463
+ r1.className = 'r1';
464
+ const dot = document.createElement('span');
465
+ dot.className = 'dot';
466
+ const name = document.createElement('span');
467
+ name.className = 'name';
468
+ name.textContent = r.name; // 模型可控内容 ⇒ 只能 textContent
469
+ r1.append(dot, name);
470
+
471
+ const r2 = document.createElement('span');
472
+ r2.className = 'r2';
473
+ r2.textContent = `${fmtMs(r.ms)} · ${fmtNum(r.tokens)} tok · ${ago(r.startedAt)}`;
474
+
475
+ b.append(r1, r2);
476
+
477
+ // 第三行 = CLI 侧记账(目标目录 + 能力组合)。不是 dev 起的 run 没有这一行
478
+ if (r.note) {
479
+ const r3 = document.createElement('span');
480
+ r3.className = 'r3';
481
+ const caps = r.note.toolSources === null ? '全量' : r.note.toolSources.join(',');
482
+ r3.textContent = `${short(r.note.workdir)} · ${caps}${r.note.multiTurn ? ' · 多轮' : ''}`;
483
+ b.appendChild(r3);
484
+ }
116
485
  b.addEventListener('click', () => open(r.traceId));
117
486
  runsEl.appendChild(b);
118
487
  }
119
488
  }
120
489
 
490
+ // ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
491
+ function renderChat() {
492
+ const dev = state.dev;
493
+ const def = multiTurnDefault(state.selected, dev ? dev.multiTurn : []);
494
+ const value = state.multiTurn ?? def.value;
495
+ // 三条都要满足才显示:多轮开着、会话文件读得到、dev 环在场 —— 不做空壳
496
+ const show = !!dev && dev.available && value && state.session !== null;
497
+ tabsEl.hidden = !show;
498
+ // 清空对话条与对话视图同生共死(它清的就是这份对话)——
499
+ // **还要跟页签走**:它长在对话视图上,切回 run 列表时不该留在那儿
500
+ chatBarEl.hidden = !(show && state.tab === 'chat');
501
+ if (!show) {
502
+ chatEl.hidden = true;
503
+ return;
504
+ }
505
+ chatSidEl.textContent = `会话 ${state.sessionId}`;
506
+ // 清空会换 id、而 run 在飞时后端会 409(run 结束要写回当前会话)—— 在 UI 层先挡住
507
+ chatClearEl.disabled = dev.running;
508
+ // 会话 id 从 dev 环取,**不写死**:清空对话换的就是它(写死 = 「同一事实两个写法」)
509
+ const turns = mergeConversation(state.session.messages ?? [], state.list, state.sessionId);
510
+ chatEl.replaceChildren();
511
+ if (turns.length === 0) {
512
+ const d = document.createElement('div');
513
+ d.className = 'empty';
514
+ d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
515
+ chatEl.appendChild(d);
516
+ }
517
+ for (const t of turns) {
518
+ const box = document.createElement('div');
519
+ box.className = 'turn' + (t.failed ? ' failed' : t.aborted ? ' aborted' : '');
520
+ const who = document.createElement('div');
521
+ who.className = 'who';
522
+ who.textContent = 'user';
523
+ const u = document.createElement('div');
524
+ u.className = 'msg';
525
+ u.textContent = t.user;
526
+ box.append(who, u);
527
+ if (t.assistant !== null) {
528
+ const who2 = document.createElement('div');
529
+ who2.className = 'who';
530
+ who2.textContent = 'assistant';
531
+ const a = document.createElement('div');
532
+ a.className = 'msg';
533
+ a.textContent = t.assistant;
534
+ box.append(who2, a);
535
+ } else if (t.failed) {
536
+ // 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
537
+ const f = document.createElement('div');
538
+ f.className = 'msg';
539
+ f.textContent = '(这一轮失败了 —— 会话文件里没有它的回复)';
540
+ box.appendChild(f);
541
+ } else if (t.aborted) {
542
+ // 中止轮同样没回写会话(框架只在成功路径回写)⇒ 也靠 join 出来,
543
+ // 但它不是失败:文案与左边线都要与失败区分开
544
+ const f = document.createElement('div');
545
+ f.className = 'msg';
546
+ f.textContent = '(这一轮被中止了 —— 没有回复写回会话)';
547
+ box.appendChild(f);
548
+ }
549
+ chatEl.appendChild(box);
550
+ }
551
+ chatEl.hidden = state.tab !== 'chat';
552
+ }
553
+
554
+ function selectTab(tab) {
555
+ state.tab = tab;
556
+ $('tab-runs').className = 'tab' + (tab === 'runs' ? ' active' : '');
557
+ $('tab-chat').className = 'tab' + (tab === 'chat' ? ' active' : '');
558
+ runsEl.hidden = tab !== 'runs';
559
+ chatEl.hidden = tab !== 'chat';
560
+ // 对话视图上的那条「清空对话」也跟着页签显隐 —— 它的可见性在 renderChat 里判
561
+ renderChat();
562
+ }
563
+
564
+ // ---------- 在飞 run 的实时右栏(①) ----------
565
+
566
+ /**
567
+ * run 开跑:把右栏切成「这一轮的空树」。
568
+ *
569
+ * 为什么必须在 run-start 就切(而不是等第一条 span):不切的话右栏留着**上一轮**那棵树,
570
+ * 一次十几秒的 run 期间它看起来就是「这次的结果」。同理清掉上一轮的回复 / 排行 / 用量 ——
571
+ * 它们都是**上一轮**的账,与在飞的这一轮无关(历史 run 在左栏列表里随时能点回来)。
572
+ */
573
+ function beginLiveRender() {
574
+ state.live = emptyTraceAccumulator();
575
+ state.reply = '';
576
+ state.replyFor = null;
577
+ replyEl.hidden = true;
578
+ replyEl.textContent = '';
579
+ summaryEl.replaceChildren();
580
+ usageEl.hidden = true;
581
+ ttlEl.textContent = '';
582
+ const ph = document.createElement('div');
583
+ ph.className = 'tr-empty';
584
+ ph.textContent = '// 这一轮在飞 —— span 会一个个长出来(收尾时用整棵 trace 覆盖)';
585
+ traceEl.replaceChildren(ph);
586
+ }
587
+
588
+ /**
589
+ * 增量事件 → 折回 → 重画整棵树。
590
+ *
591
+ * `playTrace` 是**幂等全量重画**(自己拆好树再画),所以这里不需要「局部更新」那套:
592
+ * 每次把当前已知的 spans 交回去就行。用 rAF 合帧是必要的 —— 一次 run 可能有几百条
593
+ * 记账事件,逐条重绘是白烧 CPU(而人眼只看得见一帧)。
594
+ */
595
+ let liveFrame = 0;
596
+ function renderLive() {
597
+ if (liveFrame) return;
598
+ liveFrame = requestAnimationFrame(() => {
599
+ liveFrame = 0;
600
+ if (!state.live) return;
601
+ const t = partialTrace(state.live);
602
+ // 空树不交给 playTrace(它对空 spans 直接返回 false,右栏会留着占位那行 —— 那正是我们要的)
603
+ if (t.spans.length === 0) return;
604
+ if (!playTrace(view, t)) return;
605
+ ttlEl.textContent = `${t.traceId}(在飞)`;
606
+ usageEl.hidden = false;
607
+ });
608
+ }
609
+
610
+ // ---------- 拉数据 ----------
611
+ async function refreshDev() {
612
+ try {
613
+ const res = await fetch('/api/dev');
614
+ state.dev = res.ok ? await res.json() : null;
615
+ } catch {
616
+ state.dev = null;
617
+ }
618
+ // 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
619
+ state.sessionId = state.dev?.sessionId ?? '';
620
+ // 页面**在 run 在飞时**才加载(刷新 / 新开标签)⇒ 补一个累加器,好接住后续的增量帧。
621
+ // 之前那些 span 收不到(SSE 不重放)—— 它们会随收尾那份整棵 trace 一起补齐。
622
+ if (state.dev?.running && !state.live) state.live = emptyTraceAccumulator();
623
+ renderControls();
624
+ renderChat();
625
+ renderBanner();
626
+ }
627
+
628
+ async function refreshSession() {
629
+ if (!state.dev?.available) return;
630
+ try {
631
+ const res = await fetch('/api/session');
632
+ const body = res.ok ? await res.json() : { session: null };
633
+ state.session = body.session ?? null;
634
+ } catch {
635
+ state.session = null;
636
+ }
637
+ }
638
+
121
639
  async function refresh() {
122
- const res = await fetch('/api/runs');
123
- list = await res.json();
124
- renderList();
640
+ // ⚠️ 必须查 `res.ok`:token 过期 / 403 时 body 是 `{ error: … }`,而
641
+ // `renderList` 对非数组只走「`!state.list.length`」那一支 ⇒ 会显示误导性的
642
+ // 空态「还没有 run」。**一个错误的空态比一句错误更难发现**(用户以为只是没跑过)。
643
+ try {
644
+ const res = await fetch('/api/runs');
645
+ if (!res.ok) {
646
+ const body = await res.json().catch(() => ({}));
647
+ setNotice(`读 run 列表失败:${body.error || res.status}`, true);
648
+ return;
649
+ }
650
+ state.list = await res.json();
651
+ renderList();
652
+ } catch (e) {
653
+ setNotice(`读 run 列表失败:${e.message}`, true);
654
+ }
125
655
  }
126
656
 
127
657
  async function open(id) {
128
658
  const res = await fetch('/api/runs/' + encodeURIComponent(id));
129
659
  if (!res.ok) return;
130
660
  const trace = await res.json();
131
- current = id;
661
+ state.current = id;
132
662
  ttlEl.textContent = trace.traceId;
133
663
  usageEl.hidden = false;
134
664
  playTrace(view, trace);
135
665
  summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
666
+ // 回复正文只对**刚跑完的那一轮**有意义(trace 上没有它 —— 它是 run-done 事件带的)
667
+ // ⇒ 打开**别的** run 时必须清掉,否则它挂在别人的 trace 底下(张冠李戴)。
668
+ //
669
+ // ⚠️ 但不能**无条件**清:`run-done` 与「这条 trace 的收尾帧」几乎同毫秒到达,
670
+ // 而这条 handler 的链更长(refresh → refreshSession → renderChat → open)——
671
+ // 无条件清会把刚写上去的那句回复擦掉(端到端实测 17 ms,现象是「跑完了但没回复」)。
672
+ if (!replyBelongsTo(id, state.replyFor)) {
673
+ state.reply = '';
674
+ state.replyFor = null;
675
+ replyEl.hidden = true;
676
+ replyEl.textContent = '';
677
+ }
136
678
  renderList();
137
679
  }
138
680
 
139
- // 打开最新一条(首屏/新 run 到达时)
140
681
  async function openLatest() {
141
682
  await refresh();
142
- if (list.length && !current) await open(list[0].traceId);
683
+ if (state.list.length && !state.current) await open(state.list[0].traceId);
684
+ }
685
+
686
+ // ---------- 目录浏览 / 选文件(③) ----------
687
+ // 浏览器拿不到用户选的文件夹的绝对路径(webkitdirectory 只给相对路径、
688
+ // File System Access 只给 handle.name),而 dev 环要的是绝对路径 ⇒ 服务端列目录。
689
+ //
690
+ // 三个控件语义(2026-09-22 复核后定,前两条是实测出来的缺口):
691
+ // - 点**目录名** ⇒ 进去;点 `..` ⇒ 上一层;「选这个目录」⇒ 把当前位置当工作目录。
692
+ // - 点**文件名** ⇒ 工作目录 = 它**所在的**目录(就是当前这一层),prompt 为空时填文件名。
693
+ // 只列目录的旧形态下,「让 agent 看某个文件」只能靠用户自己把名字打进 prompt。
694
+ // - 隐藏目录(`.git` / `~/x/.y`)单列在正常目录之后 —— 旧实现整批过滤掉,
695
+ // 于是那些目录**根本点不进去**(`..` 只能退到它上面,进不去)。
696
+ let fsPath = '';
697
+ function closeFs() {
698
+ fsEl.hidden = true;
699
+ }
700
+ async function openFs(at) {
701
+ const res = await fetch('/api/fs?path=' + encodeURIComponent(at));
702
+ if (!res.ok) {
703
+ const body = await res.json().catch(() => ({ error: '读目录失败' }));
704
+ setNotice(body.error || '读目录失败', true);
705
+ return;
706
+ }
707
+ const body = await res.json();
708
+ fsPath = body.path;
709
+ fsPathEl.textContent = body.path;
710
+ fsListEl.replaceChildren();
711
+
712
+ const row = (text, cls, title, onClick) => {
713
+ const b = document.createElement('button');
714
+ b.type = 'button';
715
+ b.className = cls;
716
+ b.textContent = text;
717
+ if (title) b.title = title;
718
+ b.addEventListener('click', onClick);
719
+ fsListEl.appendChild(b);
720
+ };
721
+ const enterDir = (name) => () => void openFs(`${fsPath}/${name}`);
722
+ /** 选一个文件:工作目录取**当前这一层**(它所在的目录),prompt 只在空时被填 */
723
+ const useFile = (name) => () => {
724
+ workdirEl.value = fsPath;
725
+ state.workdir = fsPath;
726
+ const picked = promptAfterFilePick(promptEl.value, name);
727
+ if (picked.filled) promptEl.value = picked.prompt;
728
+ closeFs();
729
+ setNotice(
730
+ picked.filled
731
+ ? `工作目录:${short(fsPath)}(prompt 已填「${name}」)`
732
+ : `工作目录:${short(fsPath)}(prompt 里已有内容,一个字没动)`,
733
+ false,
734
+ );
735
+ };
736
+
737
+ for (const name of body.dirs) row(name + '/', 'fs-item', '', enterDir(name));
738
+ for (const name of body.dotDirs ?? []) {
739
+ row(name + '/', 'fs-item fs-dot', '隐藏目录', enterDir(name));
740
+ }
741
+ const files = body.files ?? [];
742
+ if (files.length > 0) {
743
+ const sep = document.createElement('div');
744
+ sep.className = 'fs-sep';
745
+ sep.textContent = '文件';
746
+ fsListEl.appendChild(sep);
747
+ for (const name of files) {
748
+ row(name, 'fs-item fs-file', '选这个文件:工作目录设成它所在的目录,prompt 为空时填文件名', useFile(name));
749
+ }
750
+ }
751
+ if (body.filesTruncated) {
752
+ const more = document.createElement('div');
753
+ more.className = 'hint';
754
+ more.textContent = `(文件只列了前 ${files.length} 个)`;
755
+ fsListEl.appendChild(more);
756
+ }
757
+ fsEl.hidden = false;
758
+ fsEl.dataset.parent = body.parent;
759
+ }
760
+ $('fs-up').addEventListener('click', () => void openFs(fsEl.dataset.parent || fsPath));
761
+ $('fs-use').addEventListener('click', () => {
762
+ workdirEl.value = fsPath;
763
+ state.workdir = fsPath;
764
+ closeFs();
765
+ setNotice(`工作目录:${short(fsPath)}`, false);
766
+ });
767
+ // ⚠️ 这里**不是**开关:旧实现「面板开着时再点一次把它关掉」,于是
768
+ // 「在输入框敲了目标路径 → 点浏览…」这个最自然的动作是把面板关掉,
769
+ // 用户就以为「选不了别的目录」。现在点它**总是**按当前输入打开。
770
+ $('browse').addEventListener('click', () => {
771
+ void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
772
+ });
773
+
774
+ // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
775
+ workdirEl.addEventListener('input', () => {
776
+ state.workdir = workdirEl.value;
777
+ });
778
+ mtEl.addEventListener('change', () => {
779
+ state.multiTurn = mtEl.checked; // 显式覆盖(调试就是要试开/关的区别)
780
+ renderControls();
781
+ renderChat();
782
+ });
783
+ document.addEventListener('click', (e) => {
784
+ if (capsEl.open && !capsEl.contains(e.target)) capsEl.open = false;
785
+ // 目录浏览器:点面板外面收起。`浏览…` 不再是开关之后,这是主要的收起方式
786
+ // (按钮自己那一支要排除,否则点它就会「开完立刻被这里收掉」)。
787
+ if (!fsEl.hidden && !fsEl.contains(e.target) && !$('browse').contains(e.target)) closeFs();
788
+ });
789
+
790
+ promptEl.addEventListener('keydown', (e) => {
791
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
792
+ // (乙) prompt 回显:只把文字填回输入框,模型看不见 —— 与多轮开关无关
793
+ if (state.cursor === HISTORY_DRAFT) state.draft = promptEl.value;
794
+ state.cursor = historyStep(state.history, state.cursor, e.key === 'ArrowUp' ? 'older' : 'newer');
795
+ promptEl.value = historyText(state.history, state.cursor, state.draft);
796
+ promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
797
+ e.preventDefault();
798
+ return;
799
+ }
800
+ if (e.key === 'Enter') submit();
801
+ });
802
+ runEl.addEventListener('click', submit);
803
+ abortEl.addEventListener('click', abortRun);
804
+ chatClearEl.addEventListener('click', clearSession);
805
+
806
+ async function submit() {
807
+ const req = readControls();
808
+ if (req.prompt.length === 0) {
809
+ setNotice('prompt 不能为空', true);
810
+ return;
811
+ }
812
+ state.history = pushPromptHistory(state.history, req.prompt);
813
+ state.cursor = HISTORY_DRAFT;
814
+ state.draft = '';
815
+ // 先禁用(防连点),但**不自己恢复** —— 恢复交给 refreshDev():
816
+ // 「能不能再跑」的真值只在 dev.running 一处,面板自己存一份就会漂
817
+ runEl.disabled = true;
818
+ try {
819
+ const res = await fetch('/run', {
820
+ method: 'POST',
821
+ headers: { 'content-type': 'application/json' },
822
+ // 刻意**不**带 sessionId:它由 dev 环(父进程)自己决定,面板只是视图。
823
+ // 带上它反而危险 —— 一个停留在「清空对话」之前的页面能把新 run 写回**旧**会话
824
+ // (刚清掉的对话自己回来)。理由见 dev-protocol 的 `DevMessage`。
825
+ body: JSON.stringify(req),
826
+ });
827
+ const body = await res.json().catch(() => ({}));
828
+ if (!res.ok) {
829
+ setNotice(`没跑起来:${body.error || res.status}`, true);
830
+ await refreshDev();
831
+ return;
832
+ }
833
+ setNotice(body.restarted ? '已接受(换能力选择 ⇒ 重启了 runner 进程)' : '已接受', false);
834
+ await refreshDev();
835
+ } catch (e) {
836
+ setNotice(`请求失败:${e.message}`, true);
837
+ await refreshDev();
838
+ }
143
839
  }
144
840
 
145
- // SSE:新 run 到达即刷新列表并切到最新
841
+ // ---------- 中止 / 清空对话 ----------
842
+ // 两个都不是「面板自己说了算」的操作:真值在后端(running / sessionId),
843
+ // 面板发完请求一律 refreshDev() 回读,不本地改状态。
844
+
845
+ async function abortRun() {
846
+ abortEl.disabled = true;
847
+ try {
848
+ const res = await fetch('/run/abort', { method: 'POST' });
849
+ const body = await res.json().catch(() => ({}));
850
+ if (!res.ok) {
851
+ setNotice(`中止失败:${body.error || res.status}`, true);
852
+ } else if (body.escalated) {
853
+ setNotice('中止超时(工具不响应 signal)⇒ 已重启 runner 兜底。这次 run 的 trace 丢了', true);
854
+ } else {
855
+ setNotice('已发出中止:run 会以 aborted 收尾,trace 保得住', false);
856
+ }
857
+ } catch (e) {
858
+ setNotice(`中止请求失败:${e.message}`, true);
859
+ }
860
+ await refreshDev();
861
+ }
862
+
863
+ async function clearSession() {
864
+ chatClearEl.disabled = true;
865
+ try {
866
+ const res = await fetch('/session/clear', { method: 'POST' });
867
+ const body = await res.json().catch(() => ({}));
868
+ if (!res.ok) {
869
+ setNotice(`清空失败:${body.error || res.status}`, true);
870
+ } else {
871
+ setNotice(`已清空:新会话 ${body.sessionId}(旧对话还在盘上,模型不再带着它跑)`, false);
872
+ }
873
+ } catch (e) {
874
+ setNotice(`清空请求失败:${e.message}`, true);
875
+ }
876
+ await refreshDev();
877
+ await refreshSession();
878
+ renderChat();
879
+ }
880
+
881
+ // ---------- SSE ----------
882
+ // 两个事件源:默认事件 = run 摘要(老行为);命名事件 `dev` = dev 环的生命周期
146
883
  const es = new EventSource('/stream');
147
884
  es.onmessage = async (m) => {
148
885
  const s = JSON.parse(m.data);
@@ -152,12 +889,75 @@
152
889
  const nameEl = document.createElement('b');
153
890
  nameEl.textContent = s.name;
154
891
  liveEl.append(nameEl, ` · ${fmtMs(s.ms)}`);
892
+ // 收尾那份整棵 trace 到了 ⇒ 在飞的那份**作废**:`open()` 画的是权威版本,
893
+ // 而且此后不该再让迟到的增量帧把它重画成残缺的一棵(见 dev-protocol 的 DevEvent)
894
+ state.live = null;
155
895
  await refresh();
896
+ await refreshSession();
897
+ renderChat();
156
898
  await open(s.traceId);
157
899
  };
158
- es.onerror = () => { liveEl.textContent = '连接断开(dev 进程还在吗?)'; };
900
+ es.addEventListener('dev', async (m) => {
901
+ const ev = JSON.parse(m.data);
902
+ if (ev.kind === 'run-start') {
903
+ liveEl.textContent = '正在跑…';
904
+ // 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
905
+ beginLiveRender();
906
+ setNotice(
907
+ `运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
908
+ `${ev.multiTurn ? ' · 多轮' : ''}`,
909
+ false,
910
+ );
911
+ // run-start 是「确实开跑了」的权威信号 ⇒ 中止按钮在这里出现(不看面板自己的乐观状态)
912
+ await refreshDev();
913
+ } else if (ev.kind === 'trace-event') {
914
+ // 在飞的一笔记账(①):折回 + 合帧重画。`state.live` 为空说明收尾那份已经到了
915
+ // (或这一轮不是面板起的)⇒ 丢掉 —— 权威版本已经在屏幕上,别用残缺的覆盖它。
916
+ if (state.live && applyTraceEvent(state.live, ev.event)) renderLive();
917
+ } else if (ev.kind === 'run-done') {
918
+ state.reply = ev.finalText || '';
919
+ // 记下这条回复的**主人**:紧随其后的收尾帧会 open() 同一条 traceId,
920
+ // 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
921
+ state.replyFor = ev.traceId;
922
+ state.live = null;
923
+ replyEl.hidden = state.reply.length === 0;
924
+ replyEl.textContent = state.reply;
925
+ // 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
926
+ // 中止的 run `ok` 也是 false(引擎刻意给 aborted 带结构化 error),
927
+ // 顺序反了就会把「我按了中止」显示成「run 失败」。
928
+ const n = runDoneNotice(ev);
929
+ setNotice(n.text, n.isError);
930
+ await refreshDev();
931
+ // 多轮 run 会回写会话文件 —— 这里补刷一次,别等默认事件的时序
932
+ await refreshSession();
933
+ renderChat();
934
+ } else if (ev.kind === 'runner-restart') {
935
+ setNotice(`重启 runner:${ev.reason}`, false);
936
+ // 重启前的在飞 run 已经不存在了(它的 trace 会丢,面板在别处明说)⇒ 别留一棵半截树
937
+ state.live = null;
938
+ // 重启期间菜单是过期的(旧 runner 的能力)—— 先刷一次,等 runner-ready 再刷一次。
939
+ await refreshDev();
940
+ } else if (ev.kind === 'runner-ready') {
941
+ // 冷启动时首次 refreshDev() 早于 runner 装配完成 ⇒ 拿到空菜单。
942
+ // 这条事件就是补那一次的(否则菜单空着且无解释,要等用户先跑一次才填上)。
943
+ await refreshDev();
944
+ } else if (ev.kind === 'runner-error') {
945
+ setNotice(ev.message, true);
946
+ state.live = null;
947
+ await refreshDev();
948
+ }
949
+ });
950
+ es.onerror = () => {
951
+ liveEl.textContent = '连接断开(dev 进程还在吗?)';
952
+ };
953
+
954
+ $('tab-runs').addEventListener('click', () => selectTab('runs'));
955
+ $('tab-chat').addEventListener('click', () => selectTab('chat'));
159
956
 
160
- openLatest();
957
+ await refreshDev();
958
+ await refreshSession();
959
+ await openLatest();
960
+ renderChat();
161
961
  </script>
162
962
  </body>
163
963
  </html>