dsh-activity-pane 0.7.0 → 0.9.0

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/src/client.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  // dsh-activity-pane 浏览器运行时。
2
2
  //
3
- // 挂载策略:把窗格作为 AppFrame 中 `conversation` 槽座的前置兄弟列插入
4
- // (`#root [data-slot="conversation"] || .parentElement` 即 flex 行),让外壳的
5
- // 让步链挤压中间栏;窄屏(<=767px)转为固定抽屉 + 浮动开关按钮。
3
+ // 挂载策略:把窗格作为 AppFrame 中 keyed `main` 槽容器的前置兄弟列插入
4
+ // (`#root [data-slot="main"] || .parentElement` 即中列 flex,dsh 0.1.5 起原
5
+ // `conversation` Slot 迁移为 `main` 的 `conversation` key),让外壳的让步链
6
+ // 挤压中间栏;窄屏(<=767px)转为固定抽屉 + 浮动开关按钮。
6
7
  //
7
- // 数据来源:DSH 原生 `sessions` / `workspaces` 客户端服务(推送式快照)+ native
8
- // `sessions.history` / `sessions.models` 冷会话读取 + 运行中会话的原生订阅
9
- //(binding().session)+ 可选 `modelDirectories` 目录 store 订阅(模型选择实时更新,
10
- // 缺失时回落一次性读取),不依赖任何第三方插件数据路由,也不做状态轮询。
8
+ // 数据来源:DSH 原生 `sessions` / `workspaces` 客户端服务(推送式快照)+ `remote`
9
+ // 服务的部署级模型目录(session/modelCatalog)与会话日志分页(session/page,
10
+ // Session 格式 V3——dsh 0.1.5 起快照不再携带会话内容、connection.api 门面移除)+
11
+ // 运行中会话的原生订阅(binding().session,运行状态面)+ 可选 `modelDirectories`
12
+ // 目录 store 订阅(模型选择实时更新),不依赖任何第三方插件数据路由,也不做状态轮询。
11
13
 
12
14
  const name = "dsh-activity-pane";
13
- const inject = ["connection", "sessions", "workspaces"];
15
+ // remote.session:dsh 0.1.5 起会话 RPC(目录/日志分页)经 api-remotes 的点分命名空间
16
+ // 注入(cordis 代理对未注入属性直接抛错,父服务声明不代表子命名空间可用;仅声明
17
+ // 子命名空间即可解析,无需再注入父面)。
18
+ const inject = ["sessions", "workspaces", "uiSession", "remote.session"];
14
19
 
15
- const CONVERSATION_SELECTOR = "#root [data-slot=\"conversation\"]";
20
+ const CONVERSATION_SELECTOR = "#root [data-slot=\"main\"]";
16
21
  const PANE_ATTR = "data-dsh-activity-pane";
17
22
  const PANE_CLASS = "dap-pane";
18
23
  const LIST_CLASS = "dap-list";
@@ -24,7 +29,7 @@ const INSTANCE_KEY = "__dshActivityPaneCleanup";
24
29
  const WIDTH_STORAGE_KEY = "dsh-activity-pane:width";
25
30
  const COLLAPSED_WIDTH = 34;
26
31
  /** 宿主侧完成确认 API 前缀(C-030):acks 快照 / SSE 推送 / ack 写回,同源受信。 */
27
- const ACK_API_BASE = "/dsh-activity-pane/api";
32
+ const PANE_API_BASE = "/dsh-activity-pane/api";
28
33
  // 缩进槽宽:与连接线 CSS 几何耦合(left:-8px = INDENT_PX/2 缩进槽中线,
29
34
  // top:-6px/bottom:-2px 对应 .dap-list 的 gap:6px),改任一数值须三处同步;
30
35
  // scripts/check.mjs 有钉住断言。
@@ -432,14 +437,19 @@ const CSS = `
432
437
  box-shadow: none;
433
438
  animation: none;
434
439
  }
440
+ /* 会话卡标题(活动卡、子代理卡与最近卡共用 .dap-title)统一常规字重,不加粗
441
+ (R-01-013/AC-09 及东家 2026-09-11 视觉反馈);无按卡类的字重覆盖。 */
435
442
  [data-dsh-activity-pane] .dap-title {
436
443
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
437
- white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;
444
+ white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 400;
438
445
  }
439
- /* 最近历史卡标题降为常规字重:历史区不抢占视觉强调(R-01-013/AC-09)。 */
440
- [data-dsh-activity-pane] .dap-card[data-kind="recent"] .dap-title {
441
- font-weight: 400;
446
+ /* 标题行最右侧的累计运行时长(R-01-020/AC-01):固定占位不参与标题挤压,
447
+ 标题过长时以自身省略号让位;色调弱于标题,不与状态点抢视觉。 */
448
+ [data-dsh-activity-pane] .dap-total-time {
449
+ flex: none; margin-left: auto; font-size: 12px; line-height: 16px;
450
+ color: #8a94a3; white-space: nowrap;
442
451
  }
452
+ [data-dsh-activity-pane] .dap-total-time[hidden] { display: none; }
443
453
  /* 等待卡末行首行「类型胶囊」(R-01-002/AC-01、AC-02、AC-09、AC-13,C-043):圆底类型
444
454
  图标 + 类型文字,色相随等待类别(--dap-wait-color)——阻塞金/完成绿/错误红;
445
455
  胶囊为行内元素不自占满宽,随文字内容收缩。 */
@@ -565,6 +575,7 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
565
575
  [data-dsh-activity-pane] .dap-progress {
566
576
  display: flex; align-items: center; gap: 7px; min-width: 0;
567
577
  }
578
+ [data-dsh-activity-pane] .dap-progress[hidden] { display: none; }
568
579
  [data-dsh-activity-pane] .dap-progress .dap-track { flex: 1 1 auto; min-width: 0; }
569
580
  [data-dsh-activity-pane] .dap-pct {
570
581
  flex: none; width: 5ch; font-size: 12px; line-height: 15px; font-weight: 700; text-align: right;
@@ -1035,8 +1046,10 @@ function apply(ctx) {
1035
1046
  let disposed = false;
1036
1047
  let sessions = null;
1037
1048
  let workspaces = null;
1049
+ let uiSession = null;
1038
1050
  let sessionUnsubscribe = null;
1039
1051
  let workspaceUnsubscribe = null;
1052
+ let pendingUnsubscribe = null;
1040
1053
  let clockTimer = null;
1041
1054
  let recentTimeTimer = null;
1042
1055
  let syncScheduled = false;
@@ -1109,12 +1122,24 @@ function apply(ctx) {
1109
1122
  const sessionDetailsById = new Map();
1110
1123
  /** 会话跳转的单一重试链;避免重复点击叠加 refresh/timer。 */
1111
1124
  const openRetryStates = new Map();
1112
- /** native cold-session model/history reads, one promise per session and no polling. */
1125
+ /** 主会话目录一次性 load 的在途记账:id → promise(字段级加载指示消费)。 */
1113
1126
  const modelLoads = new Map();
1127
+ /** native cold-session log reads, one promise per session and no polling. */
1114
1128
  const historyLoads = new Map();
1115
1129
  /** 模型目录订阅(R-01-012/AC-16):id → unsubscribe;模型选择切换经原生
1116
1130
  * modelDirectories store 推送即时到达,随可见性清理/卸载先 unsubscribe 再除名。 */
1117
1131
  const modelDirectorySubs = new Map();
1132
+ const logSourceSubs = new Map();
1133
+ /** 模型目录分组的 modelId → {name, reasoning} 索引(R-01-012/AC-17):同一部署的
1134
+ * 目录分组在主/子会话间共享,经主会话的目录订阅与一次性 models RPC 就地收割,
1135
+ * 渲染时解析显示名与 effort 回退。无原型对象:模型 id 可能恰为 "constructor" 等
1136
+ * 继承键名,不得穿透回退。 */
1137
+ const catalogEntries = Object.create(null);
1138
+ /** 部署级目录分组(dsh 0.1.5 起模型上下文的显示名/effort 解析来源):由
1139
+ * remote.sessions.modelCatalog 与 modelDirectories 订阅推送共同收割。 */
1140
+ let catalogGroups = [];
1141
+ /** 部署级 modelCatalog 一次性读取的在途 Promise:成功即缓存、失败允许重试。 */
1142
+ let catalogPromise = null;
1118
1143
  /** native session.open() requests in flight; avoid duplicate cold history reads. */
1119
1144
  /** 冷数据读取并发池:队列顺序即优先级(调用方已排序),逐个完成逐个重绘。 */
1120
1145
  const loadQueue = [];
@@ -1281,7 +1306,7 @@ function apply(ctx) {
1281
1306
  acksSource = null;
1282
1307
  if (disposed || typeof window.EventSource !== "function") return;
1283
1308
  try {
1284
- const source = new window.EventSource(`${ACK_API_BASE}/acks/stream`);
1309
+ const source = new window.EventSource(`${PANE_API_BASE}/acks/stream`);
1285
1310
  source.addEventListener("state", (event) => applyAcksState(event.data ?? ""));
1286
1311
  acksSource = source;
1287
1312
  } catch {
@@ -1311,7 +1336,7 @@ function apply(ctx) {
1311
1336
  completeAcksById.set(id, { ...prev, lastTurnEnd: prev?.lastTurnEnd ?? null, ackedAt: Date.now() });
1312
1337
  queueSync();
1313
1338
  try {
1314
- const response = await fetch(`${ACK_API_BASE}/ack`, {
1339
+ const response = await fetch(`${PANE_API_BASE}/ack`, {
1315
1340
  method: "POST",
1316
1341
  headers: { "Content-Type": "application/json" },
1317
1342
  body: JSON.stringify({ sessionId: id }),
@@ -1325,8 +1350,137 @@ function apply(ctx) {
1325
1350
  }
1326
1351
  }
1327
1352
 
1328
- function apiValue(response) {
1329
- return response?.result?.ok === true ? response.result.value : null;
1353
+ // ---- 累计运行时长通道(R-01-020,C-074) ----
1354
+ // SSE 订阅宿主侧回合统计:连接即收全量快照(刷新/重连恢复),此后每次变更(回合
1355
+ // 边界、回填完成)推送新全量。无 EventSource 环境静默降级为不显示累计时长。
1356
+ // 可见会话的懒回填经 GET /busy?ids= 触发(fire-and-forget),完成后经同一 SSE 广播。
1357
+ const busyById = new Map(); // sessionId -> { busyMs, openTurnStart }
1358
+ const busyRequestedIds = new Set(); // 已触发过回填的会话 id(宿主侧单飞,本地防重复请求)
1359
+ const busyRetryAtById = new Map(); // sessionId -> 回填失败后的最早重试时刻(30s 退避,不逼近轮询)
1360
+ let busySource = null;
1361
+ let busyFetchInflight = false;
1362
+
1363
+ /** SSE 全量快照应用:接受已解析对象或原始 JSON 文本;解析失败静默丢弃。 */
1364
+ function applyBusyState(input) {
1365
+ if (disposed) return;
1366
+ let state = typeof input === "object" ? input : null;
1367
+ if (state === null) {
1368
+ try {
1369
+ state = JSON.parse(input);
1370
+ } catch {
1371
+ state = null;
1372
+ }
1373
+ }
1374
+ if (state === null || typeof state !== "object") return;
1375
+ busyById.clear();
1376
+ for (const [id, record] of Object.entries(state)) {
1377
+ busyById.set(String(id), {
1378
+ busyMs: normalizeBusyMs(record?.busyMs),
1379
+ openTurnStart: normalizeBusyMs(record?.openTurnStart),
1380
+ waitedMs: normalizeBusyMs(record?.waitedMs),
1381
+ openWaitStart: normalizeBusyMs(record?.openWaitStart),
1382
+ });
1383
+ }
1384
+ queueSync();
1385
+ }
1386
+
1387
+ /** 累计运行时长注入(R-01-020/AC-01、AC-03、AC-07):已完成回合累计 + 开放回合实时
1388
+ * 已耗时(扣除等待区间);渲染期按 now 合成,阻塞等待期间公式自冻结;子代理条目
1389
+ * 不注入(entry.totalBusyMs 保持 undefined → 节点隐藏)。 */
1390
+ function applyTotalBusy(entry, now) {
1391
+ const busyRecord = busyById.get(entry.id);
1392
+ entry.totalBusyMs = totalBusyDisplayMs({
1393
+ busyMs: busyRecord?.busyMs ?? null,
1394
+ openTurnStart: busyRecord?.openTurnStart ?? null,
1395
+ waitedMs: busyRecord?.waitedMs ?? null,
1396
+ openWaitStart: busyRecord?.openWaitStart ?? null,
1397
+ now,
1398
+ });
1399
+ }
1400
+
1401
+ /** 触发可见主会话的懒回填:只对未请求过的 id 发一次 GET;在途时跳过(SSE 广播兜底)。 */
1402
+ function requestBusyBackfill(ids) {
1403
+ if (disposed || typeof fetch !== "function") return;
1404
+ const pending = ids.filter((id) => !busyRequestedIds.has(id) && !busyById.has(id) && Date.now() >= (busyRetryAtById.get(id) ?? 0));
1405
+ if (pending.length === 0 || busyFetchInflight) return;
1406
+ for (const id of pending) busyRequestedIds.add(id);
1407
+ busyFetchInflight = true;
1408
+ fetch(`${PANE_API_BASE}/busy?ids=${encodeURIComponent(pending.join(","))}`)
1409
+ .then((response) => (response.ok ? response.json() : null))
1410
+ .then((state) => {
1411
+ if (state !== null && typeof state === "object") applyBusyState(state);
1412
+ })
1413
+ .catch(() => {
1414
+ // 回填触发失败:30s 退避后允许重试,不逐帧逼近轮询形态。
1415
+ const retryAt = Date.now() + 30_000;
1416
+ for (const id of pending) {
1417
+ busyRequestedIds.delete(id);
1418
+ busyRetryAtById.set(id, retryAt);
1419
+ }
1420
+ })
1421
+ .finally(() => {
1422
+ busyFetchInflight = false;
1423
+ });
1424
+ }
1425
+
1426
+ /** (重)建 SSE 连接:先关闭旧连接再新建;连接即收宿主侧全量快照。 */
1427
+ function connectBusyStream() {
1428
+ try {
1429
+ busySource?.close();
1430
+ } catch {}
1431
+ busySource = null;
1432
+ if (disposed || typeof window.EventSource !== "function") return;
1433
+ try {
1434
+ const source = new window.EventSource(`${PANE_API_BASE}/busy/stream`);
1435
+ source.addEventListener("state", (event) => applyBusyState(event.data ?? ""));
1436
+ busySource = source;
1437
+ } catch {
1438
+ busySource = null;
1439
+ }
1440
+ }
1441
+ // 回到前台(含 bfcache 还原)时 busy 通道自愈:重建 SSE,借连接全量快照收敛;
1442
+ // 具名 handler 使 cleanup 能移除监听(与 acks 通道同模式,R-02-003)。
1443
+ const onBusyVisibilityResume = () => {
1444
+ if (document.visibilityState === "visible" && !disposed) connectBusyStream();
1445
+ };
1446
+ const onBusyPageShow = (event) => {
1447
+ if (event?.persisted === true && !disposed) connectBusyStream();
1448
+ };
1449
+ document.addEventListener("visibilitychange", onBusyVisibilityResume);
1450
+ window.addEventListener("pageshow", onBusyPageShow);
1451
+ connectBusyStream();
1452
+
1453
+ function remoteValue(response) {
1454
+ if (response?.ok === true) return response.value;
1455
+ throw response?.error ?? new Error("remote request failed");
1456
+ }
1457
+
1458
+ /** 会话日志分页地址(R-01-012):主会话 `{kind:"session", sessionId}`;子代理
1459
+ * `{kind:"subagent", parentSessionId, childSessionId, mode}`——母会话 id 兼容
1460
+ * `parentSessionId` / `parentId` 两种条目键名,mode 无条目标注时按一次性子代理
1461
+ * 处理、读取失败由 pagedHistoryEvents 以 null 收敛为空白详情(R-01-013)。 */
1462
+ function sessionPageAddress(id, byId) {
1463
+ const row = byId[id] ?? {};
1464
+ if (row?.origin === "subagent") {
1465
+ const parentId = row.parentSessionId ?? row.parentId;
1466
+ if (parentId !== undefined && parentId !== null) {
1467
+ return {
1468
+ kind: "subagent",
1469
+ parentSessionId: String(parentId),
1470
+ childSessionId: String(id),
1471
+ mode: row.continuable === true ? "continuable" : "one-shot",
1472
+ };
1473
+ }
1474
+ }
1475
+ return { kind: "session", sessionId: String(id) };
1476
+ }
1477
+
1478
+ /** 部署级目录分组收割(R-01-012/AC-01):modelCatalog RPC 与 modelDirectories
1479
+ * 目录订阅推送共享同一部署目录,后到覆盖(分组集合一致);展平索引进
1480
+ * `catalogEntries` 供子代理溯源 id 解析显示名。空分组不覆盖既有缓存。 */
1481
+ function harvestCatalog(groups) {
1482
+ if (Array.isArray(groups) && groups.length > 0) catalogGroups = groups;
1483
+ Object.assign(catalogEntries, catalogModelEntries(groups));
1330
1484
  }
1331
1485
 
1332
1486
  /** 同帧重启等待提醒动画:数量胶囊保持亮度呼吸,卡片末行保持 opacity 脉冲,
@@ -1351,16 +1505,17 @@ function apply(ctx) {
1351
1505
  try {
1352
1506
  directory = ctx.get("modelDirectories")?.directoryFor?.(id) ?? null;
1353
1507
  } catch {
1354
- directory = null; // 会话无 scope:回落一次性读取
1508
+ directory = null; // 会话无 scope:回落日志提取
1355
1509
  }
1356
1510
  if (directory === null) return;
1357
1511
  const syncFromDirectory = () => {
1358
1512
  if (disposed) return;
1359
1513
  const snap = directory.store?.getSnapshot?.();
1360
1514
  if (!snap?.current) return; // 目录未就绪:不覆写既有取值
1515
+ harvestCatalog(snap.groups);
1361
1516
  detail.models = { current: snap.current, groups: snap.groups ?? [] };
1362
1517
  detail.model = modelMetadata(detail.models);
1363
- // 订阅已产值标记:晚到的一次性 RPC 快照不得回写切换前的旧值。
1518
+ // 订阅已产值标记:晚到的一次性快照不得回写切换前的旧值。
1364
1519
  detail.modelLive = true;
1365
1520
  queueSync();
1366
1521
  };
@@ -1368,17 +1523,48 @@ function apply(ctx) {
1368
1523
  try {
1369
1524
  unsubscribe = directory.store.subscribe(syncFromDirectory);
1370
1525
  } catch {
1371
- return; // 订阅失败:保持一次性读取结果
1526
+ return; // 订阅失败:保持日志提取结果
1372
1527
  }
1373
1528
  modelDirectorySubs.set(id, unsubscribe);
1374
- syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性 RPC
1529
+ syncFromDirectory(); // 目录已被主窗口加载时立即同步,该会话免发一次性读取
1530
+ }
1531
+
1532
+ /** 目录 store 一次性 load(dsh 0.1.5 起惰性加载:不 load 不产出当前选择,原生模型
1533
+ * 选择器同样先 load)。load 与 select() 的 generation 竞争以「最新操作胜出」,用户
1534
+ * 切换后到仍胜出。e2e 接缝(dap-e2e-model-delay)延迟 load 发起,使模型上下文
1535
+ * 严格晚于时间线呈现,渐进渲染可观察(R-01-014/AC-03)。 */
1536
+ function loadDirectoryOnce(id) {
1537
+ if (disposed || !modelDirectorySubs.has(id) || modelLoads.has(id)) return;
1538
+ let directory = null;
1539
+ try {
1540
+ directory = ctx.get("modelDirectories")?.directoryFor?.(id) ?? null;
1541
+ } catch {
1542
+ directory = null;
1543
+ }
1544
+ if (directory === null) return;
1545
+ try {
1546
+ const loadPromise = delayedModelCall(() => (typeof directory.load === "function" ? directory.load() : null));
1547
+ if (loadPromise && typeof loadPromise.finally === "function") {
1548
+ modelLoads.set(id, loadPromise);
1549
+ // 守卫只在失败时释放(放行下一次渲染重试);成功后保持到可见性 prune,
1550
+ // 兑现一次性 load 语义。若 settle 即释放,目录 store 的每次广播
1551
+ // (syncInputs 无条件以新引用 set)都会再驱动 render → load → 广播,
1552
+ // 形成 rAF 速率反馈环——空闲期 60fps 满载,手机端表现为持续发烫。
1553
+ loadPromise.catch(() => {
1554
+ if (modelLoads.get(id) === loadPromise) modelLoads.delete(id);
1555
+ });
1556
+ }
1557
+ loadPromise?.catch(() => {});
1558
+ } catch {
1559
+ // load 不可用:保持订阅,等待主窗口加载后的推送。
1560
+ }
1375
1561
  }
1376
1562
 
1377
- function loadNativeDetails(ids, previewFallbackIds = new Set(), durationFallbackIds = new Set()) {
1378
- const api = ctx.get("connection")?.api?.sessions;
1379
- if (!api) return;
1563
+ function loadNativeDetails({ ids, previewFallbackIds = new Set(), durationFallbackIds = new Set(), subagentModelReadIds = new Set() }) {
1564
+ const sessionRemote = ctx.get("remote.session") ?? null;
1565
+ if (!sessionRemote) return;
1566
+ ensureCatalogGroups(sessionRemote);
1380
1567
  const byId = getSnapshot(sessions, "list")?.byId ?? {};
1381
- const modelPromises = [];
1382
1568
  const historyPromises = [];
1383
1569
  for (const id of ids) {
1384
1570
  const detail = sessionDetailsById.get(id) ?? {};
@@ -1386,80 +1572,55 @@ function apply(ctx) {
1386
1572
  if (liveSnapshot) detail.snapshot = liveSnapshot;
1387
1573
  sessionDetailsById.set(id, detail);
1388
1574
  const subagent = isSubagentRow(byId[id], byId);
1389
- // 主会话先试模型目录订阅:store 已有当前选择时同步填充(随后 plan.model 为假、免发 RPC);
1390
- // 子代理的目录不可用(宿主以 agent-busy 拒绝其模型 RPC),不建立订阅。
1391
- if (!subagent && e2eModelDelayMs === 0) subscribeModelDirectory(id, detail);
1392
- const snapshotReady = detail.snapshot?.openState === "open";
1393
- // 冷窗口兜底(R-01-009/AC-06、R-01-012/AC-12):快照就绪但窗口缺开放回合起点
1394
- // (超长回合的 turn/start 在尾页窗口之外;等待/空闲会话不算缺口)或缺可锚
1395
- // 用户行时,补读一次 history。
1396
- const turnStartMissing = openTurnStartMissing({
1397
- snapshotReady,
1398
- running: byId[id]?.running === true,
1399
- hasLiveness: livenessById.has(id),
1400
- liveStartTime: livenessById.get(id)?.liveness?.startTime ?? null,
1401
- });
1402
- const windowComplete = snapshotReady !== true || (!turnStartMissing && detail.snapshotHasAnchorableUserRow === true);
1403
- const durationFallbackNeeded = durationFallbackIds.has(id) && detail.durationFallbackLoaded !== true;
1404
- const plan = detailLoadPlan({
1405
- detail,
1406
- isSubagent: subagent,
1407
- snapshotReady,
1408
- historyNeeded: needsHistorySnapshot(detail.snapshot),
1409
- previewFallbackNeeded: previewFallbackIds.has(id),
1410
- durationFallbackNeeded,
1411
- windowComplete,
1412
- modelInflight: modelLoads.has(id),
1413
- historyInflight: historyLoads.has(id) || sessionOpenLoads.has(id),
1414
- });
1415
- if (plan.subagent) {
1416
- // 子代理的 models 读取必被宿主以 agent-busy 拒绝:直接留空,不发注定失败的 RPC。
1417
- detail.model ??= { model: "", reasoning: "" };
1418
- } else if (plan.model && typeof api.models === "function") {
1419
- const promise = enqueueDetailLoad(() => delayedModelCall(() => api.models({ sessionId: id }))
1420
- .then((response) => {
1421
- const value = apiValue(response);
1422
- if (!value) {
1423
- if (!detail.modelLive) detail.model = { model: "", reasoning: "" };
1424
- return;
1425
- }
1426
- // 目录订阅已产出更新的当前选择时,晚到的 RPC 快照不得回写旧值(R-01-012/AC-16)。
1427
- if (detail.modelLive) return;
1428
- detail.models = value;
1429
- detail.model = modelMetadata(value);
1430
- })
1431
- .catch((error) => {
1432
- if (!detail.modelLive) detail.model = { model: "", reasoning: "" };
1433
- detail.modelError = error instanceof Error ? error.message : String(error);
1434
- }));
1435
- modelLoads.set(id, promise);
1436
- // settle 即移除记账:在途判定驱动加载指示,残留会让空字段永久误报加载。
1437
- promise.finally(() => {
1438
- if (modelLoads.get(id) === promise) modelLoads.delete(id);
1439
- });
1440
- modelPromises.push(promise);
1575
+ // 主会话建立模型目录订阅:dsh 0.1.5 起目录 store 惰性加载(不 load 不产出当前
1576
+ // 选择),订阅与一次性 load 一并触发;子代理的目录不可用(宿主以 agent-busy
1577
+ // 拒绝其模型 RPC),不建立订阅。e2e 接缝把订阅与 load 整体延后,使模型上下文
1578
+ // 严格晚于时间线呈现(R-01-014/AC-03 渐进语义可观察)。
1579
+ if (!subagent) {
1580
+ if (e2eModelDelayMs === 0) {
1581
+ subscribeModelDirectory(id, detail);
1582
+ loadDirectoryOnce(id);
1583
+ } else {
1584
+ delayedModelCall(() => {
1585
+ subscribeModelDirectory(id, detail);
1586
+ loadDirectoryOnce(id);
1587
+ return null;
1588
+ });
1589
+ }
1441
1590
  }
1442
- if (plan.history && typeof api.history === "function") {
1443
- if (durationFallbackNeeded) detail.durationFallbackLoaded = true;
1444
- if (previewFallbackIds.has(id)) detail.previewFallbackLoaded = true;
1591
+ captureSessionLog(id, { subagent, cwd: byId[id]?.cwd ?? "" });
1592
+ // 长会话深读兜底(R-01-013/AC-03):最近卡预览/子代理溯源不在尾页日志窗口内
1593
+ // 且宿主标记 hasMore 时,按 beforeSeq 向前回溯翻页(默认无页数上限)。
1594
+ const windowEntries = Array.isArray(detail.log?.entries) ? detail.log.entries : [];
1595
+ const lastSeq = Number(windowEntries.at(-1)?.event?.seq);
1596
+ const deepReadNeeded =
1597
+ (previewFallbackIds.has(id) || durationFallbackIds.has(id) || (subagent && subagentModelReadIds.has(id))) &&
1598
+ detail.log?.hasMore === true &&
1599
+ Number.isFinite(lastSeq) &&
1600
+ detail.historyDeepReadDone !== true;
1601
+ // 在途守卫:深翻未在途才入队。渲染逐帧发生而 deepReadNeeded 在深翻完成前恒真,
1602
+ // 无守卫时每帧重复入队新 job,队列以 LOAD_CONCURRENCY 并发放大发——同一会话的
1603
+ // page RPC 风暴,且 historyLoads 的 set/delete 随 job 翻转驱动加载指示高频闪烁。
1604
+ if (deepReadNeeded && !historyLoads.has(id)) {
1605
+ detail.previewFallbackLoaded = true;
1606
+ detail.durationFallbackLoaded = true;
1607
+ const address = sessionPageAddress(id, byId);
1445
1608
  const promise = enqueueDetailLoad(() => Promise.resolve()
1446
1609
  .then(async () => {
1447
- // 单池任务内串行回溯深翻(默认无页数上限):向前翻到命中最近一条
1448
- // 用户消息或翻尽为止——超长会话的最后用户消息可能在尾页窗口之外,
1449
- // 固定页数上限会让历史卡用户预览永久缺失(R-01-013/AC-03 回溯承诺)。
1450
- // 运行会话缺窗口内回合起点时要求深翻至命中开放回合 turn/start
1451
- // (R-01-009/AC-06 冷窗口兜底)。
1452
1610
  const { events, error } = await pagedHistoryEvents({
1453
- fetchPage: async (beforeSeq) => apiValue(await api.history({ sessionId: id, beforeSeq, maxMessages: 50 })),
1454
- requireOpenTurnStart: turnStartMissing,
1611
+ fetchPage: async (beforeSeq) => {
1612
+ const value = remoteValue(
1613
+ await sessionRemote.page({ address, throughSeq: lastSeq, beforeSeq, maxMessages: 50 }),
1614
+ );
1615
+ if (!value) return null;
1616
+ return { events: Array.isArray(value.records) ? value.records : [], hasMore: value.hasMore === true };
1617
+ },
1455
1618
  });
1456
1619
  if (error) detail.historyError = error instanceof Error ? error.message : String(error);
1457
1620
  detail.history = events;
1458
- // R-01-017:冷路径同样折叠分组(取全量页内事件再折成最多 4 组,
1459
- // 含指令锚行窗口选择,R-01-012/AC-12~AC-15)。
1460
- detail.timeline = foldedHistoryTimeline(events, 4, byId[id]?.cwd ?? "");
1461
- detail.previews = messagePreviews({ history: events });
1462
- }))
1621
+ detail.historyDeepReadDone = true;
1622
+ applyLogEvents(id, detail, events, { subagent, cwd: byId[id]?.cwd ?? "", planSubagentModelRead: subagent });
1623
+ }));
1463
1624
  historyLoads.set(id, promise);
1464
1625
  promise.finally(() => {
1465
1626
  if (historyLoads.get(id) === promise) historyLoads.delete(id);
@@ -1467,7 +1628,7 @@ function apply(ctx) {
1467
1628
  historyPromises.push(promise);
1468
1629
  }
1469
1630
  }
1470
- const pending = modelPromises.concat(historyPromises);
1631
+ const pending = historyPromises;
1471
1632
  if (pending.length > 0) {
1472
1633
  // 逐个完成即重绘(先就绪先显示,不等待全部,R-01-014/AC-03);
1473
1634
  // 并立即重绘一次让加载指示在数据返回前出现。
@@ -1476,15 +1637,115 @@ function apply(ctx) {
1476
1637
  }
1477
1638
  }
1478
1639
 
1640
+ /** 日志页落地后的详情派生(R-01-012、R-01-017):时间线折叠、消息预览与子代理模型上下文。
1641
+ * 子代理沿用溯源链(assistant/message source.model + 请求头 effort,T-122);主会话模型
1642
+ * 不经日志提取(目录链订阅 + 一次性 load 承载,见 installServiceSubscriptions 注)。
1643
+ * 无请求头/无溯源时保持空白,不以部署默认或其它会话取值冒充(R-01-012/AC-18)。 */
1644
+ function applyLogEvents(id, detail, events, { subagent, cwd = "", planSubagentModelRead = false }) {
1645
+ detail.history = events;
1646
+ detail.timeline = foldedHistoryTimeline(events, 4, cwd);
1647
+ detail.previews = messagePreviews({ history: events });
1648
+ if (subagent) {
1649
+ if (planSubagentModelRead) {
1650
+ detail.modelReadDone = true;
1651
+ if (!detail.model) detail.model = modelFromHistoryEvents(events);
1652
+ } else if (!detail.model) {
1653
+ detail.model = modelFromHistoryEvents(events);
1654
+ }
1655
+ if (detail.model && !detail.model.reasoning) {
1656
+ detail.model.reasoning = reasoningEffortFromHistoryEvents(events) ?? "";
1657
+ }
1658
+ return;
1659
+ }
1660
+ // 主会话模型上下文不经日志提取:以 modelDirectories 目录订阅为准(实时推送
1661
+ // 当前选择),不可得时保持空白,不以日志历史取值或部署默认冒充(R-01-012/AC-18)。
1662
+ }
1663
+
1664
+ /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
1665
+ * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
1666
+ * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
1667
+ function captureSessionLog(id, { subagent, cwd }) {
1668
+ const detail = sessionDetailsById.get(id) ?? {};
1669
+ sessionDetailsById.set(id, detail);
1670
+ let session = null;
1671
+ try {
1672
+ session = sessions?.binding?.(id)?.session ?? null;
1673
+ } catch {
1674
+ session = null;
1675
+ }
1676
+ if (session === null) return;
1677
+ // 日志窗口无效化订阅(事件驱动,不构成轮询,R-02-004):等待/历史卡无会话状态
1678
+ // 订阅(syncLiveness 只订阅运行中),窗口推进(回合收尾、流式尾)必须经此回调
1679
+ // 重读快照并重绘;否则窗口更新只能靠渲染期重读兜底——渲染一旦静默(一次性
1680
+ // load 修复后),完成提醒耗时等窗口派生数据将永久停摆。随可见性 prune。
1681
+ if (typeof session.eventSource?.subscribe === "function" && !logSourceSubs.has(id)) {
1682
+ try {
1683
+ logSourceSubs.set(
1684
+ id,
1685
+ session.eventSource.subscribe(() => {
1686
+ if (disposed) return;
1687
+ const listSnap = getSnapshot(sessions, "list");
1688
+ captureSessionLog(id, {
1689
+ subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}),
1690
+ cwd: listSnap?.byId?.[id]?.cwd ?? "",
1691
+ });
1692
+ queueSync();
1693
+ }),
1694
+ );
1695
+ } catch {
1696
+ // 订阅不可用:回退渲染期重读。
1697
+ }
1698
+ }
1699
+ try {
1700
+ // 日志窗口缺席才发起 open:非订阅会话(等待/历史卡)无快照推送,openState 永远
1701
+ // 不可知,只以 openState 把关会对已水合会话逐帧重发 open,settle→delete→重发
1702
+ // 的在途翻转同样驱动加载指示抖动(docsim 卡闪烁根因之一)。
1703
+ if (!detail.log && detail.snapshot?.openState !== "open" && !sessionOpenLoads.has(id) && typeof session.open === "function") {
1704
+ const opening = Promise.resolve(session.open()).catch(() => {});
1705
+ sessionOpenLoads.set(id, opening);
1706
+ opening.finally(() => {
1707
+ if (sessionOpenLoads.get(id) === opening) sessionOpenLoads.delete(id);
1708
+ queueSync();
1709
+ });
1710
+ }
1711
+ } catch {
1712
+ // open 不可用:保持当前窗口。
1713
+ }
1714
+ const log = session.eventSource?.getSnapshot?.() ?? null;
1715
+ if (log === detail.log) return;
1716
+ detail.log = log;
1717
+ const entries = Array.isArray(log?.entries) ? log.entries : [];
1718
+ applyLogEvents(id, detail, entries, { subagent, cwd });
1719
+ }
1720
+
1721
+ /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
1722
+ * RPC 移除,目录经 `remote.session.modelCatalog()` 无参数读取;成功即缓存不再
1723
+ * 重发,失败留待下轮详情读取重试。 */
1724
+ function ensureCatalogGroups(sessionRemote) {
1725
+ if (catalogGroups.length > 0 || catalogPromise !== null) return;
1726
+ catalogPromise = Promise.resolve()
1727
+ .then(() => remoteValue(sessionRemote.modelCatalog()))
1728
+ .then((value) => {
1729
+ if (value && Array.isArray(value.groups)) harvestCatalog(value.groups);
1730
+ })
1731
+ .catch(() => {})
1732
+ .finally(() => {
1733
+ catalogPromise = null;
1734
+ });
1735
+ }
1736
+
1479
1737
  function installServiceSubscriptions() {
1480
1738
  const nextSessions = ctx.get("sessions");
1481
1739
  const nextWorkspaces = ctx.get("workspaces");
1482
- if (nextSessions === sessions && nextWorkspaces === workspaces) return;
1740
+ const nextUiSession = ctx.get("uiSession");
1741
+ if (nextSessions === sessions && nextWorkspaces === workspaces && nextUiSession === uiSession) return;
1483
1742
 
1484
1743
  sessionUnsubscribe?.();
1485
1744
  workspaceUnsubscribe?.();
1745
+ pendingUnsubscribe?.();
1486
1746
  sessions = nextSessions ?? null;
1487
1747
  workspaces = nextWorkspaces ?? null;
1748
+ uiSession = nextUiSession ?? null;
1488
1749
  try {
1489
1750
  sessionUnsubscribe = sessions?.list?.subscribe?.(queueSync) ?? null;
1490
1751
  } catch {
@@ -1495,6 +1756,14 @@ function apply(ctx) {
1495
1756
  } catch {
1496
1757
  workspaceUnsubscribe = null;
1497
1758
  }
1759
+ try {
1760
+ // 0.1.5 起 sessions 行不再承载 pendingInteraction:待确认/待审查/待回复改由
1761
+ // uiSession.pendingInteractions(Map<sessionId, interaction>)独立承载,
1762
+ // 订阅其快照变化驱动等待卡重算(R-01-002/AC-03)。
1763
+ pendingUnsubscribe = uiSession?.pendingInteractions?.subscribe?.(queueSync) ?? null;
1764
+ } catch {
1765
+ pendingUnsubscribe = null;
1766
+ }
1498
1767
 
1499
1768
  queueSync();
1500
1769
  }
@@ -1750,6 +2019,23 @@ function apply(ctx) {
1750
2019
  foot.append(capsule, noteRow);
1751
2020
  }
1752
2021
 
2022
+ /** 进度行骨架(运行卡与子代理卡共用,R-01-009/AC-06、AC-14):可伸缩轨道 + 固定宽百分比。 */
2023
+ function makeProgressRow() {
2024
+ const track = makeEl("div", "dap-track");
2025
+ track.append(makeEl("div", "dap-fill"));
2026
+ const progressRow = makeEl("div", "dap-progress");
2027
+ progressRow.append(track, makeEl("span", "dap-pct"));
2028
+ return progressRow;
2029
+ }
2030
+
2031
+ /** 统计行骨架:左列 token/速率/命中率(超长省略号截断),时长固定最右(R-01-009/AC-05)。 */
2032
+ function makeStatsRow() {
2033
+ const statsRow = makeEl("div", "dap-token-stats");
2034
+ statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
2035
+ statsRow.hidden = true;
2036
+ return statsRow;
2037
+ }
2038
+
1753
2039
  /** 静态骨架卡片;动态文本一律走 textContent,规避 HTML 注入。 */
1754
2040
  function cardChildren(kind) {
1755
2041
  const head = makeEl("div", "dap-card-head");
@@ -1757,15 +2043,22 @@ function apply(ctx) {
1757
2043
  const workspaceIcon = makeEl("span", "dap-workspace-icon");
1758
2044
  workspaceIcon.append(createWorkspaceFolderIcon());
1759
2045
  workspace.append(workspaceIcon, makeEl("span", "dap-workspace-text"));
1760
- head.append(workspace, makeEl("div", "dap-model"));
2046
+ const model = makeEl("div", "dap-model");
2047
+ head.append(workspace, model);
1761
2048
  if (kind === "subagent") {
1762
2049
  const row = makeEl("div", "dap-row");
1763
- row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
1764
- return [head, row, makeEl("div", "dap-subtrace")];
2050
+ // 模型名与标题同行右对齐(东家反馈):子代理卡无工作区徽标与累计时长,
2051
+ // 标题行右侧即模型位;空值时区域隐藏不占位(R-01-012/AC-17、AC-18)。
2052
+ row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"), model, makeEl("span", "dap-total-time"));
2053
+ // 运行中子代理卡与主会话运行卡同构的进度行与统计行(R-01-009/AC-14);
2054
+ // 非运行时进度行隐藏、统计行冻结(R-01-009/AC-15)。
2055
+ const progressRow = makeProgressRow();
2056
+ progressRow.hidden = true;
2057
+ return [row, makeEl("div", "dap-subtrace"), progressRow, makeStatsRow()];
1765
2058
  }
1766
2059
  if (kind === "recent") {
1767
2060
  const row = makeEl("div", "dap-row");
1768
- row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
2061
+ row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"), makeEl("span", "dap-total-time"));
1769
2062
  const userLine = makeEl("div", "dap-history-line");
1770
2063
  userLine.dataset.role = "user";
1771
2064
  const userIcon = makeEl("span", "dap-history-icon");
@@ -1780,14 +2073,11 @@ function apply(ctx) {
1780
2073
  const agentLabel = makeEl("span", "dap-history-label");
1781
2074
  agentLabel.textContent = "助手";
1782
2075
  agentLine.append(agentIcon, agentLabel, makeEl("span", "dap-history-separator"), makeEl("span", "dap-history-text"));
1783
- const statsRow = makeEl("div", "dap-token-stats");
1784
- statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1785
- statsRow.hidden = true;
1786
- return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];
2076
+ return [head, row, userLine, agentLine, makeStatsRow(), makeEl("div", "dap-note")];
1787
2077
  }
1788
2078
  if (kind === "awaiting") {
1789
2079
  const row = makeEl("div", "dap-row");
1790
- row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
2080
+ row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"), makeEl("span", "dap-total-time"));
1791
2081
  // 等待三类末行结构(R-01-002/AC-08、AC-09,C-043):首行「类型胶囊」(圆底类型
1792
2082
  // 图标 + 类型文字),其下为正文行——阻塞/错误的说明文字,完成提醒的
1793
2083
  // 「继续对话,或移入历史」+ 行尾「移入历史」按钮。
@@ -1798,24 +2088,14 @@ function apply(ctx) {
1798
2088
  noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());
1799
2089
  const awaitHead = makeEl("div", "dap-await-head");
1800
2090
  awaitHead.append(capsule);
1801
- const statsRow = makeEl("div", "dap-token-stats");
1802
- statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1803
- statsRow.hidden = true;
1804
2091
  const foot = makeEl("div", "dap-foot");
1805
2092
  foot.append(awaitHead, noteRow);
1806
- return [head, row, makeEl("div", "dap-trace"), statsRow, foot];
2093
+ return [head, row, makeEl("div", "dap-trace"), makeStatsRow(), foot];
1807
2094
  }
1808
2095
  const row = makeEl("div", "dap-row");
1809
- row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"));
1810
- const track = makeEl("div", "dap-track");
1811
- track.append(makeEl("div", "dap-fill"));
1812
- const progressRow = makeEl("div", "dap-progress");
1813
- progressRow.append(track, makeEl("span", "dap-pct"));
1814
- // 统计行双段结构:左列 token/速率/命中率(超长省略号截断),时长固定最右(R-01-009/AC-05)。
1815
- const statsRow = makeEl("div", "dap-token-stats");
1816
- statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"));
1817
- statsRow.hidden = true;
1818
- return [head, row, makeEl("div", "dap-trace"), progressRow, statsRow];
2096
+ row.append(makeEl("span", "dap-dot"), makeEl("span", "dap-title"), makeEl("span", "dap-total-time"));
2097
+ const progressRow = makeProgressRow();
2098
+ return [head, row, makeEl("div", "dap-trace"), progressRow, makeStatsRow()];
1819
2099
  }
1820
2100
 
1821
2101
  function createInlineIcon({ viewBox, width = 12, height = 12, parts }) {
@@ -2164,6 +2444,29 @@ function apply(ctx) {
2164
2444
  }
2165
2445
  }
2166
2446
 
2447
+ /** 进度行写入(运行卡与子代理卡共用,R-01-009/AC-06、AC-14):百分比与填充宽度,未变化跳过 DOM 写。 */
2448
+ function renderProgressRow(el, progress) {
2449
+ const pct = el.querySelector(".dap-pct");
2450
+ if (pct !== null) pct.textContent = `${Math.round(progress ?? 0)}%`;
2451
+ const fill = el.querySelector(".dap-fill");
2452
+ if (fill !== null) {
2453
+ const width = `${Math.min(100, Math.max(0, progress ?? 0))}%`;
2454
+ if (fill.style.width !== width) fill.style.width = width;
2455
+ }
2456
+ }
2457
+
2458
+ /** 最近回合耗时 memo(等待卡与暂停子代理卡共用,R-01-009/AC-12、AC-15):history 引用
2459
+ * 不变即命中缓存。busy 口径(起止差值扣除回合内阻塞等待),与标题行总耗时同口径,
2460
+ * 保证恒不大于累计值(R-01-020)。 */
2461
+ function memoTurnDuration(detail) {
2462
+ const history = detail.history ?? null;
2463
+ if (detail.memoTurnDurationHistoryOf !== history) {
2464
+ detail.memoTurnDurationHistoryOf = history;
2465
+ detail.memoTurnDuration = lastTurnDuration({ history });
2466
+ }
2467
+ return detail.memoTurnDuration ?? null;
2468
+ }
2469
+
2167
2470
  /** 统计行渲染:运行卡、等待卡与最近卡写入速率、token 与耗时,旧骨架缺节点时就地补齐。 */
2168
2471
  function renderTokenStats(el, entry) {
2169
2472
  let stats = el.querySelector(".dap-token-stats");
@@ -2278,6 +2581,15 @@ function apply(ctx) {
2278
2581
  const title = el.querySelector(".dap-title");
2279
2582
  if (title !== null && title.textContent !== entry.title)
2280
2583
  title.textContent = entry.title;
2584
+ // 标题行最右侧的累计运行时长(R-01-020/AC-01、AC-06):有数据才显示,
2585
+ // 无数据(含子代理卡)隐藏节点,不以 0 冒充。
2586
+ const totalTime = el.querySelector(".dap-total-time");
2587
+ if (totalTime !== null) {
2588
+ const totalText = Number.isFinite(entry.totalBusyMs) && entry.totalBusyMs >= 0 ? fmtElapsedMs(entry.totalBusyMs) : "";
2589
+ if (totalTime.textContent !== totalText) totalTime.textContent = totalText;
2590
+ const totalHidden = totalText === "";
2591
+ if (totalTime.hidden !== totalHidden) totalTime.hidden = totalHidden;
2592
+ }
2281
2593
 
2282
2594
  const capsule = el.querySelector(".dap-capsule");
2283
2595
  // 陈旧骨架就地迁移(C-043 热装兼容):旧版「正文行 + 行尾类型徽标」骨架升级为
@@ -2316,16 +2628,9 @@ function apply(ctx) {
2316
2628
  }
2317
2629
 
2318
2630
  if (entry.kind === "running") {
2319
- const pct = el.querySelector(".dap-pct");
2320
- if (pct !== null)
2321
- pct.textContent = `${Math.round(entry.progress ?? 0)}%`;
2631
+ renderProgressRow(el, entry.progress);
2322
2632
  const traceContainer = el.querySelector(".dap-trace");
2323
2633
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry);
2324
- const fill = el.querySelector(".dap-fill");
2325
- if (fill !== null) {
2326
- const width = `${Math.min(100, Math.max(0, entry.progress ?? 0))}%`;
2327
- if (fill.style.width !== width) fill.style.width = width;
2328
- }
2329
2634
  renderTokenStats(el, entry);
2330
2635
  return;
2331
2636
  }
@@ -2333,6 +2638,17 @@ function apply(ctx) {
2333
2638
  if (entry.kind === "subagent") {
2334
2639
  const traceContainer = el.querySelector(".dap-subtrace");
2335
2640
  if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: true });
2641
+ // 运行中呈现与运行卡同构的进度行;锚点空闲(暂停等待)时整行隐藏(R-01-009/AC-15)。
2642
+ const progressRow = el.querySelector(".dap-progress");
2643
+ if (progressRow !== null) {
2644
+ if (Number.isFinite(entry.progress)) {
2645
+ renderProgressRow(el, entry.progress);
2646
+ if (progressRow.hidden) progressRow.hidden = false;
2647
+ } else if (!progressRow.hidden) {
2648
+ progressRow.hidden = true;
2649
+ }
2650
+ }
2651
+ renderTokenStats(el, entry);
2336
2652
  return;
2337
2653
  }
2338
2654
 
@@ -2420,6 +2736,10 @@ function apply(ctx) {
2420
2736
  const detail = sessionDetailsById.get(id) ?? {};
2421
2737
  detail.snapshot = snapshot;
2422
2738
  sessionDetailsById.set(id, detail);
2739
+ // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
2740
+ // 就地重派生(R-01-009)。
2741
+ const listSnap = getSnapshot(sessions, "list");
2742
+ captureSessionLog(id, { subagent: isSubagentRow(listSnap?.byId?.[id], listSnap ?? {}), cwd: listSnap?.byId?.[id]?.cwd ?? "" });
2423
2743
  queueSync();
2424
2744
  });
2425
2745
  } catch {
@@ -2942,6 +3262,19 @@ function apply(ctx) {
2942
3262
  }
2943
3263
  }
2944
3264
  const listState = listLoadState(snapshot);
3265
+ // 0.1.5 sessions 行不再承载 pendingInteraction:从 uiSession.pendingInteractions
3266
+ // 快照(Map<sessionId, interaction>,kind ∈ approval/plan-review/question)回填到
3267
+ // 行副本上,等待卡分类(awaiting/blocked/提问中)保持既有单一口径(R-01-002/AC-03)。
3268
+ const pendingSnapshot = uiSession?.pendingInteractions?.getSnapshot?.() ?? null;
3269
+ if (pendingSnapshot instanceof Map && pendingSnapshot.size > 0 && snapshot?.byId) {
3270
+ const byId = { ...snapshot.byId };
3271
+ for (const [id, interaction] of pendingSnapshot) {
3272
+ const row = byId[id];
3273
+ if (!isRecord(row) || typeof interaction?.kind !== "string") continue;
3274
+ byId[id] = { ...row, pendingInteraction: interaction.kind };
3275
+ }
3276
+ snapshot = { ...snapshot, byId };
3277
+ }
2945
3278
  // 只消费上一轮追加请求;列表短暂 pending 时保留已展开页,ready 后继续从同一前缀呈现。
2946
3279
  recentAppendQueued = false;
2947
3280
  const workspaceSnapshot = getSnapshot(workspaces, "list");
@@ -2961,24 +3294,42 @@ function apply(ctx) {
2961
3294
  const runLikeIds = new Set(
2962
3295
  active.filter((entry) => shouldSubscribeToSession(entry, snapshot?.byId ?? {})).map((entry) => entry.id),
2963
3296
  );
2964
- syncLiveness(runLikeIds, active.some((entry) => entry.kind === "running"));
3297
+ // 运行卡时钟:运行中子代理卡同样承载逐秒推进的进度与时长(R-01-009/AC-14)。
3298
+ syncLiveness(
3299
+ runLikeIds,
3300
+ active.some((entry) => entry.kind === "running" || (entry.kind === "subagent" && runLikeIds.has(entry.id))),
3301
+ );
2965
3302
  for (const entry of active) {
2966
3303
  const liveRecord = livenessById.get(entry.id);
2967
3304
  const live = liveRecord?.liveness ?? null;
2968
3305
  const detail = sessionDetailsById.get(entry.id);
2969
3306
  if (entry.kind === "running" && detail) detail.durationFallbackLoaded = false;
3307
+ // 累计运行时长(R-01-020/AC-01、AC-03):主会话条目注入显示值——渲染期合成,
3308
+ // 含开放回合的实时已耗时并随时钟逐秒推进;子代理卡片不显示。
3309
+ if (entry.kind !== "subagent") applyTotalBusy(entry, now);
2970
3310
  const detailSnapshot = liveRecord?.snapshot ?? detail?.snapshot ?? null;
2971
3311
  if (detail && detail.memoHistoryAnchorOf !== (detail.history ?? null)) {
2972
3312
  detail.memoHistoryAnchorOf = detail.history ?? null;
2973
3313
  detail.memoHistoryAnchor = historyInstructionAnchor(detail.history);
2974
3314
  }
3315
+ // 等待/暂停呈现(pendingText 存在)且自身快照为冻结值时,残留 running 行全部落定;
3316
+ // 存在活动后代时保留尾部提升的「agent 工作中」呈现(R-01-009/AC-10 委托周期语义)。
3317
+ const entryIdle = (entry.pendingText ?? null) !== null && entry.descendantActive !== true;
3318
+ const entryCwd = snapshot?.byId?.[entry.id]?.cwd ?? "";
3319
+ // log 派生 memo:history 引用 / idle / cwd 变化才重算。落定在折叠前生效
3320
+ // (foldedHistoryTimeline 的 settleIdle),组标题由已定案成员派生——阻塞等待卡
3321
+ // 呈现「运行了命令」+「等待回答」摘要,而非「正在运行」蓝闪(R-01-009/AC-09)。
3322
+ const historyRef = detail?.history ?? null;
3323
+ const logReady = Array.isArray(historyRef) && historyRef.length > 0;
3324
+ if (detail && logReady && (detail.memoLogTimelineOf !== historyRef || detail.memoLogTimelineIdle !== entryIdle || detail.memoLogTimelineCwd !== entryCwd)) {
3325
+ detail.memoLogTimelineOf = historyRef;
3326
+ detail.memoLogTimelineIdle = entryIdle;
3327
+ detail.memoLogTimelineCwd = entryCwd;
3328
+ detail.memoLogTimeline = foldedHistoryTimeline(historyRef, 4, entryCwd, entryIdle);
3329
+ }
2975
3330
  if (detail && detailSnapshot) {
2976
3331
  // 按快照引用 memo:引用不变(时钟 tick、无关推送)时命中缓存,
2977
3332
  // 长会话不再每次渲染全序扫描。
2978
- const entryCwd = snapshot?.byId?.[entry.id]?.cwd ?? "";
2979
- // 等待/暂停呈现(pendingText 存在)且自身快照为冻结值时,残留 running 行全部落定;
2980
- // 存在活动后代时保留尾部提升的「agent 工作中」呈现(R-01-009/AC-10 委托周期语义)。
2981
- const entryIdle = (entry.pendingText ?? null) !== null && entry.descendantActive !== true;
2982
3333
  if (detail.memoTimelineOf !== detailSnapshot || detail.memoTimelineCwd !== entryCwd || detail.memoTimelineDescendantActive !== (entry.descendantActive === true) || detail.memoTimelineIdle !== entryIdle || detail.memoTimelineAnchor !== (detail.memoHistoryAnchor ?? null)) {
2983
3334
  detail.memoTimelineOf = detailSnapshot;
2984
3335
  detail.memoTimelineCwd = entryCwd;
@@ -2990,7 +3341,11 @@ function apply(ctx) {
2990
3341
  // 出现在输出(窗口行或锚行),反之需 history 补读。
2991
3342
  detail.snapshotHasAnchorableUserRow = detail.memoTimeline.some(isAnchorableUserRow);
2992
3343
  }
2993
- entry.timeline = detail.memoTimeline.length > 0 ? detail.memoTimeline : detail.timeline ?? [];
3344
+ // V3 快照不再承载 chat/partial:活动时间线以 log 派生(eventSource 窗口,含
3345
+ // live-chunk 流式行)为主源;快照派生仅在 log 未就绪时兜底(锚行回退)。
3346
+ entry.timeline = logReady ? detail.memoLogTimeline : detail.memoTimeline ?? [];
3347
+ } else if (logReady) {
3348
+ entry.timeline = detail.memoLogTimeline;
2994
3349
  } else {
2995
3350
  entry.timeline = detail?.timeline ?? entry.timeline ?? [];
2996
3351
  }
@@ -3012,18 +3367,19 @@ function apply(ctx) {
3012
3367
  }
3013
3368
  }
3014
3369
  if (entry.kind === "awaiting" && detail) {
3015
- const history = detail.history ?? null;
3016
- if (detail.memoTurnDurationSnapshotOf !== detailSnapshot || detail.memoTurnDurationHistoryOf !== history) {
3017
- detail.memoTurnDurationSnapshotOf = detailSnapshot;
3018
- detail.memoTurnDurationHistoryOf = history;
3019
- detail.memoTurnDuration = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history });
3020
- }
3021
- entry.elapsedMs = detail.memoTurnDuration ?? null;
3370
+ entry.elapsedMs = memoTurnDuration(detail);
3022
3371
  }
3023
3372
  if (detail?.model) {
3024
3373
  entry.model = detail.model.model;
3025
3374
  entry.reasoning = detail.model.reasoning;
3026
3375
  }
3376
+ // 子代理溯源得到的是 provider 模型 id:经目录分组解析为显示名,effort 缺失时
3377
+ // 以同一目录条目回退(R-01-012/AC-17);目录未覆盖该 id 时保留原始回退(AC-18)。
3378
+ if (entry.kind === "subagent" && detail?.model) {
3379
+ const catalogEntry = catalogEntries[detail.model.model];
3380
+ if (catalogEntry?.name) entry.model = catalogEntry.name;
3381
+ if (!entry.reasoning && catalogEntry?.reasoning) entry.reasoning = catalogEntry.reasoning;
3382
+ }
3027
3383
  // 待回复卡列表补全(C-040、C-064):buildEntries 运行时快照时间线可能仍为空,
3028
3384
  // 而上面的 memo 才在本帧算出结构化提问预览;等待卡静止后常无下一帧,因此在此
3029
3385
  // 立即补入 questionPreview,由 cardSignature 驱动本帧 DOM 写入。
@@ -3032,7 +3388,9 @@ function apply(ctx) {
3032
3388
  if (question !== null) entry.questionPreview = question;
3033
3389
  }
3034
3390
  // 字段级加载指示(R-01-014/AC-02):补充数据在途时卡片对应位置显示活动图标。
3035
- entry.loadingModel = !detail?.model && modelLoads.has(entry.id);
3391
+ // 主会话模型经目录 store 一次性 load 到达(modelLoads 记账),子代理模型经
3392
+ // 日志读取提取(R-01-012/AC-17)。
3393
+ entry.loadingModel = !detail?.model && (modelLoads.has(entry.id) || (entry.kind === "subagent" && historyLoads.has(entry.id)));
3036
3394
  entry.loadingTimeline =
3037
3395
  entry.timeline.length === 0 &&
3038
3396
  (historyLoads.has(entry.id) || sessionOpenLoads.has(entry.id) || (runLikeIds.has(entry.id) && !liveRecord));
@@ -3053,7 +3411,8 @@ function apply(ctx) {
3053
3411
  // 之外)时,取 history 深翻提取的开放回合起点。
3054
3412
  const anchor = progressAnchor(progressAnchorById.get(entry.id) ?? null, {
3055
3413
  descendantActive: entry.descendantActive === true,
3056
- hostStartTime: live?.startTime ?? detail?.memoOpenTurnStart ?? null,
3414
+ // dsh 0.1.5 起快照不再携带 turnTimings:开放回合起点由宿主侧 busy 记账兜底。
3415
+ hostStartTime: live?.startTime ?? busyById.get(entry.id)?.openTurnStart ?? detail?.memoOpenTurnStart ?? null,
3057
3416
  now,
3058
3417
  });
3059
3418
  progressAnchorById.set(entry.id, anchor);
@@ -3069,6 +3428,21 @@ function apply(ctx) {
3069
3428
  // 连续、周期外回合切换归零,R-01-009/AC-06,C-014、C-044)。
3070
3429
  entry.progress = progressOf({ elapsedMs: elapsedMs ?? 0, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
3071
3430
  }
3431
+ if (entry.kind === "subagent") {
3432
+ // 运行中子代理卡与主会话运行卡同口径:同一锚点状态机与进度曲线(R-01-009/AC-14)。
3433
+ const elapsedMs = Number.isFinite(anchor.anchor) ? Math.max(0, now - anchor.anchor) : null;
3434
+ if (elapsedMs !== null) {
3435
+ Object.assign(entry, projectionStats, { elapsedMs });
3436
+ if (detail) detail.lastRuntimeStats = mergeRuntimeStats(projectionStats, detail.lastRuntimeStats);
3437
+ entry.progress = progressOf({ elapsedMs, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) });
3438
+ } else {
3439
+ // 非运行(暂停等待):冻结最后已知统计与最近回合耗时,progress 置空隐藏
3440
+ // 进度条(R-01-009/AC-15);刷新/无留存时回退当前列表投影。
3441
+ Object.assign(entry, mergeRuntimeStats(detail?.lastRuntimeStats, projectionStats));
3442
+ entry.elapsedMs = detail ? memoTurnDuration(detail) : null;
3443
+ entry.progress = null;
3444
+ }
3445
+ }
3072
3446
  }
3073
3447
  // 历史区时间精化(R-01-010/AC-08、AC-09):从保留快照的 turnTimings 与已拉取的
3074
3448
  // history 同批事件提取最后回合结束时刻(取两者较新者),按引用 memo;均无则不提供,
@@ -3088,13 +3462,15 @@ function apply(ctx) {
3088
3462
  recentTotal = recentCandidates.length;
3089
3463
  const recent = recentCandidates.slice(0, recentVisibleCount);
3090
3464
  recentHasMore = recent.length < recentTotal;
3091
- // 最近卡统计复用运行卡的列表投影口径;耗时从保留快照或已读 history 取最近完整回合,
3092
- // 缺边界时仅为当前可见历史卡安排一次既有 history 补读(R-01-013/AC-12)。
3465
+ // 最近卡统计复用运行卡的列表投影口径;耗时从已读 history 取最近完整回合的运行
3466
+ // 过程时长(busy 口径,与标题行总耗时一致,R-01-013/AC-12),缺边界时仅为当前
3467
+ // 可见历史卡安排一次既有 history 补读。
3093
3468
  const recentDurationFallbackIds = new Set();
3094
3469
  for (const entry of recent) {
3095
3470
  const detail = sessionDetailsById.get(entry.id);
3096
- const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
3097
- const elapsedMs = lastTurnDuration({ turnTimings: detailSnapshot?.turnTimings, history: detail?.history });
3471
+ const elapsedMs = lastTurnDuration({ history: detail?.history ?? null });
3472
+ // 累计运行时长(R-01-020/AC-01):最近历史卡同为标题行右侧显示;历史卡无开放回合。
3473
+ applyTotalBusy(entry, now);
3098
3474
  const stats = statsFromProjection(snapshot?.byId?.[entry.id]?.projectionValues, elapsedMs);
3099
3475
  const retained = detail?.lastRuntimeStats;
3100
3476
  Object.assign(entry, stats, retained ? {
@@ -3129,9 +3505,27 @@ function apply(ctx) {
3129
3505
  ...active.filter((entry) => entry.kind === "awaiting").map((entry) => entry.id),
3130
3506
  ...recentDurationFallbackIds,
3131
3507
  ]);
3508
+ // 子代理模型溯源读取时机(R-01-012/AC-17):运行中看快照最新助手节点是否已
3509
+ // 定案(事件已落宿主日志,尾页读取必命中;工具密集期 4 行折叠窗口可能不含
3510
+ // 助手行,故直接读快照而非折叠时间线);不在运行中则整段日志已冻结,直接读取。
3511
+ // 开局只有流式 partial 时不读,避免早读扑空。
3512
+ const subagentModelReadIds = new Set();
3513
+ for (const entry of active) {
3514
+ if (entry.kind !== "subagent") continue;
3515
+ if (!runLikeIds.has(entry.id)) {
3516
+ subagentModelReadIds.add(entry.id);
3517
+ continue;
3518
+ }
3519
+ const detail = sessionDetailsById.get(entry.id);
3520
+ const detailSnapshot = livenessById.get(entry.id)?.snapshot ?? detail?.snapshot ?? null;
3521
+ if (chatLatestAssistantSettled(detailSnapshot)) subagentModelReadIds.add(entry.id);
3522
+ }
3132
3523
  const detailIds = [...active, ...recent].map((entry) => entry.id);
3133
3524
  detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
3134
- loadNativeDetails(detailIds, previewFallbackIds, durationFallbackIds);
3525
+ loadNativeDetails({ ids: detailIds, previewFallbackIds, durationFallbackIds, subagentModelReadIds });
3526
+ // 累计运行时长懒回填(R-01-020/AC-05):对可见主会话触发宿主侧存量回合补齐;
3527
+ // 宿主侧每会话单飞,重复触发由宿主合并;完成后经 busy SSE 广播推送。
3528
+ requestBusyBackfill([...active, ...recent].filter((entry) => entry.kind !== "subagent").map((entry) => entry.id));
3135
3529
  const visibleIds = new Set([...active, ...recent].map((entry) => entry.id));
3136
3530
  // 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
3137
3531
  // 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
@@ -3139,6 +3533,7 @@ function apply(ctx) {
3139
3533
  pruneInvisibleEntries([sessionDetailsById, modelLoads, historyLoads, sessionOpenLoads], visibleIds);
3140
3534
  // 模型目录订阅同生命周期:不可见即先 unsubscribe 再除名,监听器不残留(R-01-012/AC-16)。
3141
3535
  pruneSubscriptions(modelDirectorySubs, visibleIds);
3536
+ pruneSubscriptions(logSourceSubs, visibleIds);
3142
3537
  // 重试链目标已成为当前会话(他途到达)即取消,避免过期链条拽回会话。
3143
3538
  cancelStaleOpenRetries({ currentId: snapshot?.current ?? null, activatedId: lastActivatedId });
3144
3539
 
@@ -3427,7 +3822,16 @@ function apply(ctx) {
3427
3822
  // 只观察 center 的直接子节点,捕获 seat/pane 重挂载,不观察 pane 子树。
3428
3823
  centerObserver = new MutationObserver(() => {
3429
3824
  const nextSeat = document.querySelector(CONVERSATION_SELECTOR);
3430
- if (nextSeat?.parentElement !== center) installFrameObserver();
3825
+ if (nextSeat?.parentElement !== center) {
3826
+ installFrameObserver();
3827
+ } else if (nextSeat !== seat) {
3828
+ // 中间列未换但槽容器被宿主原地替换:流式观察者换绑到新节点,
3829
+ // 不整层重装(旧节点已脱离 DOM,留着会让流式 childList 更新丢失)。
3830
+ conversationObserver?.disconnect();
3831
+ conversationObserver = new MutationObserver(queueSync);
3832
+ conversationObserver.observe(nextSeat, { childList: true, subtree: true });
3833
+ observedSeat = nextSeat;
3834
+ }
3431
3835
  queueSync();
3432
3836
  });
3433
3837
  centerObserver.observe(center, { childList: true });
@@ -3450,8 +3854,17 @@ function apply(ctx) {
3450
3854
 
3451
3855
  // ---- 交互:移动端抽屉开关、弹窗收起、桌面折叠 ----
3452
3856
  function onToggleClick() {
3453
- const pane = document.querySelector(`[${PANE_ATTR}]`);
3454
- const open = pane?.getAttribute("data-open") !== "true";
3857
+ let pane = document.querySelector(`[${PANE_ATTR}]`);
3858
+ if (pane === null) {
3859
+ // 宿主视图替换窗口期/绑定失败时窗格可能缺失:先补绑再开合,开关不得静默无响应
3860
+ // (真机宿主重渲染节奏与桌面不同,此窗口期在触屏上更易被命中,R-01-008/AC-01)。
3861
+ pane = ensurePane();
3862
+ if (pane === null) {
3863
+ console.warn("[dsh-activity-pane] 窗格挂载点不存在(main 槽容器未就绪),已忽略本次开关点击");
3864
+ return;
3865
+ }
3866
+ }
3867
+ const open = pane.getAttribute("data-open") !== "true";
3455
3868
  togglePane(open);
3456
3869
  }
3457
3870
  toggle.addEventListener("click", onToggleClick);
@@ -3467,11 +3880,19 @@ function apply(ctx) {
3467
3880
  disposed = true;
3468
3881
  sessionUnsubscribe?.();
3469
3882
  workspaceUnsubscribe?.();
3883
+ pendingUnsubscribe?.();
3470
3884
  acksSource?.close();
3471
3885
  acksSource = null;
3886
+ busySource?.close();
3887
+ busySource = null;
3888
+ document.removeEventListener("visibilitychange", onBusyVisibilityResume);
3889
+ window.removeEventListener("pageshow", onBusyPageShow);
3472
3890
  document.removeEventListener("visibilitychange", onVisibilityResume);
3473
3891
  window.removeEventListener("pageshow", onPageShow);
3474
3892
  completeAcksById.clear();
3893
+ busyById.clear();
3894
+ busyRequestedIds.clear();
3895
+ busyRetryAtById.clear();
3475
3896
  if (clockTimer !== null) clearInterval(clockTimer);
3476
3897
  if (recentTimeTimer !== null) clearInterval(recentTimeTimer);
3477
3898
  if (e2eListReleaseTimer !== null) clearTimeout(e2eListReleaseTimer);
@@ -3488,6 +3909,7 @@ function apply(ctx) {
3488
3909
  livenessById.clear();
3489
3910
  // 卸载即全量退订:空可见集合驱动 pruneSubscriptions 先 unsubscribe 再除名。
3490
3911
  pruneSubscriptions(modelDirectorySubs, new Set());
3912
+ pruneSubscriptions(logSourceSubs, new Set());
3491
3913
  progressAnchorById.clear();
3492
3914
  sessionOpenLoads.clear();
3493
3915
  loadQueue.length = 0;