mingdao-harness 0.6.23 → 0.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CONFIG.md CHANGED
@@ -925,11 +925,29 @@ mingdao ledger verify <runId> --key <文件> # 用指定公钥验签(本
925
925
  顺手一次就会把签名密钥清掉、让全部历史账本变成"另一把密钥签发"。
926
926
  - **默认开启**:新账本在首次收尾时若本机还没有密钥会**自动生成**并签名(可选签名在合规上等于
927
927
  没有签名)。封条里带 `sig{alg,keyId,value}`,`run.end` 链内带 `sigKey`(公钥指纹)。
928
- - **`run.start.permissionSource`(v0.6.17 §3.64)**:记录本回合生效权限的**来源**——`explicit`
929
- (用户/调用方显式选择,如 WebUI 权限档)/ `preset`(预设声明生效)/ `config`(`config.json`
930
- 的 `permission`)/ `cli`(后台/调度任务由 CLI 参数指定)/ `default`(以上都没有,落到内置缺省
931
- `ask`;WebUI 的 `config` 分支在缺字段时也归 `config`)。`permission` 仍是生效值、`LEDGER_VERSION`
932
- 不变;旧账本没有该字段照样 `verify` 通过(`show` 只是少一行可选的「权限来源」)。
928
+ - **`run.start.permissionSource`(v0.6.17 §3.64,v0.6.24 §3.89 补全 worker/REPL)**:记录本回合
929
+ 生效权限的**来源**。`permission` 仍是生效值,两者分开记——单看生效值无法回答「是谁定的」。
930
+ **完整枚举**(`src/ledger.js` 的 `PERMISSION_SOURCES`;缺失/非法一律写 `null`,不猜):
931
+
932
+ | 值 | 含义 |
933
+ | --- | --- |
934
+ | `explicit` | 用户/调用方在**本回合交互**里显式选择(WebUI 权限档) |
935
+ | `preset` | 预设声明并被采纳的 `permission`(已过反提权判定) |
936
+ | `config` | `config.json` 的 `permission` |
937
+ | `cli` | 前台 CLI 显式参数(保留值;`mingdao run --permission` 属后台任务,按下面的 `job` 记) |
938
+ | `job` | **后台任务/调度**的任务规格显式指定 `permission`(`mingdao run --permission ...`、`schedule add --permission ...`)。v0.6.24 §3.89 新增;刻意不复用 `explicit`,避免与「用户跑的时候按了权限档」混淆 |
939
+ | `default` | 以上都没有,落到内置缺省 `ask` |
940
+
941
+ **各入口取值口径**:
942
+
943
+ | 入口 | 写入值 | 时机 |
944
+ | --- | --- | --- |
945
+ | WebUI | `explicit` / `preset` / `config` | 显式权限档 → `explicit`;预设声明 → `preset`;都不发 → `config`(`resolveTurnPermission` 的既有语义,配置缺字段也归 `config`) |
946
+ | CLI 单次问答 | `preset` / `config` / `default` | `--preset` 声明 permission → `preset`;`config.permission` 存在 → `config`;否则 → `default` |
947
+ | 后台 worker / 调度 | `job` / `config` / `default` | 任务规格(含 `mingdao run --permission`、schedule 的 `permission`)显式指定 → `job`;否则 `config.permission` → `config`;都没有 → `default`。worker 的 `ask → readonly` 降级只改生效值,来源仍按上述口径记 |
948
+ | REPL | `preset` / `config` / `default` | 启动时 `--preset` 已声明 permission → `preset`;否则 `config`(有 `permission`)/ `default`(无)。会话内 `/preset` 且预设 permission 被采纳 → `preset`;仅换提示词/工具白名单的预设不冒充来源,沿用切换前的值。当前没有 `/permission` 命令 |
949
+
950
+ `LEDGER_VERSION` 不变;旧账本没有该字段照样 `verify` 通过(`show` 只是少一行可选的「权限来源」)。
933
951
  - **`ledger verify` 的三种结论**(前两者退出码 0,第三者非 0):
934
952
 
935
953
  | 结论 | 含义 | 退出码 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.23",
3
+ "version": "0.6.25",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,8 +96,16 @@ export async function runRepl(ctx) {
96
96
  const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
97
97
  let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
98
98
  let permissionNow = permission; // /preset 可换权限模式
99
- // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
100
- let agentCfg = ctx.agentCfg || cfg;
99
+ // v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化。
100
+ // v0.6.24 §3.89:REPL 启动时的来源沿用入口(cli.js)算好的值:未切预设 → `config`(config.json
101
+ // 有 permission)或 `default`(内置缺省);`--preset` 已声明 permission → `preset`。这里只兜底
102
+ // 补齐、绝不写 null——"来源未知"与"来源就是配置"是两件事,REPL 属于后者。
103
+ // 这里**保持同一对象**(不拷贝):cli.js 装配的首回合 agent 持有的 cfg 就是这个 agentCfg,
104
+ // 原地补来源才能让首回合同样可见;拷贝会让下面的补齐对已有 agent 失效。
105
+ let agentCfg = ctx.agentCfg || { ...cfg };
106
+ if (!agentCfg.permissionSource) {
107
+ agentCfg.permissionSource = cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default';
108
+ }
101
109
  let mcpManager = ctx.mcpManager;
102
110
  let modelName = ctx.modelName;
103
111
  let provider = ctx.provider;
@@ -300,7 +308,18 @@ export async function runRepl(ctx) {
300
308
  } else if (p.permission !== undefined) {
301
309
  over.permission = permOv.permission;
302
310
  }
303
- agentCfg = { ...cfg, ...over, presetName: p.name };
311
+ // v0.6.24 §3.89:`/preset` 换了本会话的规则环境,来源必须跟着换。只有预设真的声明、
312
+ // 并被反提权判定采纳了 permission 才记 `preset`(与 cli.js 的 hasOwnProperty 同一口径);
313
+ // 仅换提示词/工具白名单/模型的预设不冒充来源,沿用切换前的来源。
314
+ const permissionFromPreset = Object.prototype.hasOwnProperty.call(over, 'permission');
315
+ agentCfg = {
316
+ ...cfg,
317
+ ...over,
318
+ presetName: p.name,
319
+ permissionSource: permissionFromPreset
320
+ ? 'preset'
321
+ : agentCfg.permissionSource ?? (cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default'),
322
+ };
304
323
  if (over.model) await switchToModel(over.model, { silent: true, persist: false });
305
324
  presetBlock = presetSystemBlock(p);
306
325
  permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
package/src/ledger.js CHANGED
@@ -44,13 +44,23 @@ export const LEDGER_VERSION = 1;
44
44
  * 一次"来源不可见"的坑(界面选了自动、却被只读预设压着跑)迫使我们把来源写进账本:
45
45
  * 事后自查时,`permission:"auto"` 单看无法区分"用户显式选的"与"config 里本来就是 auto"。
46
46
  *
47
- * - `explicit`:用户/调用方在本回合显式选择(WebUI 的权限档、CLI 的显式参数);
47
+ * - `explicit`:用户/调用方在**本回合**显式选择(WebUI 的权限档;前台 CLI 暂无 `--permission` 入口);
48
48
  * - `preset`:由预设声明的 permission 生效(反提权判定已通过);
49
49
  * - `config`:来自 `config.json` 的 `permission`;
50
- * - `cli`:后台/调度任务由 CLI 参数指定(`mingdao run --permission ...`);
50
+ * - `cli`:保留给前台 CLI 的显式权限参数(当前没有前台 `--permission` 入口;
51
+ * 后台任务的 `--permission` 属于任务规格,按下面的 `job` 记);
52
+ * - `job`:**后台任务/调度**的任务规格显式指定(`mingdao run --permission ...` /
53
+ * `schedule add --permission ...` 落进 task/job 的 permission)——v0.6.24 §3.89 新增。
54
+ * 刻意不复用 `explicit`:那个值表示「用户在本回合的交互里选的」,而后台任务的权限
55
+ * 是**提交任务时写进规格**的,事后看到 `explicit` 会误以为有人在跑的时候按了权限档;
51
56
  * - `default`:以上都没有,落到内置缺省(`ask`)。
57
+ *
58
+ * 各入口的取值口径(完整表见 `docs/CONFIG.md`「账本来源签名」一节):
59
+ * WebUI → explicit / preset / config;CLI 前台 → preset / config / default(`cli` 为保留值,当前无写入路径);
60
+ * 后台 worker → job(任务规格显式) / config(否则);REPL → preset(本回合切了声明
61
+ * permission 的预设) / config / default(启动时未切预设)。
52
62
  */
53
- export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'default']);
63
+ export const PERMISSION_SOURCES = Object.freeze(['explicit', 'preset', 'config', 'cli', 'job', 'default']);
54
64
  /** 默认保留最近多少次运行(超出按 mtime 删最旧) */
55
65
  const DEFAULT_MAX_RUNS = 200;
56
66
  const GENESIS = '0'.repeat(16);
@@ -897,7 +907,7 @@ export function exportRun(/** @type {any} */ runId, { format = 'json' } = {}) {
897
907
  lines.push(`- 模型:${start?.model ?? '—'} · 会话:${start?.session ?? '—'} · 权限:${start?.permission ?? '—'}`);
898
908
  // v0.6.17 §3.64:来源是**可选**字段——旧账本没有它,这一行就整行不出现(输出契约不变);
899
909
  // 新账本才有,且只增行、不改上面那行「权限:…」的既有形状。
900
- if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / default=内置缺省)`);
910
+ if (start?.permissionSource) lines.push(`- 权限来源:${start.permissionSource}(explicit=显式选择 / preset=预设 / config=配置 / cli=命令行 / job=后台任务规格 / default=内置缺省)`);
901
911
  lines.push(`- 开始:${start?.at ? new Date(start.at).toISOString() : '—'}`);
902
912
  lines.push(`- 结束:${end ? `${end.status}(${end.ms}ms,${end.steps} 步)` : '未结束'}`);
903
913
  lines.push(`- 费用:${end?.priced ? `¥${Number(end.yuanTotal ?? 0).toFixed(4)}` : '**无法估算**(模型无价,不是 0 元)'}`);
@@ -49,6 +49,16 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
49
49
  perm = 'readonly';
50
50
  note = 'ask 权限下后台任务按只读执行';
51
51
  }
52
+ // v0.6.24 §3.89:把权限来源写进账本。此前 worker 路径恒写 null,后台任务/调度会话的
53
+ // `permission` 是 auto 还是 readonly 单看生效值无法区分「任务规格显式定的」与「配置来的」。
54
+ // · 任务规格/CLI 显式给了 permission → `job`(**不复用 explicit**:那个值表示
55
+ // "用户在本回合交互里选的",提交任务时写进规格是另一回事;spawn 时它经
56
+ // `--permission` 传入,语义仍是任务规格);
57
+ // · 否则权限口径来自 `config.json` → `config`;
58
+ // · 配置里也没有 → 内置缺省 `default`(不冒充 config)。
59
+ // 生效值必须用**降级后**的 perm(ask → readonly 已在上面完成),否则账本会记下实际没生效的 ask。
60
+ const permissionSource = permission ? 'job' : cfg.permission !== undefined && cfg.permission !== null ? 'config' : 'default';
61
+ const agentCfg = { ...cfg, permission: perm, permissionSource };
52
62
  // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:00、14:00–18:00)
53
63
  // 自动顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
54
64
  if (offpeak && isPeakHour(new Date())) {
@@ -93,7 +103,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
93
103
  io,
94
104
  modelName,
95
105
  workingDir,
96
- cfg,
106
+ cfg: agentCfg,
97
107
  mcp: mcpFacade || undefined,
98
108
  sessionRef,
99
109
  onCompact: (/** @type {any} */ msgs) => {
package/src/web/app.js CHANGED
@@ -298,6 +298,231 @@ function msgTimeText(ts, now) {
298
298
  let generating = false; // 生成中:发送按钮复用为停止按钮
299
299
  let currentSession = null, thinking = null;
300
300
 
301
+ // ==== v0.6.24 §3.88:左侧栏(会话列) ====
302
+ // 只做「展示 + 转发」:会话数据来自 refreshSessions() 已有的 sessionsGroupedCache(不新增请求、
303
+ // 不引入轮询);点会话写回 #sessions 并派发 change(复用既有切换逻辑);新建对话 / 工作空间
304
+ // 分别复用 #newChat 与 #wsSel 的 handler。折叠偏好记忆在 localStorage。
305
+ const SIDE_NAV_STORAGE_KEY = 'mingdao-sidenav-collapsed';
306
+ let sideNavUserCollapsed = false;
307
+
308
+ /** @param {any} storage */
309
+ function sideNavCollapsedPref(storage) {
310
+ try { return !!storage && storage.getItem(SIDE_NAV_STORAGE_KEY) === '1'; } catch { return false; }
311
+ }
312
+ /** @param {any} storage @param {boolean} collapsed */
313
+ function writeSideNavCollapsed(storage, collapsed) {
314
+ try { if (storage) storage.setItem(SIDE_NAV_STORAGE_KEY, collapsed ? '1' : '0'); } catch {}
315
+ return !!collapsed;
316
+ }
317
+
318
+ // —— §3.90:「最近会话」整块可折叠(标题与计数常驻,只藏列表),偏好记 localStorage ——
319
+ const RECENT_STORAGE_KEY = 'mingdao-recent-collapsed';
320
+ let recentCollapsed = false;
321
+ /** @param {any} storage */
322
+ function recentCollapsedPref(storage) {
323
+ try { return !!storage && storage.getItem(RECENT_STORAGE_KEY) === '1'; } catch { return false; }
324
+ }
325
+ /** @param {any} storage @param {boolean} collapsed */
326
+ function writeRecentCollapsed(storage, collapsed) {
327
+ try { if (storage) storage.setItem(RECENT_STORAGE_KEY, collapsed ? '1' : '0'); } catch {}
328
+ return !!collapsed;
329
+ }
330
+ /** @param {number} n 当前会话条数(只显示数字,标题「最近会话」始终可见) */
331
+ function updateRecentCount(n) {
332
+ const el = $('#sideNavRecentCount');
333
+ if (el) el.textContent = String(Math.max(0, Number(n) || 0));
334
+ }
335
+ /** @param {boolean} collapsed 折叠只切 #sideNavSessions.hidden,不销毁列表节点 */
336
+ function syncRecentCollapsed(collapsed) {
337
+ const box = $('#sideNavSessions');
338
+ if (box) box.hidden = !!collapsed;
339
+ const btn = $('#sideNavRecentToggle');
340
+ if (btn) {
341
+ btn.setAttribute('aria-expanded', String(!collapsed));
342
+ btn.title = collapsed ? '展开最近会话' : '折叠最近会话';
343
+ }
344
+ }
345
+ function toggleRecentSessions() {
346
+ recentCollapsed = !recentCollapsed;
347
+ let storage = null;
348
+ try { storage = window.localStorage; } catch {}
349
+ writeRecentCollapsed(storage, recentCollapsed);
350
+ syncRecentCollapsed(recentCollapsed);
351
+ }
352
+ /** 三档断点:≥1180 三栏 / 900–1180 左栏自动折叠 / <900 抽屉。 */
353
+ function sideNavBreakpoint(width) {
354
+ const w = Number(width) || 0;
355
+ if (w >= 1180) return 'wide';
356
+ if (w >= 900) return 'mid';
357
+ return 'drawer';
358
+ }
359
+ function sideNavLayout(width, pref) {
360
+ const mode = sideNavBreakpoint(width);
361
+ const collapsed = mode === 'mid' || (mode === 'wide' && !!pref);
362
+ return { mode, collapsed, width: mode === 'drawer' ? 0 : (collapsed ? 48 : 240), drawer: mode === 'drawer' };
363
+ }
364
+ function sideNavClassNames(layout) {
365
+ const out = [];
366
+ if (layout.mode === 'wide') out.push('side-wide');
367
+ if (layout.mode === 'mid') out.push('side-mid');
368
+ if (layout.mode === 'drawer') out.push('side-drawer');
369
+ if (layout.mode === 'wide' && layout.collapsed) out.push('side-collapsed');
370
+ return out;
371
+ }
372
+ /**
373
+ * 渲染左栏会话列表:只读分组缓存,四个分组标题 + 条目;当前会话高亮。
374
+ * @param {any} container @param {any[]} groups @param {any} current @param {(s:any)=>void} [onPick]
375
+ * @returns {number} 渲染的会话条数
376
+ */
377
+ function renderSideNavSessions(container, groups, current, onPick) {
378
+ if (!container) return 0;
379
+ while (container.firstChild) container.removeChild(container.firstChild);
380
+ const list = Array.isArray(groups) ? groups : [];
381
+ if (!list.length) {
382
+ const empty = document.createElement('div');
383
+ empty.className = 'sn-empty';
384
+ empty.textContent = '暂无会话';
385
+ container.appendChild(empty);
386
+ return 0;
387
+ }
388
+ let n = 0;
389
+ for (const g of list) {
390
+ const head = document.createElement('div');
391
+ head.className = 'sn-group';
392
+ head.textContent = String(g.title || '');
393
+ container.appendChild(head);
394
+ for (const s of (Array.isArray(g.items) ? g.items : [])) {
395
+ const item = document.createElement('button');
396
+ item.type = 'button';
397
+ item.className = 'sn-item' + (s.file === current ? ' on' : '');
398
+ item.dataset.file = String(s.file || '');
399
+ item.title = String(s.label || s.file || '');
400
+ if (s.file === current) item.setAttribute('aria-current', 'true');
401
+ const ico = document.createElement('span');
402
+ ico.className = 'sn-item-ico';
403
+ ico.setAttribute('aria-hidden', 'true');
404
+ ico.textContent = '💬';
405
+ const label = document.createElement('span');
406
+ label.className = 'sn-item-label';
407
+ label.textContent = String(s.label || s.file || '');
408
+ item.appendChild(ico);
409
+ item.appendChild(label);
410
+ item.addEventListener('click', () => { if (typeof onPick === 'function') onPick(s); });
411
+ container.appendChild(item);
412
+ n += 1;
413
+ }
414
+ }
415
+ return n;
416
+ }
417
+ /** 点击左栏会话:写回顶栏 #sessions 并派发 change(复用既有 onchange 切换逻辑)。 */
418
+ function pickSessionFromSideNav(s) {
419
+ const sel = $('#sessions');
420
+ if (!sel || !s || !s.file) return;
421
+ sel.value = s.file;
422
+ sel.dispatchEvent(new Event('change'));
423
+ }
424
+ /** 工作空间镜像:选项与选中值从顶栏 #wsSel 同步,切换仍交给原控件 handler。 */
425
+ function syncSideNavWorkspace() {
426
+ const src = $('#wsSel'), dst = $('#sideNavWs');
427
+ if (!src || !dst) return;
428
+ while (dst.firstChild) dst.removeChild(dst.firstChild);
429
+ for (const o of src.options) {
430
+ const n = document.createElement('option');
431
+ n.value = o.value;
432
+ n.textContent = o.textContent;
433
+ dst.appendChild(n);
434
+ }
435
+ dst.value = src.value;
436
+ const icon = $('#sideNavWsIcon');
437
+ if (icon) {
438
+ const cur = src.options[src.selectedIndex];
439
+ icon.title = '工作空间' + (cur && cur.value ? ':' + String(cur.textContent || '').replace(/^●\s*/, '') : '(点此切换)');
440
+ }
441
+ }
442
+ /** 当前会话高亮(只切类,不重建列表,避免搜索/滚动位置抖动)。 */
443
+ function syncSideNavActive() {
444
+ const box = $('#sideNavSessions');
445
+ if (!box) return;
446
+ for (const el of box.querySelectorAll('.sn-item')) {
447
+ const on = !!(currentSession && el.dataset && el.dataset.file === currentSession);
448
+ el.classList.toggle('on', on);
449
+ if (on) el.setAttribute('aria-current', 'true'); else el.removeAttribute('aria-current');
450
+ }
451
+ }
452
+ function syncSideNavToggle() {
453
+ const btn = $('#sideNavCollapse');
454
+ const mode = sideNavBreakpoint(window.innerWidth || 0);
455
+ const drawerOpen = document.body.classList.contains('side-drawer-open');
456
+ if (btn) {
457
+ btn.textContent = mode === 'drawer' ? (drawerOpen ? '×' : '☰') : (sideNavUserCollapsed ? '»' : '«');
458
+ btn.setAttribute('aria-expanded', String(mode === 'drawer' ? drawerOpen : !sideNavUserCollapsed));
459
+ btn.title = mode === 'drawer' ? (drawerOpen ? '关闭会话侧栏' : '打开会话侧栏') : (sideNavUserCollapsed ? '展开侧栏' : '折叠侧栏');
460
+ }
461
+ const rail = $('#sideNavRailBtn');
462
+ if (rail) { rail.classList.toggle('on', drawerOpen); rail.title = drawerOpen ? '关闭会话侧栏' : '打开会话侧栏'; }
463
+ }
464
+ function applySideNavLayout(width) {
465
+ const w = width == null ? (Number(window.innerWidth) || 0) : (Number(width) || 0);
466
+ const layout = sideNavLayout(w, sideNavUserCollapsed);
467
+ const names = sideNavClassNames(layout);
468
+ for (const c of ['side-wide', 'side-mid', 'side-drawer', 'side-collapsed']) {
469
+ document.body.classList.toggle(c, names.includes(c));
470
+ }
471
+ if (layout.mode !== 'drawer') document.body.classList.remove('side-drawer-open');
472
+ const nav = $('#sideNav');
473
+ if (nav) nav.classList.toggle('collapsed', layout.collapsed);
474
+ syncSideNavToggle();
475
+ }
476
+ function toggleSideNav() {
477
+ const mode = sideNavBreakpoint(window.innerWidth || 0);
478
+ if (mode === 'drawer') {
479
+ document.body.classList.toggle('side-drawer-open');
480
+ syncSideNavToggle();
481
+ return;
482
+ }
483
+ if (mode === 'mid') return; // 900–1180:自动折叠,不提供手动展开(避免挤窄主区)
484
+ sideNavUserCollapsed = !sideNavUserCollapsed;
485
+ let storage = null;
486
+ try { storage = window.localStorage; } catch {}
487
+ writeSideNavCollapsed(storage, sideNavUserCollapsed);
488
+ applySideNavLayout(window.innerWidth || 0);
489
+ }
490
+ function initSideNav() {
491
+ let storage = null;
492
+ try { storage = window.localStorage; } catch {}
493
+ sideNavUserCollapsed = sideNavCollapsedPref(storage);
494
+ recentCollapsed = recentCollapsedPref(storage);
495
+ const newBtn = $('#sideNavNew');
496
+ if (newBtn) newBtn.onclick = () => { const b = $('#newChat'); if (b) b.click(); };
497
+ const collapse = $('#sideNavCollapse');
498
+ if (collapse) collapse.onclick = toggleSideNav;
499
+ const recentToggle = $('#sideNavRecentToggle');
500
+ if (recentToggle) recentToggle.onclick = toggleRecentSessions;
501
+ const rail = $('#sideNavRailBtn');
502
+ if (rail) rail.onclick = toggleSideNav;
503
+ const ws = $('#sideNavWs');
504
+ if (ws) ws.addEventListener('change', () => {
505
+ const main = $('#wsSel');
506
+ if (!main) return;
507
+ main.value = ws.value;
508
+ main.dispatchEvent(new Event('change'));
509
+ });
510
+ const wsIcon = $('#sideNavWsIcon');
511
+ if (wsIcon) wsIcon.onclick = () => {
512
+ const main = $('#wsSel');
513
+ if (!main) return;
514
+ try { main.focus(); } catch {}
515
+ try { if (typeof main.showPicker === 'function') { main.showPicker(); return; } } catch {}
516
+ try { main.click(); } catch {}
517
+ };
518
+ syncSideNavWorkspace();
519
+ const recentN = renderSideNavSessions($('#sideNavSessions'), sessionsGroupedCache, currentSession, pickSessionFromSideNav);
520
+ updateRecentCount(recentN);
521
+ syncRecentCollapsed(recentCollapsed);
522
+ applySideNavLayout();
523
+ }
524
+ // ==== /v0.6.24 §3.88 ====
525
+
301
526
 
302
527
 
303
528
  function newAiMsg(ts){ const el=document.createElement('div'); el.className='msg-ai'; const t=msgTimeText(ts); if(t){ const m=document.createElement('div'); m.className='msg-time'; m.textContent=t; el.appendChild(m); } chatEl.appendChild(el); scrollBottom(); return el; }
@@ -1337,6 +1562,7 @@ function applyResponsiveLayout(width) {
1337
1562
  const cls = layoutClassesFor(w);
1338
1563
  document.body.classList.toggle('narrow-side', cls.narrow);
1339
1564
  document.body.classList.toggle('narrow-run', cls.narrowRun);
1565
+ applySideNavLayout(w); // §3.88:左栏三档断点与主区推开量
1340
1566
  syncRunPanel();
1341
1567
  }
1342
1568
  function initResponsive() {
@@ -1359,6 +1585,51 @@ for (const key of RAIL_TABS) {
1359
1585
  }
1360
1586
  setRailTab('trace');
1361
1587
 
1588
+ // ==== v0.6.25 §3.90:右栏「概览」(会话配置 + 本轮数据)可折叠 ====
1589
+ // 默认折叠,折叠时只显示一行摘要(模型 · 权限 · 阶段 · 工具数);偏好记 localStorage。
1590
+ // 展开后的字段与数据来源不变:仍由 renderRunPanel() 写 #rpModel/#rpPerm/六个 data-rk。
1591
+ const RP_OVERVIEW_STORAGE_KEY = 'mingdao-rp-overview-expanded';
1592
+ let rpOverviewExpanded = false;
1593
+ /** @param {any} storage */
1594
+ function rpOverviewPref(storage) {
1595
+ try { return !!storage && storage.getItem(RP_OVERVIEW_STORAGE_KEY) === '1'; } catch { return false; }
1596
+ }
1597
+ /** @param {any} storage @param {boolean} expanded */
1598
+ function writeRpOverview(storage, expanded) {
1599
+ try { if (storage) storage.setItem(RP_OVERVIEW_STORAGE_KEY, expanded ? '1' : '0'); } catch {}
1600
+ return !!expanded;
1601
+ }
1602
+ /** 折叠时的一行摘要:完全复用 runPanelView() 的字段,不另算数据。 */
1603
+ function rpOverviewSummary(v) {
1604
+ const x = v || {};
1605
+ return [x.model || x.modelLabel, x.permission, x.phase, x.tools].filter((s) => s != null && s !== '').join(' · ') || '—';
1606
+ }
1607
+ /** @param {boolean} expanded 只切 hidden/aria,不销毁字段节点(id 与数据来源不变) */
1608
+ function syncRpOverview(expanded) {
1609
+ rpOverviewExpanded = !!expanded;
1610
+ const body = $('#rpOverviewBody');
1611
+ if (body) body.hidden = !rpOverviewExpanded;
1612
+ const head = $('#rpOverviewHead');
1613
+ if (head) {
1614
+ head.setAttribute('aria-expanded', String(rpOverviewExpanded));
1615
+ head.title = rpOverviewExpanded ? '折叠概览' : '展开概览(会话配置 + 本轮数据)';
1616
+ }
1617
+ }
1618
+ function toggleRpOverview() {
1619
+ let storage = null;
1620
+ try { storage = window.localStorage; } catch {}
1621
+ writeRpOverview(storage, !rpOverviewExpanded);
1622
+ syncRpOverview(!rpOverviewExpanded);
1623
+ }
1624
+ function initRpOverview() {
1625
+ let storage = null;
1626
+ try { storage = window.localStorage; } catch {}
1627
+ const head = $('#rpOverviewHead');
1628
+ if (head) head.onclick = toggleRpOverview;
1629
+ syncRpOverview(rpOverviewPref(storage));
1630
+ }
1631
+ initRpOverview();
1632
+
1362
1633
  // —— 工具时间线 DOM:一张卡 + 可复用行(开始/完成不换节点) ——
1363
1634
  function toolStepNode(step) {
1364
1635
  const li = document.createElement('li'); li.className = step.liClass;
@@ -1510,6 +1781,8 @@ function renderRunPanel() {
1510
1781
  set('#rpPreset', v.preset);
1511
1782
  set('#rpPerm', v.permission);
1512
1783
  set('#rpPermSrc', '来源:' + v.permissionSource + '(' + v.permissionSourceLabel + ')');
1784
+ // §3.90:折叠概览的一行摘要与展开后的字段同源(同一个 v,不同展示)。
1785
+ set('#rpOverviewSum', rpOverviewSummary(v));
1513
1786
  const total = Number(runState.contextTotal) || 0;
1514
1787
  const used = Math.max(0, Number(runState.contextUsed) || 0);
1515
1788
  const pct = total ? Math.min(100, Math.round((used / total) * 100)) : 0;
@@ -2188,8 +2461,7 @@ function openTraj(msg){
2188
2461
  // 点击标题定位到聊天中的对应工具卡
2189
2462
  list.appendChild(div);
2190
2463
  }
2191
- // §3.82:轨迹入口不再单独开旧浮层——切到右栏「轨迹」标签(旧面板与其 #tjList 仍在 DOM 里)。
2192
- $('#trajRailBtn').classList.add('on');
2464
+ // §3.82:轨迹详情在右栏「轨迹」标签;§3.90 起窄条不再有 🧭 入口,旧面板渲染保留但不再挂可见入口。
2193
2465
  activateRailTab('trace');
2194
2466
  }
2195
2467
  // 面板布局同步:§3.82 起轨迹/子代理/任务/仪表盘都在右栏标签页内,
@@ -2204,19 +2476,8 @@ function syncPanelLayout(){
2204
2476
  // §3.73:窄屏右栏收起时,轨迹/子Agent 在对话流末尾显示;右栏(抽屉)一开就让位。
2205
2477
  syncTraceDock();
2206
2478
  }
2207
- $('#tjClose').onclick=()=>{ setRunPanel(false); $('#trajRailBtn').classList.remove('on'); };
2208
- $('#trajRailBtn').onclick=()=>{
2209
- // §3.82:轨迹入口 = 打开右栏并切到「轨迹」标签(没有轨迹时也切过去,显示空态)。
2210
- const m=lastMsgWithTraj();
2211
- if(m && m._traj && m._traj.length){ openTraj(m); return; }
2212
- $('#trajRailBtn').classList.add('on');
2213
- activateRailTab('trace');
2214
- };
2215
- function lastMsgWithTraj(){
2216
- const msgs=[...document.querySelectorAll('#chat .msg-ai')].reverse();
2217
- for(const m of msgs){ if(m._traj && m._traj.length) return m; }
2218
- return msgs.length ? msgs[0] : null;
2219
- }
2479
+ $('#tjClose').onclick=()=>{ setRunPanel(false); };
2480
+ // §3.90:窄条 🧭 入口已删除(与右栏「轨迹」标签重复);轨迹只在右栏标签可达。
2220
2481
  function renderSubPanel(){
2221
2482
  const count=sessionSubs.length;
2222
2483
  const badge=$('#subBadge'); if(badge){ badge.style.display=count>0?'':'none'; badge.textContent=count>99?'99+':String(count); }
@@ -2236,14 +2497,8 @@ function renderSubPanel(){
2236
2497
  }
2237
2498
  if(!count) list.innerHTML='<div class="empty" style="color:var(--faint);font-size:12px;padding:10px">本会话还没有子 Agent(task 工具会派生子 Agent)</div>';
2238
2499
  }
2239
- const toggleSubPanel=()=>{
2240
- // §3.82:子Agent 入口 = 打开右栏并切到「子Agent」标签(不再单独开旧浮层)。
2241
- renderSubPanel();
2242
- activateRailTab('sub');
2243
- $('#subRailBtn').classList.add('on');
2244
- };
2245
- $('#subRailBtn').onclick=toggleSubPanel;
2246
- $('#sbClose').onclick=()=>{ setRunPanel(false); $('#subRailBtn').classList.remove('on'); };
2500
+ // §3.90:窄条 🤖 入口已删除(与右栏「子Agent」标签重复);子Agent 只在右栏标签可达。
2501
+ $('#sbClose').onclick=()=>{ setRunPanel(false); };
2247
2502
  // —— 任务面板(多会话并行) ——
2248
2503
  function setBtn(){
2249
2504
  // §138:停止点击后立刻有可见反馈(「停止中…」+ 禁用防连点),回合真正收尾后由 finally 复位。
@@ -2340,6 +2595,7 @@ function renderCrumb(){
2340
2595
  const w=$('#crumbWs'); if(w) w.textContent=currentWorkspaceName||'工作空间';
2341
2596
  const cur=sessionsCache.find((s)=>s&&s.file===currentSession);
2342
2597
  const c=$('#crumbSession'); if(c) c.textContent=cur?(cur.label||cur.file):(currentSession?String(currentSession).replace(/\.jsonl$/,''):'新对话');
2598
+ syncSideNavActive();
2343
2599
  }
2344
2600
  async function refreshSessions(q){
2345
2601
  const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions';
@@ -2354,6 +2610,9 @@ async function refreshSessions(q){
2354
2610
  for(const s of g.items){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; og.appendChild(o); }
2355
2611
  sel.appendChild(og);
2356
2612
  }
2613
+ // §3.88:左栏只读同一份分组缓存(不额外请求/不轮询);§3.90 同步折叠标题上的会话数
2614
+ const recentN = renderSideNavSessions($('#sideNavSessions'), sessionsGroupedCache, currentSession, pickSessionFromSideNav);
2615
+ updateRecentCount(recentN);
2357
2616
  if(currentSession&&!q) sel.value=currentSession;
2358
2617
  renderCrumb();
2359
2618
  }
@@ -2511,6 +2770,7 @@ async function refreshWsSel(){
2511
2770
  const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
2512
2771
  sel.appendChild(mgr);
2513
2772
  renderCrumb();
2773
+ syncSideNavWorkspace(); // §3.88:左栏工作空间镜像顶栏选项/选中值
2514
2774
  }
2515
2775
  /** 命令面板与顶栏下拉共用的工作空间切换(携带当前会话,语义与旧路径一致)。 */
2516
2776
  async function applyWorkspaceSwitch(name){
@@ -2957,4 +3217,6 @@ async function openSessModal(){
2957
3217
  }
2958
3218
  $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); } };
2959
3219
  if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
3220
+ // §3.88:左栏先接线(读折叠偏好 + 渲染一次);init() 里的 refreshSessions 会再次用最新数据刷新。
3221
+ initSideNav();
2960
3222
  init();
@@ -596,6 +596,18 @@ body.run-open #sessSheet{right:318px}
596
596
  四个面板本体搬进标签页后不再 fixed(覆盖既有 #tasksPanel/#dashPanel 的定位,宽度跟随右栏)。 */
597
597
  .rp-top{flex:none;padding:10px 12px 0}
598
598
  .rp-top .rp-sec{margin-bottom:8px}
599
+ /* §3.90:概览(会话配置 + 本轮数据)收进标签栏下方的可折叠区块;默认折叠只留一行摘要。
600
+ 展开后内容仍是原来的字段与 id(renderRunPanel 的写入点一个没改)。 */
601
+ .rp-overview{flex:none;margin:0 12px 8px;border:1px solid var(--border);border-radius:9px;background:var(--bg3);overflow:hidden}
602
+ #runPanel [role="tablist"]{margin-top:8px}
603
+ #rpOverviewHead{display:flex;align-items:center;gap:5px;width:100%;min-width:0;border:0;background:transparent;color:var(--dim);font:inherit;font-size:11.5px;text-align:left;padding:6px 7px;cursor:pointer}
604
+ #rpOverviewHead:hover{color:var(--text)}
605
+ .rp-ov-title{flex:none;font-weight:600;color:var(--text);white-space:nowrap}
606
+ .rp-ov-sum{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;color:var(--dim)}
607
+ .rp-ov-tri{flex:none;width:0;height:0;border-top:4px solid currentColor;border-left:3px solid transparent;border-right:3px solid transparent;transition:transform .15s}
608
+ #rpOverviewHead[aria-expanded="false"] .rp-ov-tri{transform:rotate(-90deg)}
609
+ #rpOverviewBody{padding:8px;border-top:1px solid var(--border);max-height:46vh;overflow-y:auto;overscroll-behavior:contain}
610
+ #rpOverviewBody[hidden]{display:none}
599
611
  /* §3.83:四个标签统一成设计稿 `.seg` 分段控件观感(外框 bg3 + padding 3 / 圆角 9,
600
612
  内按钮 26px 高 / 圆角 6;选中态 panel 底 + text 字 + 600 字重)。只改样式,切换逻辑不动。 */
601
613
  .rp-tabs{display:flex;gap:4px;padding:3px;margin:0 12px 8px;flex:none;background:var(--bg3);border:1px solid var(--border);border-radius:9px}
@@ -640,9 +652,82 @@ body.run-open #sessSheet{right:318px}
640
652
  #emptyState .es-lead,
641
653
  .empty-step .es-main span,
642
654
  .empty-step .es-state{color:var(--dim)}
655
+
656
+ /* ============================================================================
657
+ v0.6.24 §3.88 左侧栏(设计稿三栏的标志性部分)
658
+ ——只新增左栏自己的样式,复用既有 token;不改顶栏高度、#runPanel 宽度、
659
+ 既有断点与字号阶。布局:现有窄条 #trajRail(42) → 本栏(240/48) → 主区 → 右栏。
660
+ ============================================================================ */
661
+ #sideNav{position:fixed;left:42px;top:52px;bottom:0;width:240px;min-width:0;background:var(--bg2);border-right:1px solid var(--border);z-index:34;display:flex;flex-direction:column;overflow:hidden;transition:width .16s ease}
662
+ #sideNav .sn-head{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border)}
663
+ #sideNav .sn-title{flex:1;min-width:0;font-size:12px;font-weight:600;color:var(--dim);letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
664
+ #sideNavCollapse{flex:none;width:26px;height:26px;padding:0;border-radius:7px;background:var(--bg3);font-size:13px;line-height:1;display:inline-flex;align-items:center;justify-content:center}
665
+ #sideNav .sn-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 8px;overflow-y:auto;overscroll-behavior:contain}
666
+ #sideNavNew{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;min-width:0;padding:8px 10px;border-radius:9px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:13px;font-weight:600;white-space:nowrap}
667
+ #sideNavNew:hover{border-color:var(--accent2);color:var(--accent2)}
668
+ #sideNavNew .sn-label{overflow:hidden;text-overflow:ellipsis}
669
+ .sn-ws{display:flex;align-items:center;gap:6px;min-width:0;padding:0 2px}
670
+ .sn-ws .sn-ws-ico{flex:none;font-size:13px;line-height:1;color:var(--dim)}
671
+ #sideNavWs{flex:1;min-width:0;max-width:none;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 6px;font-size:12px}
672
+ #sideNavWsIcon{display:none;align-items:center;justify-content:center;width:36px;height:30px;padding:0;border-radius:8px;background:var(--bg3);font-size:14px;line-height:1}
673
+ #sideNav .sn-sec-title{flex:none;font-size:10.5px;color:var(--dim);letter-spacing:.05em;padding:2px 6px;white-space:nowrap}
674
+ /* §3.90:「最近会话」标题变成折叠开关;折叠后标题与计数仍在,只藏列表项。 */
675
+ #sideNav button.sn-sec-title{display:flex;align-items:center;gap:6px;width:100%;min-width:0;border:0;border-radius:6px;background:transparent;font-family:inherit;font-weight:600;cursor:pointer;text-align:left}
676
+ #sideNav button.sn-sec-title:hover{color:var(--text);background:var(--bg3)}
677
+ .sn-sec-chev{flex:none;width:10px;font-size:9px;line-height:1;color:var(--faint);transition:transform .15s}
678
+ #sideNavRecentToggle[aria-expanded="false"] .sn-sec-chev{transform:rotate(-90deg)}
679
+ .sn-sec-label{flex:none;white-space:nowrap}
680
+ .sn-sec-count{margin-left:auto;flex:none;min-width:16px;text-align:right;font-size:10px;font-weight:400;color:var(--faint);font-variant-numeric:tabular-nums}
681
+ /* 侧栏收成 48px 图标列时,标题/计数不参与(与既有 .sn-sec-title 的隐藏口径一致)。 */
682
+ body.side-collapsed #sideNav #sideNavRecentToggle,
683
+ body.side-mid #sideNav #sideNavRecentToggle{display:none}
684
+ #sideNavSessions[hidden]{display:none}
685
+ #sideNavSessions{display:flex;flex-direction:column;gap:1px;min-width:0}
686
+ #sideNavSessions .sn-group{font-size:10.5px;line-height:1.6;color:var(--dim);padding:7px 6px 2px;letter-spacing:.02em;white-space:nowrap}
687
+ #sideNavSessions .sn-item{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:transparent;color:var(--dim);text-align:left;font-size:12.5px;line-height:1.5;cursor:pointer}
688
+ #sideNavSessions .sn-item:hover{background:var(--bg3);color:var(--text)}
689
+ #sideNavSessions .sn-item.on{background:var(--bg3);color:var(--text);font-weight:600;box-shadow:inset 2px 0 0 var(--accent)}
690
+ #sideNavSessions .sn-item-ico{flex:none;width:16px;text-align:center;font-size:13px}
691
+ #sideNavSessions .sn-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
692
+ #sideNavSessions .sn-empty{font-size:11.5px;color:var(--dim);padding:8px 6px;white-space:nowrap}
693
+ #trajRail #sideNavRailBtn{display:none}
694
+ /* —— 三档断点:≥1180 展开(240) / 900–1180 自动折叠(48) / <900 抽屉(默认收起) —— */
695
+ body.side-wide #sideNav{width:240px}
696
+ body.side-wide.side-collapsed #sideNav{width:48px}
697
+ body.side-mid #sideNav{width:48px}
698
+ body.side-drawer #sideNav{display:none;width:240px}
699
+ body.side-drawer.side-drawer-open #sideNav{display:flex;z-index:55;box-shadow:12px 0 32px rgba(0,0,0,.45)}
700
+ body.side-drawer #trajRail #sideNavRailBtn{display:flex}
701
+ /* 主区/输入区右移,左栏不遮正文;顶栏保持整条横贯(高度/入口不动) */
702
+ body.side-wide main,body.side-wide footer{margin-left:282px}
703
+ body.side-wide.side-collapsed main,body.side-wide.side-collapsed footer{margin-left:90px}
704
+ body.side-mid main,body.side-mid footer{margin-left:90px}
705
+ body.side-drawer main,body.side-drawer footer{margin-left:0}
706
+ /* 折叠态:只留图标 + tooltip(label/分组标题隐藏;折叠按钮本身是图标,保留用于展开) */
707
+ body.side-collapsed .sn-title,
708
+ body.side-mid .sn-title,
709
+ body.side-collapsed #sideNavNew .sn-label,
710
+ body.side-mid #sideNavNew .sn-label,
711
+ body.side-collapsed .sn-sec-title,
712
+ body.side-mid .sn-sec-title,
713
+ body.side-collapsed #sideNavSessions .sn-group,
714
+ body.side-mid #sideNavSessions .sn-group,
715
+ body.side-collapsed #sideNavSessions .sn-item-label,
716
+ body.side-mid #sideNavSessions .sn-item-label,
717
+ body.side-collapsed .sn-ws,
718
+ body.side-mid .sn-ws,
719
+ body.side-mid #sideNavCollapse{display:none}
720
+ body.side-mid .sn-head::before{content:'💬';font-size:14px;line-height:1;color:var(--dim)}
721
+ body.side-collapsed #sideNavWsIcon,
722
+ body.side-mid #sideNavWsIcon{display:inline-flex}
723
+ body.side-collapsed .sn-head,body.side-mid .sn-head{padding:10px 6px;justify-content:center}
724
+ body.side-collapsed #sideNav .sn-body,body.side-mid #sideNav .sn-body{padding:10px 6px;align-items:center}
725
+ body.side-collapsed #sideNavNew,body.side-mid #sideNavNew{width:36px;padding:8px 0;gap:0}
726
+ body.side-collapsed #sideNavSessions .sn-item,body.side-mid #sideNavSessions .sn-item{width:36px;justify-content:center;padding:6px 0;gap:0}
727
+ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions .sn-item.on{box-shadow:inset 0 0 0 1px var(--accent)}
643
728
  </style>
644
729
  </head>
645
- <body>
730
+ <body class="side-wide">
646
731
  <header>
647
732
  <div class="logo">MingDao <span>Harness</span></div>
648
733
  <!-- v0.6.21 小步重排(§3.81):顶栏只保留「我在哪」的当前位置;模型/预设/权限只在 #sessSheet 里改。 -->
@@ -660,13 +745,40 @@ body.run-open #sessSheet{right:318px}
660
745
  <input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
661
746
  <select id="sessions" title="历史会话"><option value="">历史会话</option></select>
662
747
  <button id="tasksBtn" title="任务面板">任务</button><button id="sessMgrBtn" title="管理会话(重命名/删除)">✎</button>
663
- <button id="newChat">新对话</button>
748
+ <!-- v0.6.25 §3.90:顶栏「新对话」与左侧栏 #sideNavNew 重复,可视入口只留左侧。
749
+ 保留 hidden 兼容节点:id/处理器语义不变(左侧按钮与 ⌘K 仍点它),只是不可见。 -->
750
+ <span id="newChatCompat" hidden><button id="newChat">新对话</button></span>
664
751
  </div>
665
752
  </header>
666
753
  <div id="trajRail">
667
- <button class="rail-btn" id="trajRailBtn" title="轨迹(点击查看详情)">🧭</button>
668
- <button class="rail-btn" id="subRailBtn" title="子 Agent">🤖<span class="rail-badge" id="subBadge" style="display:none"></span></button>
754
+ <!-- §3.88:<900 时左栏收成抽屉,这个窄条图标负责打开/关闭(≥900 隐藏)。 -->
755
+ <button class="rail-btn" id="sideNavRailBtn" title="会话侧栏(打开/关闭)">☰</button>
669
756
  </div>
757
+ <!-- v0.6.25 §3.90:旧窄条 🧭/🤖 与右栏四标签重复,已从可见窄条移除;轨迹/子Agent 只在
758
+ 右栏「轨迹」「子Agent」标签可达。下面两个隐藏兼容节点不渲染、不绑定点击,仅让既有
759
+ class 清理与历史回归脚本保持空引用安全(#newChatCompat 同理)。 -->
760
+ <span id="legacyRailCompat" hidden aria-hidden="true"><span id="trajRailBtn"></span><span id="subRailBtn"></span></span>
761
+ <!-- v0.6.24 §3.88 左侧栏:设计稿三栏的会话列。只新增;顶栏既有入口一个不删。
762
+ 数据复用 refreshSessions() 的 sessionsGroupedCache;切换走 #sessions change;
763
+ 工作空间镜像 #wsSel(原控件保留在顶栏原位);新建对话复用 #newChat handler。 -->
764
+ <aside id="sideNav" aria-label="会话侧栏">
765
+ <div class="sn-head">
766
+ <span class="sn-title">会话</span>
767
+ <button id="sideNavCollapse" type="button" title="折叠侧栏" aria-label="折叠侧栏" aria-expanded="true">«</button>
768
+ </div>
769
+ <div class="sn-body">
770
+ <button id="sideNavNew" type="button" title="新建对话">+<span class="sn-label">新建对话</span></button>
771
+ <div class="sn-ws">
772
+ <span class="sn-ws-ico" aria-hidden="true">📁</span>
773
+ <select id="sideNavWs" title="工作空间(与顶栏 #wsSel 同源,切换走同一个 handler)"></select>
774
+ </div>
775
+ <button id="sideNavWsIcon" type="button" title="工作空间" aria-label="工作空间">📁</button>
776
+ <button id="sideNavRecentToggle" type="button" class="sn-sec-title" aria-expanded="true" aria-controls="sideNavSessions" title="折叠最近会话">
777
+ <span class="sn-sec-chev" aria-hidden="true">▾</span><span class="sn-sec-label">最近会话</span><span class="sn-sec-count" id="sideNavRecentCount" title="当前会话数">0</span>
778
+ </button>
779
+ <div id="sideNavSessions" role="list" aria-label="会话列表"></div>
780
+ </div>
781
+ </aside>
670
782
  <main>
671
783
  <!-- P1-6 空态三步引导(v0.6.19 §3.70):无会话 / 无消息时显示;示例任务只填入输入框,不自动发送。 -->
672
784
  <div id="emptyState" hidden>
@@ -738,37 +850,48 @@ body.run-open #sessSheet{right:318px}
738
850
  ≥1180 默认展开并推开正文;<1180 收起为顶栏「运行」chip,点开是覆盖式抽屉。无新增轮询。 -->
739
851
  <aside id="runPanel" aria-label="本轮运行" hidden>
740
852
  <div class="rp-head"><span class="rp-live" aria-hidden="true"></span><span>本轮运行</span><span class="rp-sub">指标口径:本轮(非累计)</span><button id="rpCollapse" type="button" title="收起右栏">收起</button></div>
741
- <!-- §3.82:状态条(阶段/首帧/吞吐/工具数/上下文/费用)留在顶部,不随标签切换。 -->
742
- <div class="rp-top">
743
- <section class="rp-sec">
744
- <div class="rp-sec-title">会话配置<span class="line"></span></div>
745
- <dl class="rp-kv">
746
- <dt>模型</dt><dd><span id="rpModel">—</span><span class="rp-src" id="rpModelSrc">设置</span></dd>
747
- <dt>API id</dt><dd><span class="rp-id" id="rpModelId">—</span></dd>
748
- <dt>预设</dt><dd><span id="rpPreset">无预设</span><span class="rp-src">本轮</span></dd>
749
- <dt>权限</dt><dd><span id="rpPerm">—</span><span class="rp-src" id="rpPermSrc">—</span></dd>
750
- </dl>
751
- </section>
752
- <section class="rp-sec">
753
- <div class="rp-sec-title">本轮数据<span class="line"></span></div>
754
- <div class="rp-metrics">
755
- <div class="rp-metric"><span class="l">阶段</span><span class="v" data-rk="phase" id="rpPhase">空闲</span></div>
756
- <div class="rp-metric"><span class="l">首帧 TTFT</span><span class="v" data-rk="ttft" id="rpTtft">—</span></div>
757
- <div class="rp-metric"><span class="l">吞吐</span><span class="v" data-rk="tps"><span id="rpTps">—</span><small>tok/s</small></span></div>
758
- <div class="rp-metric"><span class="l">工具步数</span><span class="v" data-rk="tools" id="rpTools">0</span></div>
759
- <div class="rp-metric"><span class="l">本轮用时</span><span class="v" id="rpTime">—</span></div>
760
- <div class="rp-metric wide"><span class="l">上下文占用</span><span class="v" data-rk="ctx" id="rpCtx">—</span><div class="rp-ctxbar"><i id="rpCtxBar"></i></div></div>
761
- <div class="rp-metric wide"><span class="l">本轮费用(估算)</span><span class="v" data-rk="cost" id="rpCost">—</span></div>
762
- </div>
763
- </section>
764
- </div>
765
- <!-- §3.82 第二步:四个标签。只切 display(railTabDisplay),节点不销毁、id 不变。 -->
853
+ <!-- §3.82:空结构占位(hidden)。真实的「会话配置 + 本轮数据」按 §3.90 收进下方可折叠
854
+ 「概览」区块;这里保留旧锚点,避免历史回归脚本对 rp-top 位置的断言失效。 -->
855
+ <div class="rp-top" hidden aria-hidden="true"></div>
856
+ <!-- §3.82 第二步:四个标签。只切 display(railTabDisplay),节点不销毁、id 不变。
857
+ §3.90:标签栏上移到「本轮运行」标题行之下,内容区因此拿到更大高度。 -->
766
858
  <div class="rp-tabs" role="tablist" aria-label="右栏标签">
767
859
  <button id="rtTrace" type="button" role="tab" data-tab="trace" aria-selected="true" aria-controls="tabTrace" class="seg on">轨迹</button>
768
860
  <button id="rtSub" type="button" role="tab" data-tab="sub" aria-selected="false" aria-controls="tabSub" class="seg">子Agent</button>
769
861
  <button id="rtTasks" type="button" role="tab" data-tab="tasks" aria-selected="false" aria-controls="tabTasks" class="seg">任务</button>
770
862
  <button id="rtDash" type="button" role="tab" data-tab="dash" aria-selected="false" aria-controls="tabDash" class="seg">仪表盘</button>
771
863
  </div>
864
+ <!-- §3.90:概览默认折叠,折叠时只显示一行摘要;展开后字段与数据来源与旧 rp-top 完全一致。 -->
865
+ <section id="rpOverview" class="rp-overview" aria-label="概览">
866
+ <button id="rpOverviewHead" type="button" aria-expanded="false" aria-controls="rpOverviewBody" title="展开概览(会话配置 + 本轮数据)">
867
+ <span class="rp-ov-title">概览</span>
868
+ <span id="rpOverviewSum" class="rp-ov-sum">—</span>
869
+ <span class="rp-ov-tri" aria-hidden="true"></span>
870
+ </button>
871
+ <div id="rpOverviewBody" hidden>
872
+ <section class="rp-sec">
873
+ <div class="rp-sec-title">会话配置<span class="line"></span></div>
874
+ <dl class="rp-kv">
875
+ <dt>模型</dt><dd><span id="rpModel">—</span><span class="rp-src" id="rpModelSrc">设置</span></dd>
876
+ <dt>API id</dt><dd><span class="rp-id" id="rpModelId">—</span></dd>
877
+ <dt>预设</dt><dd><span id="rpPreset">无预设</span><span class="rp-src">本轮</span></dd>
878
+ <dt>权限</dt><dd><span id="rpPerm">—</span><span class="rp-src" id="rpPermSrc">—</span></dd>
879
+ </dl>
880
+ </section>
881
+ <section class="rp-sec">
882
+ <div class="rp-sec-title">本轮数据<span class="line"></span></div>
883
+ <div class="rp-metrics">
884
+ <div class="rp-metric"><span class="l">阶段</span><span class="v" data-rk="phase" id="rpPhase">空闲</span></div>
885
+ <div class="rp-metric"><span class="l">首帧 TTFT</span><span class="v" data-rk="ttft" id="rpTtft">—</span></div>
886
+ <div class="rp-metric"><span class="l">吞吐</span><span class="v" data-rk="tps"><span id="rpTps">—</span><small>tok/s</small></span></div>
887
+ <div class="rp-metric"><span class="l">工具步数</span><span class="v" data-rk="tools" id="rpTools">0</span></div>
888
+ <div class="rp-metric"><span class="l">本轮用时</span><span class="v" id="rpTime">—</span></div>
889
+ <div class="rp-metric wide"><span class="l">上下文占用</span><span class="v" data-rk="ctx" id="rpCtx">—</span><div class="rp-ctxbar"><i id="rpCtxBar"></i></div></div>
890
+ <div class="rp-metric wide"><span class="l">本轮费用(估算)</span><span class="v" data-rk="cost" id="rpCost">—</span></div>
891
+ </div>
892
+ </section>
893
+ </div>
894
+ </section>
772
895
  <div class="rp-tabbody">
773
896
  <section id="tabTrace" class="rp-tab" role="tabpanel" aria-labelledby="rtTrace" data-tab="trace">
774
897
  <!-- §3.73:轨迹折叠区(renderTracePanels 写入 #rpTraceList,不在这里重写)。 -->