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