@migor/cli 0.9.2 → 0.9.3

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.
@@ -283,6 +283,9 @@
283
283
  </details>
284
284
  <input id="workdir" class="ctl" type="text" spellcheck="false" placeholder="工作目录(绝对路径)" />
285
285
  <button id="browse" class="ctl mini" type="button">浏览…</button>
286
+ <!-- 系统选择…:浏览器弹不出系统目录对话框(拿不到绝对路径),由 dev 进程代弹
287
+ (POST /api/fs/pick)。与「浏览…」(服务端列目录)并列,互为兜底。 -->
288
+ <button id="pick" class="ctl mini" type="button" title="弹出系统目录选择框(在你的桌面上,不在浏览器里)">系统选择…</button>
286
289
  <label class="mt ctl" title="多轮的初始值 = 所选能力声明的 OR;可在这里覆盖">
287
290
  <input type="checkbox" id="mt" /> <span id="mt-label">单轮</span>
288
291
  </label>
@@ -318,8 +321,11 @@
318
321
  applyTraceEvent,
319
322
  browseTarget,
320
323
  capabilityBadge,
324
+ chatViewVisible,
321
325
  collapseDecision,
322
326
  emptyTraceAccumulator,
327
+ filterSelected,
328
+ formatToolSources,
323
329
  historyStep,
324
330
  historyText,
325
331
  mergeConversation,
@@ -333,6 +339,7 @@
333
339
  runDoneNotice,
334
340
  runIsFailure,
335
341
  shortenPath,
342
+ turnKey,
336
343
  } from './panel-logic.js';
337
344
 
338
345
  const $ = (id) => document.getElementById(id);
@@ -438,6 +445,9 @@
438
445
 
439
446
  // 能力多选(默认全选)。全选时 badge 显示 N/N —— 一眼看出这次是全量还是收窄
440
447
  const all = dev.capabilities;
448
+ // runner 重启后菜单可能变了:先丢掉已失效的 token(不丢的话徽标会说谎成「4/3」,
449
+ // 分子里混着菜单上已不存在的名字),再兜空集回落全选
450
+ state.selected = filterSelected(state.selected, all);
441
451
  if (state.selected.length === 0 && all.length > 0) state.selected = [...all];
442
452
  capsSumEl.textContent = `能力 ${capabilityBadge(state.selected, all)} ▾`;
443
453
  capsPopEl.replaceChildren();
@@ -551,7 +561,7 @@
551
561
  if (r.note) {
552
562
  const r3 = document.createElement('span');
553
563
  r3.className = 'r3';
554
- const caps = r.note.toolSources === null ? '全量' : r.note.toolSources.join(',');
564
+ const caps = formatToolSources(r.note.toolSources);
555
565
  r3.textContent = `${short(r.note.workdir)} · ${caps}${r.note.multiTurn ? ' · 多轮' : ''}`;
556
566
  b.appendChild(r3);
557
567
  }
@@ -565,7 +575,9 @@
565
575
  /**
566
576
  * 展开态**活在渲染之外**(与 trace-view 的 `expanded` 同一个理由):面板每次全量重画,
567
577
  * 状态留在 DOM 里就会被下一次重画合上 —— 而「刚点开的那段自己收起来了」是最烦人的那种 bug。
568
- * 键要**跨重画稳定**:回复用 traceId、对话轮用下标(会话是只追加的,下标稳定)。
578
+ * 键要**跨重画稳定**:回复用 traceId、对话轮用 `sessionId + 下标`
579
+ * (`turnKey` —— 只追加的会话里下标稳定,而 sessionId 防「清空对话」后
580
+ * 旧下标的展开态套到新对话上)。
569
581
  */
570
582
  const expandedMsgs = new Set();
571
583
 
@@ -683,7 +695,8 @@
683
695
  const def = multiTurnDefault(state.selected, dev ? dev.multiTurn : []);
684
696
  const value = state.multiTurn ?? def.value;
685
697
  // 三条都要满足才显示:多轮开着、会话文件读得到、dev 环在场 —— 不做空壳
686
- const show = !!dev && dev.available && value && state.session !== null;
698
+ // (判定是纯逻辑 chatViewVisible,这里只做取值 → 调它 → 写回)
699
+ const show = chatViewVisible(dev, value, state.session);
687
700
  tabsEl.hidden = !show;
688
701
  // 清空对话条与对话视图同生共死(它清的就是这份对话)——
689
702
  // **还要跟页签走**:它长在对话视图上,切回 run 列表时不该留在那儿
@@ -704,7 +717,8 @@
704
717
  d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
705
718
  chatEl.appendChild(d);
706
719
  }
707
- // 下标当折叠态的键:会话只追加 ⇒ 下标跨重画稳定
720
+ // 下标进折叠态的键时必须混进 sessionId(turnKey):「清空对话」换 id 后下标
721
+ // 从 0 重来,不带 id 会把旧会话的展开态套到新对话的第 0/1/… 轮上
708
722
  let ti = -1;
709
723
  for (const t of turns) {
710
724
  ti++;
@@ -726,7 +740,7 @@
726
740
  // 助手轮是**模型正文** ⇒ Markdown 渲染 + 长了折叠。
727
741
  // 用户轮保持字面(`u.textContent = t.user` 上面那两行没动)—— 那是「我打的字」,
728
742
  // 把它按 Markdown 重排会让面板看起来像改过我的输入。
729
- renderMessage(a, t.assistant, `turn:${ti}`);
743
+ renderMessage(a, t.assistant, turnKey(state.sessionId, ti));
730
744
  box.append(who2, a);
731
745
  } else if (t.failed) {
732
746
  // 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
@@ -804,11 +818,21 @@
804
818
 
805
819
  // ---------- 拉数据 ----------
806
820
  async function refreshDev() {
821
+ // ⚠️ 与 refresh() 同一口径:必须查 res.ok。403(token 失效 / cookie 过期)时
822
+ // 旧实现静默把 dev 置空 ⇒ 输入条无声消失;服务端 403 的 error 文案本来就写清了
823
+ // 解决办法,把它亮出来,别让用户对着「输入条怎么没了」干瞪眼。
807
824
  try {
808
825
  const res = await fetch('/api/dev');
809
- state.dev = res.ok ? await res.json() : null;
810
- } catch {
826
+ if (!res.ok) {
827
+ const body = await res.json().catch(() => ({}));
828
+ state.dev = null;
829
+ setNotice(`读 dev 状态失败:${body.error || res.status}(输入条因此先收起来了)`, true);
830
+ } else {
831
+ state.dev = await res.json();
832
+ }
833
+ } catch (e) {
811
834
  state.dev = null;
835
+ setNotice(`读 dev 状态失败:${e.message}(输入条因此先收起来了)`, true);
812
836
  }
813
837
  // 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
814
838
  state.sessionId = state.dev?.sessionId ?? '';
@@ -822,12 +846,21 @@
822
846
 
823
847
  async function refreshSession() {
824
848
  if (!state.dev?.available) return;
849
+ // ⚠️ 与 refresh() 同一口径:失败必须亮出来。旧实现静默把 session 置空 ⇒
850
+ // 对话无声变空(看起来像「对话被清了」,其实只是 403 / 读文件失败)
825
851
  try {
826
852
  const res = await fetch('/api/session');
827
- const body = res.ok ? await res.json() : { session: null };
853
+ if (!res.ok) {
854
+ const body = await res.json().catch(() => ({}));
855
+ state.session = null;
856
+ setNotice(`读会话失败:${body.error || res.status}(对话视图因此先收起来了)`, true);
857
+ return;
858
+ }
859
+ const body = await res.json();
828
860
  state.session = body.session ?? null;
829
- } catch {
861
+ } catch (e) {
830
862
  state.session = null;
863
+ setNotice(`读会话失败:${e.message}(对话视图因此先收起来了)`, true);
831
864
  }
832
865
  }
833
866
 
@@ -965,6 +998,57 @@
965
998
  void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
966
999
  });
967
1000
 
1001
+ // ---------- 系统目录选择器(与「浏览…」并列,互为兜底) ----------
1002
+ // 浏览器弹不出系统对话框 ⇒ 由 dev 进程代弹(POST /api/fs/pick;token 走 cookie,
1003
+ // 与其它请求同一条缝)。服务端契约:
1004
+ // 200 {path} 选了 / 200 {path:null} 取消 / 409 已有一个在等 / 501 平台没有可用选择器
1005
+ // 等待期间按钮置灰防重点(第一道闸;服务端那条 409 是第二道)。
1006
+ // 任何一个失败分支都必须有用户可见的提示 —— 不许静默。
1007
+ const pickEl = $('pick');
1008
+ pickEl.addEventListener('click', async () => {
1009
+ pickEl.disabled = true;
1010
+ setNotice('系统选择器已打开…(在你的桌面上,不在浏览器里)', false);
1011
+ try {
1012
+ const res = await fetch('/api/fs/pick', { method: 'POST' });
1013
+ const body = await res.json().catch(() => ({}));
1014
+ if (res.status === 501) {
1015
+ setNotice(`系统选择器不可用:${body.error || '当前平台没有可用的系统选择器'} —— 请改用旁边的「浏览…」`, true);
1016
+ return;
1017
+ }
1018
+ if (!res.ok) {
1019
+ // 409 与其余失败都走这里:服务端的 error 文案说明原因
1020
+ setNotice(`系统选择器打开失败:${body.error || res.status}`, true);
1021
+ return;
1022
+ }
1023
+ if (body.path === null || body.path === undefined) {
1024
+ setNotice('已取消(没选目录)', false);
1025
+ return;
1026
+ }
1027
+ workdirEl.value = body.path;
1028
+ state.workdir = body.path;
1029
+ setNotice(`工作目录:${short(body.path)}`, false);
1030
+ } catch (e) {
1031
+ setNotice(`系统选择器请求失败:${e.message}`, true);
1032
+ } finally {
1033
+ pickEl.disabled = false;
1034
+ }
1035
+ });
1036
+
1037
+ // 页面卸载(刷新 / 关标签页 / 跳走)⇒ **显式**告诉服务端「在飞的选择框没人看了」。
1038
+ // 为什么不能只靠连接断开:实测(2026-09-23,带插桩的产物 + 真浏览器)刷新后那条连接
1039
+ // **还开着** ⇒ 服务端收不到 close ⇒ 选择框留在桌面上、之后每次点都 409。
1040
+ // beacon 是**一次新的请求**,与服务端看不看得见断开无关;token 走 cookie(同一道缝)。
1041
+ // 无条件发:服务端幂等(没有在飞的 pick 就是空操作),省掉一份会漂的页面态。
1042
+ // ⚠️ 路径字面量与 `inspector-routes.ts` 的 PICK_CANCEL_PATH 必须逐字一致 ——
1043
+ // inspector.test.mjs 有逐字对拍断言钉着(页面不能 import 那个常量)。
1044
+ window.addEventListener('pagehide', () => {
1045
+ try {
1046
+ navigator.sendBeacon('/api/fs/pick/cancel');
1047
+ } catch {
1048
+ /* 老浏览器 / 被策略拦下:还有 res close 那条路兜着,不阻断卸载 */
1049
+ }
1050
+ });
1051
+
968
1052
  // ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
969
1053
  workdirEl.addEventListener('input', () => {
970
1054
  state.workdir = workdirEl.value;
@@ -1098,7 +1182,7 @@
1098
1182
  // 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
1099
1183
  beginLiveRender();
1100
1184
  setNotice(
1101
- `运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
1185
+ `运行中:${short(ev.workdir)} · ${formatToolSources(ev.toolSources)}` +
1102
1186
  `${ev.multiTurn ? ' · 多轮' : ''}`,
1103
1187
  false,
1104
1188
  );