dsh-activity-pane 0.15.0 → 0.16.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.
@@ -7,13 +7,16 @@ window.__ModuleLoader__.load({
7
7
  // dsh-activity-pane 核心纯函数:把 DSH 原生 sessions/workspaces 客户端服务
8
8
  // 快照映射成窗格里的"活动会话条目"。这一层不触碰 DOM,可单测。
9
9
  //
10
- // 数据源约定(来自 @deepseek-ai/dsh-client-runtime 的 sessions.list 快照):
10
+ // 数据源约定(来自 @deepseek-ai/dsh-api-session-controller 0.2.0 的 sessions.list 快照):
11
11
  // ids: SessionId[] —— 宿主列表顺序(祖先在前的 lineage 顺序)
12
- // byId[id]: { id, displayTitle, title?, cwd?, parentId?, origin?, running, completed?,
13
- // pendingInteraction?: 'approval'|'plan-review'|'question', blank, ... }
14
- // current: SessionId | undefined
15
- // subagentsByParent: { [parentId]: { entries: [{ id, label, ... }] } }
16
- // jobsBySession: { [sessionId]: JobView[] }
12
+ // byId[id]: { id, displayTitle, title?, cwd?, parentId?, origin?, running,
13
+ // retainedBy: { [source]: count }, blank, updatedAt, ... }
14
+ // current —— 0.2.0 起不再携带:以行上 retainedBy.mainView > 0
15
+ // 推导(宿主外壳 DocumentTitle / UiSession
16
+ // .publishMain / isMain 同款口径,currentIdOf)
17
+ // projectionsBySession[id]: { values: { subagentCatalog?: SubagentCatalogEntry[] },
18
+ // state: 'idle'|'loading'|'ready'|'error' }
19
+ // jobsBySession: { [sessionId]: JobView[] }(来自 jobs 服务 state.rows,watchRows 订阅)
17
20
  // 以及 workspaces.list 的 items: [{ title, path, sessionIds }]。
18
21
  //
19
22
  // 展示规则:
@@ -1526,12 +1529,42 @@ function instructionTime(row) {
1526
1529
  return Number.isFinite(time) ? time : -1;
1527
1530
  }
1528
1531
 
1529
- /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。 */
1530
- function subagentTitle(parentId, id, byId, subagentsByParent = {}) {
1531
- const entries = subagentsByParent[parentId]?.entries;
1532
- const entry = Array.isArray(entries)
1533
- ? entries.find((candidate) => String(candidate?.id) === String(id))
1534
- : undefined;
1532
+ /**
1533
+ * 当前会话 id 推导(dsh 0.2.0 起 sessions.list 不再携带 current):取行上
1534
+ * `retainedBy.mainView` 引用计数 > 0 的首行。与宿主外壳同款口径——client-ui-layout
1535
+ * DocumentTitle、client-ui-session publishMain/isMain 均以该判定取当前会话。
1536
+ * 快照非记录、行缺 retainedBy 或计数非有限数字均按未命中处理,不抛错。
1537
+ */
1538
+ function currentIdOf(list) {
1539
+ const byId = isRecord(list) && isRecord(list.byId) ? list.byId : {};
1540
+ for (const row of Object.values(byId)) {
1541
+ if (!isRecord(row) || !isRecord(row.retainedBy)) continue;
1542
+ const count = Number(row.retainedBy.mainView);
1543
+ if (Number.isFinite(count) && count > 0) return typeof row?.id === "string" ? row.id : null;
1544
+ }
1545
+ return null;
1546
+ }
1547
+
1548
+ /**
1549
+ * 母会话子代理目录条目读取(dsh 0.2.0 起 subagentsByParent 移除):取列表快照
1550
+ * `projectionsBySession[parentId].values.subagentCatalog`(SubagentCatalogEntry[]
1551
+ * —— `{ id, createdAt, mode: 'one-shot'|'continuable'|'unknown', label? }`)。
1552
+ * 投影未加载(无该键、state 非就绪或 values 缺键)按目录不可得返回 undefined,
1553
+ * 调用方经 sessions.refreshProjections(parentId) 拉取后重试。
1554
+ */
1555
+ function subagentCatalogOf(projectionsBySession, parentId) {
1556
+ if (!isRecord(projectionsBySession)) return undefined;
1557
+ const snapshot = projectionsBySession[String(parentId)];
1558
+ const entries = isRecord(snapshot) ? snapshot.values?.subagentCatalog : undefined;
1559
+ return Array.isArray(entries) ? entries : undefined;
1560
+ }
1561
+
1562
+ /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。
1563
+ * 第 4 参为 0.2.0 的列表快照 `projectionsBySession`(旧 subagentsByParent 移除)。 */
1564
+ function subagentTitle(parentId, id, byId, projectionsBySession = {}) {
1565
+ const entry = subagentCatalogOf(projectionsBySession, parentId)?.find(
1566
+ (candidate) => String(candidate?.id) === String(id),
1567
+ );
1535
1568
  const label = cleanText(entry?.label);
1536
1569
  if (label) return label;
1537
1570
  const display = cleanText(byId[id]?.displayTitle);
@@ -1580,9 +1613,9 @@ function mainTitle(byId, id) {
1580
1613
  function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = [], waitingStarts = null, jobsBySession = null) {
1581
1614
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1582
1615
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1583
- const current = snapshot?.current ?? null;
1584
- const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1585
- ? snapshot.subagentsByParent
1616
+ const current = currentIdOf(snapshot);
1617
+ const projectionsBySession = isRecord(snapshot?.projectionsBySession)
1618
+ ? snapshot.projectionsBySession
1586
1619
  : {};
1587
1620
  const archived = archivedIds instanceof Set
1588
1621
  ? new Set([...archivedIds].map((id) => String(id)))
@@ -1716,7 +1749,7 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1716
1749
  : "awaiting",
1717
1750
  descendantActive: m.descendantActive,
1718
1751
  title: m.isSub
1719
- ? subagentTitle(parentId, id, byId, subagentsByParent)
1752
+ ? subagentTitle(parentId, id, byId, projectionsBySession)
1720
1753
  : mainTitle(byId, id),
1721
1754
  workspaceTitle: workspace.title,
1722
1755
  workspaceKey: workspace.key,
@@ -2407,7 +2440,7 @@ function createSessionEventSerializer() {
2407
2440
  function buildRecent(snapshot, workspaceItems, now, detailsById = {}, archivedIds = [], completions = null, delegatingIds = null, turnEnds = null, jobsBySession = null) {
2408
2441
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
2409
2442
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
2410
- const current = snapshot?.current ?? null;
2443
+ const current = currentIdOf(snapshot);
2411
2444
  const items = Array.isArray(workspaceItems) ? workspaceItems : [];
2412
2445
  const activeIds = activeSessionIds(byId);
2413
2446
  const archived = archivedIds instanceof Set ? archivedIds : new Set(archivedIds ?? []);
@@ -2654,13 +2687,16 @@ function shouldDismissDrawerOnActivation({ targetId, currentId, mobile, drawerOp
2654
2687
  }
2655
2688
 
2656
2689
  /**
2657
- * 调用 DSH 原生会话导航;由调用方决定失败后的 refresh/retry 策略。
2658
- * 不读取 sessions.list,避免用另一份可能已过期的快照拦截跳转。
2690
+ * 调用 DSH 原生会话导航(dsh 0.2.0,T-159):经 uiWorkspace.openSession 发起
2691
+ * 「选中会话并显示其 Conversation」的导航动作(内部以 source "mainView" retain
2692
+ * 并驱动外壳 selection;替代 0.2.0 已删除的 sessions.open)。发起为 void 且可被
2693
+ * 后续导航取代,是否到达由调用方的 currentId 重试链判定;target 亦接受子代理
2694
+ * 持久父地址(SessionTarget)。服务缺失返回 false,由调用方决定重试策略。
2659
2695
  */
2660
- function openSession(sessions, sessionId) {
2661
- if (typeof sessions?.open !== "function") return false;
2696
+ function openSession(uiWorkspace, target) {
2697
+ if (typeof uiWorkspace?.openSession !== "function") return false;
2662
2698
  try {
2663
- sessions.open(sessionId);
2699
+ uiWorkspace.openSession(target);
2664
2700
  return true;
2665
2701
  } catch {
2666
2702
  return false;
@@ -2757,15 +2793,26 @@ function bindBackdropDismiss(backdrop, dismiss) {
2757
2793
  //
2758
2794
  // 数据来源:DSH 原生 `sessions` / `workspaces` 客户端服务(推送式快照)+ `remote`
2759
2795
  // 服务的部署级模型目录(session/modelCatalog)与会话日志分页(session/page,
2760
- // Session 格式 V3——dsh 0.1.5 起快照不再携带会话内容、connection.api 门面移除)+
2761
- // 运行中会话的原生订阅(binding().session,运行状态面)+ 可选 `modelDirectories`
2762
- // 目录 store 订阅(模型选择实时更新),不依赖任何第三方插件数据路由,也不做状态轮询。
2796
+ // Session 格式 V3——快照不再携带会话内容、connection.api 门面移除)+ `jobs` 服务的
2797
+ // 会话任务 roster(watchRows/state.rows,0.2.0 起 jobsBySession 自列表快照移除)+
2798
+ // `uiSession.sessionStatus` 统一状态面(running/pendingInteraction/completionUnread,
2799
+ // 0.2.0 取代 pendingInteractions)+ 运行中会话的原生订阅(retain 引用的 binding,
2800
+ // 运行状态面与 eventSource 日志窗口)+ 可选 `modelDirectories` 目录 store 订阅
2801
+ // (模型选择实时更新),不依赖任何第三方插件数据路由,也不做状态轮询。
2802
+ //
2803
+ // 会话代际生命周期(dsh 0.2.0,T-170):`binding()` 只借用已保留代际,插件经
2804
+ // `sessions.retain(target, { source: "dsh-activity-pane" })` 显式持有可见卡片的
2805
+ // 会话引用(子代理以 `sessions.subagentAddress()` 解析的持久父地址为 target),
2806
+ // `reference.ready` 落定后接线 binding.eventSource 日志窗口与 session 运行态订阅;
2807
+ // 引用随可见性 prune、紧凑档拆窗、服务代际更替与卸载释放。
2763
2808
 
2764
2809
  const name = "dsh-activity-pane";
2765
- // remote.session:dsh 0.1.5 起会话 RPC(目录/日志分页)经 api-remotes 的点分命名空间
2766
- // 注入(cordis 代理对未注入属性直接抛错,父服务声明不代表子命名空间可用;仅声明
2767
- // 子命名空间即可解析,无需再注入父面)。
2768
- const inject = ["sessions", "workspaces", "uiSession", "remote.session"];
2810
+ // remote.session:会话 RPC(目录/日志分页)经 api-remotes 的点分命名空间注入
2811
+ // (cordis 代理对未注入属性直接抛错,父服务声明不代表子命名空间可用;仅声明
2812
+ // 子命名空间即可解析,无需再注入父面)。jobs/uiWorkspace:0.2.0 起后台任务
2813
+ // roster 与会话导航分别由 jobs 服务(dsh-api-job-controller client 半面)与
2814
+ // uiWorkspace.openSession(dsh-client-ui-workspace)承载。
2815
+ const inject = ["sessions", "workspaces", "uiSession", "remote.session", "jobs", "uiWorkspace"];
2769
2816
 
2770
2817
  const CONVERSATION_SELECTOR = "#root [data-slot=\"main\"]";
2771
2818
  const PANE_ATTR = "data-dsh-activity-pane";
@@ -2888,8 +2935,10 @@ const CSS = `
2888
2935
  -webkit-overflow-scrolling: touch;
2889
2936
  padding: 0 0 10px;
2890
2937
  /* Keep the native scrollbar just left of the edge handle, as in the host
2891
- sidebar; stable keeps the list geometry unchanged when overflow appears. */
2892
- margin-right: var(--dsh-scrollbar-width, 8px);
2938
+ sidebar; stable keeps the list geometry unchanged when overflow appears.
2939
+ max() 下限 = 调宽手柄宽度(6px):dsh 0.2.0 起宿主 scrollbar-width token
2940
+ 收窄到 5px,裸 token 会让手柄压住滚动条命中区(T-170)。 */
2941
+ margin-right: max(var(--dsh-scrollbar-width, 8px), 6px);
2893
2942
  scrollbar-gutter: stable;
2894
2943
  }
2895
2944
  /* 滚动条在滚动或鼠标进入窗格时显示(R-01-004/AC-03,与外壳侧栏一致):thumb 默认透明,
@@ -3489,17 +3538,27 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3489
3538
  border-color: rgba(255, 255, 255, 0.13);
3490
3539
  filter: none;
3491
3540
  }
3492
- /* 任务卡行 1(R-01-023/AC-05):状态点 + 工具名称 + 右缘随时钟时长;工具名不可得时
3493
- 文本段隐藏,仅保留状态点与时长。 */
3541
+ /* 任务卡行 1(R-01-023/AC-05、AC-09):状态点 + 工具名称 + 右缘随时钟时长;工具名不可得时
3542
+ 文本段隐藏,仅保留状态点与时长。状态点活性脉冲(AC-09):与运行中主会话卡标题状态点
3543
+ 同源同节奏(dap-pulse 1.2s),光晕与着色同色相——运行中任务蓝、停止中琥珀。 */
3494
3544
  [data-dsh-activity-pane] .dap-card[data-kind="job"] .dap-job-kind {
3495
3545
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3496
3546
  font-size: 11px; line-height: 15px;
3497
3547
  }
3498
3548
  [data-dsh-activity-pane] .dap-job-dot {
3499
3549
  flex: none; width: 6px; height: 6px; border-radius: 50%;
3550
+ }
3551
+ [data-dsh-activity-pane] .dap-job-dot[data-status="running"] {
3500
3552
  background: #65a0ff;
3553
+ box-shadow: 0 0 6px rgba(101, 160, 255, 0.8);
3554
+ }
3555
+ [data-dsh-activity-pane] .dap-job-dot[data-status="stopping"] { background: #f5a524; box-shadow: 0 0 6px rgba(245, 165, 36, 0.8); }
3556
+ /* 活性门控(AC-09):着色、光晕与脉冲只作用于 running/stopping 两个在跑状态,AC 条件由
3557
+ CSS 显式表达,不依赖「liveJobs 只收 running/stopping」的跨层隐式前提(双轴复审修复)。 */
3558
+ [data-dsh-activity-pane] .dap-job-dot[data-status="running"],
3559
+ [data-dsh-activity-pane] .dap-job-dot[data-status="stopping"] {
3560
+ animation: dap-pulse 1.2s ease-in-out infinite;
3501
3561
  }
3502
- [data-dsh-activity-pane] .dap-job-dot[data-status="stopping"] { background: #f5a524; }
3503
3562
  [data-dsh-activity-pane] .dap-job-elapsed {
3504
3563
  flex: none; margin-left: auto; font-size: 10px; line-height: 15px;
3505
3564
  color: color-mix(in srgb, currentColor 55%, transparent); font-variant-numeric: tabular-nums;
@@ -3721,26 +3780,67 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3721
3780
  .dap-toggle .dap-toggle-count .dap-spinner {
3722
3781
  vertical-align: middle;
3723
3782
  }
3724
- /* 移动端浮动开关按钮:仅在窄屏显示(桌面隐藏)。固定于会话头部左上角、
3725
- 原生左边栏切换按钮(28px @ left:8px; top:12px)右侧(R-01-008/AC-04)。 */
3783
+ /* 移动端「活动」开关按钮:仅在窄屏显示(桌面隐藏)。呈紧凑形态(仅计数徽标,
3784
+ 无图标与文字标签,T-162)——面板图标被东家误读为边栏按钮,撤销)。常态嵌入
3785
+ 宿主头部的标题行行首作为布局子项参与排布(R-01-008/AC-04,T-163;视觉上位于
3786
+ 左边栏切换按钮右侧,其余头部控件被真实后挤);标题行不可得且宿主侧栏展开按钮
3787
+ 在屏时落位 hero 形态贴靠该按钮右侧(T-167)。落位由 placeToggle 驱动
3788
+ (data-embedded / data-hero 属性承载);未落位的开关不进入 DOM(不可嵌即不
3789
+ 渲染,C-093)。本基规则不再携带 fixed 定位与高层级 z-index:按钮处于 flex
3790
+ 容器时其 z-index 即使 static 也生效(CSS Flexbox 规范),高层级声明曾使嵌入
3791
+ 态整体逃逸宿主抽屉之上(T-167 根因)。按钮本体无外层胶囊壳:不携带底色、
3792
+ 描边与投影(T-168,东家指示只留内部涂色的计数胶囊),可见形态即
3793
+ .dap-toggle-count 徽标本体;border:none 与 background:none 显式压掉 UA
3794
+ 默认按钮壳——appearance:none 不摘除 UA 声明的边框与底色,WebKit 探针实测
3795
+ 漏出 2px 灰壳;外层命中盒最小 44×38px,内层计数徽标最小 36×24px,
3796
+ 计数文字 12px、水平 padding 8px(R-01-008/AC-07、C-095)。
3797
+ width:max-content + appearance:none + max-width:64px 显式钉宽并钳硬上限;
3798
+ 两位计数时外层命中盒保持至少 44px 宽,较长计数沿既有上限处理。 */
3726
3799
  .dap-toggle {
3727
- position: fixed; top: 12px; left: 44px; z-index: 2147482991;
3728
3800
  display: none;
3729
- align-items: center; gap: 6px;
3730
- min-height: 30px; padding: 0 11px;
3731
- border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
3732
- border-radius: 999px;
3733
- background: rgba(24, 28, 38, 0.94);
3734
- color: currentColor;
3801
+ align-items: center; gap: 5px;
3802
+ min-width: 44px; height: 38px; min-height: 38px; padding: 0;
3803
+ appearance: none; white-space: nowrap; width: max-content; max-width: 64px;
3804
+ box-sizing: border-box;
3805
+ border: none; background: none;
3735
3806
  font-size: 12px; font-weight: 600;
3736
3807
  cursor: pointer;
3737
- box-shadow: 0 6px 16px rgba(0,0,0,.34);
3808
+ }
3809
+ /* 嵌入态:回归文档流参与标题行排布;flex:none + width:max-content +
3810
+ max-width:64px 在此重复声明是特异度保险——宿主样式表的 (0,1,1) 级后代规则
3811
+ 可压过 0-1-0 的基规则,嵌入态处于宿主子树内必须自带 (0,2,0) 钉宽;min-width
3812
+ 44px、height 38px 与宿主移动控件对齐;右缘 8px 与标题簇分隔。z-index:1 为层级归位
3813
+ (T-167):flex 子项的 z-index 即使 static 也生效,置 1 使按钮略高于行内
3814
+ 兄弟、仍远低于宿主任何覆盖层(移动壳层抽屉 1300 / 遮罩 1250 / 菜单 1100),
3815
+ 抽屉滑入即被正常遮挡;同时免疫宿主 header 内 absolute 装饰的意外覆盖。 */
3816
+ .dap-toggle[data-embedded] {
3817
+ position: static;
3818
+ margin-right: 8px;
3819
+ flex: none;
3820
+ width: max-content; max-width: 64px; min-width: 44px;
3821
+ height: 38px; min-height: 38px;
3822
+ z-index: 1;
3823
+ }
3824
+ /* hero 形态(T-167):标题行不可得(宿主 data-phase="hero",英雄页/空白会话)
3825
+ 时贴靠宿主侧栏展开按钮(dsh-web-mobile ⊡,data-mobile-nav="fab"/"toggle")
3826
+ 右侧 8px 并垂直居中,几何由 placeToggle 内联钉死(T-164 策略),此处承载
3827
+ position 缺省。z-index:1060 等同页面内容层:低于壳层遮罩(1250)、宿主抽屉
3828
+ (1300)与宿主菜单(1100,抽屉打开期间壳层把宿主浮层抬至 1400 同样盖住它),
3829
+ 抽屉滑入即被逐步遮挡;数值耦合 dsh-web-mobile 层契约,壳层改版会漂移,记录
3830
+ 为边界(同 T-165 判据耦合)。 */
3831
+ .dap-toggle[data-hero] {
3832
+ position: absolute;
3833
+ z-index: 1060;
3738
3834
  }
3739
3835
  .dap-toggle .dap-toggle-count {
3740
- min-width: 16px; text-align: center; border-radius: 999px;
3836
+ display: inline-flex; align-items: center; justify-content: center;
3837
+ box-sizing: border-box; min-width: 36px; max-width: 64px; height: 24px;
3838
+ line-height: 24px; text-align: center; border-radius: 999px;
3839
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3741
3840
  background: color-mix(in srgb, currentColor 16%, transparent);
3742
- padding: 0 5px; font-size: 10px; font-weight: 700;
3841
+ padding: 0 8px; font-size: 12px; font-weight: 700;
3743
3842
  }
3843
+ /* 徽标等待态:执行色底 + 呼吸动画(有待执行行动时点亮)。 */
3744
3844
  .dap-toggle[data-awaiting] .dap-toggle-count {
3745
3845
  background: rgba(46, 42, 26, 0.97);
3746
3846
  animation: dap-await-pulse 1.2s ease-in-out infinite;
@@ -3803,12 +3903,18 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3803
3903
  body,不落本规则,浮动开关徽标脉冲(R-01-002/AC-06、AC-07)照常。 */
3804
3904
  [data-dsh-activity-pane]:not([data-open="true"]) { content-visibility: hidden; }
3805
3905
  .dap-backdrop[data-drawer-open] { display: block; }
3806
- .dap-toggle { display: flex; }
3807
- .dap-toggle[data-drawer-open] { display: none; }
3906
+ /* 开关显隐改为落位属性驱动(T-167):嵌入态/hero 态各自 display:flex,
3907
+ 未落位(不在 DOM)自然不显示;fixed 兜底形态已去除。 */
3908
+ .dap-toggle[data-embedded] { display: flex; }
3909
+ .dap-toggle[data-hero] { display: flex; }
3910
+ /* 插件抽屉打开时开关留位隐藏:visibility 不可见且不可命中(满足 AC-05 隐藏),
3911
+ 标题行不因开合重排(T-160 闸口决策);pointer-events 同步摘除,不可见盒
3912
+ 不拦截宿主抽屉点击;hero 态 absolute 脱流,留位语义等价。 */
3913
+ .dap-toggle[data-drawer-open] { display: flex; visibility: hidden; pointer-events: none; }
3808
3914
  }
3809
3915
  /* 浅色主题适配:外壳以 body 上 data-ds-dark-theme 属性标记深色(缺省即浅色),
3810
3916
  并在两个作用域下翻转整套 --dsw-alias-* 变量。本块只覆盖上文暗色专用的硬编码
3811
- 颜色(卡片底色/描边/阴影、时间线文字与轨道、进度轨道、浮动开关),深色主题下
3917
+ 颜色(卡片底色/描边/阴影、时间线文字与轨道、进度轨道),深色主题下
3812
3918
  全部规则保持原值;状态色(绿/蓝/橙/红)与 currentColor 派生色两主题通用,不覆盖。
3813
3919
  ::before 状态圆点不覆盖:基色本就被各 data-status 规则接管,覆盖反而会以更高
3814
3920
  优先级压掉运行/完成/错误状态色。 */
@@ -3901,9 +4007,6 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace-item[data-ico
3901
4007
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3902
4008
  background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08));
3903
4009
  }
3904
- body:not([data-ds-dark-theme]) .dap-toggle {
3905
- background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
3906
- }
3907
4010
  /* 「回到顶部」与标题行档位按钮及左侧仓库入口的浅色覆盖:不透明层-2 底色与外壳描边别名
3908
4011
  (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
3909
4012
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
@@ -4029,9 +4132,11 @@ function apply(ctx) {
4029
4132
  let sessions = null;
4030
4133
  let workspaces = null;
4031
4134
  let uiSession = null;
4135
+ let jobs = null;
4032
4136
  let sessionUnsubscribe = null;
4033
4137
  let workspaceUnsubscribe = null;
4034
4138
  let pendingUnsubscribe = null;
4139
+ let jobsUnsubscribe = null;
4035
4140
  let clockTimer = null;
4036
4141
  let recentTimeTimer = null;
4037
4142
  let syncScheduled = false;
@@ -4077,8 +4182,13 @@ function apply(ctx) {
4077
4182
  * 重挂载后保留(R-01-021/AC-06)。 */
4078
4183
  let densityLevel = readStoredDensity();
4079
4184
  /** 待执行的锚定补偿:档位切换时登记当前选中卡片顶部的视口相对位置,本轮渲染
4080
- * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。 */
4185
+ * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。
4186
+ * dsh 0.2.0(T-170 审核):切回更高档位时窗口经 retain→ready 异步重建,时间线
4187
+ * 内容分多帧长回——补偿在沉降窗内逐帧执行,位置稳定或超窗即撤(单帧补偿
4188
+ * 会漏掉后期增长,锚点漂移)。 */
4081
4189
  let pendingDensityAnchor = null;
4190
+ /** 锚定补偿沉降窗截止时刻:窗内逐帧补偿,位置稳定提前撤,超窗强制撤。 */
4191
+ let densityAnchorDeadline = 0;
4082
4192
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
4083
4193
  let lastActivatedId = null;
4084
4194
  /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
@@ -4132,7 +4242,6 @@ function apply(ctx) {
4132
4242
  let catalogGroups = [];
4133
4243
  /** 部署级 modelCatalog 一次性读取的在途 Promise:成功即缓存、失败允许重试。 */
4134
4244
  let catalogPromise = null;
4135
- /** native session.open() requests in flight; avoid duplicate cold history reads. */
4136
4245
  /** 冷数据读取并发池:队列顺序即优先级(调用方已排序),逐个完成逐个重绘。 */
4137
4246
  const loadQueue = [];
4138
4247
  let loadInflight = 0;
@@ -4153,29 +4262,146 @@ function apply(ctx) {
4153
4262
  pumpDetailLoads();
4154
4263
  return promise;
4155
4264
  }
4156
- const sessionOpenLoads = new Map();
4157
- /** 子代理目录按需拉取记账(T-151):parentId → 已触发;失败不热重试(行为有界)。 */
4158
- const subagentCatalogRequests = new Set();
4265
+ /** 插件持有的会话引用登记(dsh 0.2.0 retain 生命周期,T-170):String(id) →
4266
+ * { reference, session, eventSource, pending }。retain 以独立
4267
+ * source `dsh-activity-pane` 持有可见卡片的会话代际(binding 只借用已保留代际,
4268
+ * 旧打开/释放调用已移出会话面);释放点 = 可见性 prune / 紧凑档拆窗 /
4269
+ * 服务代际更替 / 卸载。 */
4270
+ const sessionRefs = new Map();
4271
+ /** 插件 retain source:与宿主 mainView/workspaceOperation 并列的独立消费方标识。 */
4272
+ const PLUGIN_RETAIN_SOURCE = "dsh-activity-pane";
4273
+ /** jobs 服务按会话 roster 订阅(dsh 0.2.0 起 jobsBySession 移除,R-01-023):
4274
+ * String(id) → stop();对列表内全会话订 roster,随列表 prune。 */
4275
+ const jobWatchById = new Map();
4276
+ /** 母会话投影按需拉取记账(T-151 后继):parentId 在途记账,完成即除名允许重试。 */
4277
+ const projectionRefreshes = new Set();
4159
4278
 
4160
4279
  const style = document.createElement("style");
4161
4280
  style.id = STYLE_ID;
4162
4281
  style.textContent = CSS;
4163
4282
  document.head.appendChild(style);
4164
4283
 
4165
- // 移动端浮动开关按钮(桌面隐藏见 CSS)。
4284
+ // 移动端浮动开关按钮(桌面隐藏见 CSS):紧凑形骨架——仅计数徽标,无图标与
4285
+ // 文字标签(R-01-008/AC-04,T-162;面板图标被东家误读为边栏按钮,撤销);
4286
+ // 可访问名称由 aria-label 承担。不预挂 DOM:开关仅在 placeToggle 落位时进入
4287
+ // 文档,未落位即不渲染(C-093,fail-visible——嵌入失败表现为按钮消失)。
4166
4288
  const toggle = document.createElement("button");
4167
4289
  toggle.className = "dap-toggle";
4168
4290
  toggle.type = "button";
4169
4291
  toggle.setAttribute("aria-label", "切换活动会话窗格");
4170
- toggle.innerHTML =
4171
- "<span>活动</span><span class=\"dap-toggle-count\"></span>";
4172
- document.body.appendChild(toggle);
4292
+ toggle.innerHTML = "<span class=\"dap-toggle-count\"></span>";
4173
4293
 
4174
4294
  // 移动端抽屉透明遮罩:仅窄屏且抽屉打开时显示,点击收起抽屉(R-01-008/AC-03)。
4175
4295
  const backdrop = document.createElement("div");
4176
4296
  backdrop.className = "dap-backdrop";
4177
4297
  document.body.appendChild(backdrop);
4178
4298
 
4299
+ // 移动端「活动」开关落位守卫(T-163/T-167/T-171,R-01-008/AC-04):常态嵌入宿主
4300
+ // 头部参与布局——东家指示(左边栏弹出按钮的右侧,其余控件被真实后挤)。0.2.0 起
4301
+ // 头部结构代差:header.firstElementChild 只是左侧窄条 headerLeading;无壳层开关
4302
+ // 占座时仍嵌该行行首;移动壳层开关(data-mobile-nav="toggle")占座时改嵌会话头
4303
+ // 标题行行首(座位右缘)。hero 页头部被壳层隐藏时几何感知回落 hero 形态。
4304
+ // 插入点取行内首个内容簇(标题 crumb 导航所在顶层簇)之前,无簇时行首 prepend;
4305
+ // 幂等:已是行首元素时不动 DOM;宿主重渲染丢弃嵌入节点后由下一轮渲染守卫重插
4306
+ // (头部在 seat 子树内,conversationObserver 的 subtree 监听会经 queueSync
4307
+ // 唤醒本守卫)。
4308
+ // 标题行不可得(宿主 data-phase="hero":英雄页/空白会话;或会话视图未挂载
4309
+ // 窗口期)时落位 hero 形态:贴靠宿主侧栏展开按钮(dsh-web-mobile ⊡,语义属性
4310
+ // data-mobile-nav="toggle"/"fab",互斥、任一时刻至多一个在屏)右侧 8px 并垂直
4311
+ // 居中,absolute 挂宿主 frame([data-shell-overlay] 的 parentElement,与壳层
4312
+ // findFrame 同源,东家确认;hero 页无布局流容器可嵌,C-093)。两落位均不可得时
4313
+ // 摘除开关不渲染——静默浮层降级已去除(T-167,fail-visible)。
4314
+ // 在屏判据阈值:元素 x 超过 -10px 视为在屏内(移动壳层屏外收起抽屉 x 约 -300)
4315
+ const ON_SCREEN_X = -10;
4316
+ function placeToggle() {
4317
+ const seat = document.querySelector(CONVERSATION_SELECTOR);
4318
+ const header = seat !== null ? (seat.querySelector("header") ?? null) : null;
4319
+ const row = header !== null ? (header.firstElementChild ?? null) : null;
4320
+ // 几何感知(T-171):0.2.0 起 hero 页 header.firstElementChild 存在(左侧窄条
4321
+ // headerLeading),但移动壳层对 headerBlank/headerHidden 头部整条 display:none
4322
+ // ——行存在不等于行可见,嵌入其中会被连带隐藏(0.2.0 移动空白页入口消失根因)。
4323
+ // 可见性判据取 header 包围盒:headerLeading 自身可为 0 宽(未嵌入时槽内容被壳
4324
+ // 层隐藏,列宽塌缩),header 可见即行可嵌。宽高归零即回落 hero 形态贴靠 ⊡。
4325
+ const headerRect = header !== null ? header.getBoundingClientRect() : null;
4326
+ const headerVisible = headerRect !== null && headerRect.width > 0 && headerRect.height > 0;
4327
+ if (row !== null && headerVisible) {
4328
+ // 壳层左座检测(T-171):移动壳层在头部左缘 absolute 挂自己的目录开关
4329
+ // (data-mobile-nav="toggle",实测 [8,6,28,28]),嵌入 headerLeading 行首
4330
+ // 会与之同位叠置(0.2.0 active 页双开关冲突根因)。检出在屏壳层开关时改嵌
4331
+ // 会话头标题行(conversation.session.header 槽渲染的 titleRow,壳层以
4332
+ // padding-left:32 让出座位)行首——开关落座位右缘,标题被真实后挤;
4333
+ // 标题行不可得时维持 headerLeading 旧落位(叠置边界记录 T-171)。
4334
+ const shellToggle = [...header.querySelectorAll('button[data-mobile-nav="toggle"]')].find((el) => {
4335
+ const rect = el.getBoundingClientRect();
4336
+ return rect.width > 0 && rect.x > ON_SCREEN_X;
4337
+ });
4338
+ let target = row;
4339
+ if (shellToggle !== undefined) {
4340
+ const titleRow = header.querySelector('[data-slot="conversation.session.header"]')?.firstElementChild ?? null;
4341
+ if (titleRow !== null) target = titleRow;
4342
+ }
4343
+ // 插入点取行内首个内容簇(标题簇)之前:行首即视觉上位于左侧边栏切换按钮
4344
+ // 的右侧,其余头部控件被真实后挤(相对顺序契约,不断言行首)。簇内含 crumb
4345
+ // 导航即认定标题簇(nav 必在 head 子树内,顶层簇即 head 本身)。
4346
+ const head = target.firstElementChild;
4347
+ const cluster = head !== null && (head.tagName === "NAV" || head.querySelector("nav") !== null)
4348
+ ? head
4349
+ : null;
4350
+ const settled = toggle.parentElement === target && toggle.hasAttribute("data-embedded") &&
4351
+ (cluster !== null ? toggle.nextSibling === cluster : target.firstElementChild === toggle);
4352
+ if (settled) return;
4353
+ if (cluster !== null) target.insertBefore(toggle, cluster);
4354
+ else target.prepend(toggle);
4355
+ toggle.setAttribute("data-embedded", "");
4356
+ toggle.removeAttribute("data-hero");
4357
+ // 定位关键量内联钉死:嵌入态回归文档流不依赖样式表状态(东家真机
4358
+ // 疑似样式与脚本状态失配,T-164);样式表只承担外观。
4359
+ toggle.style.position = "static";
4360
+ toggle.style.top = "";
4361
+ toggle.style.left = "";
4362
+ return;
4363
+ }
4364
+ // hero 形态:锚定宿主侧栏展开按钮(⊡)右侧。toggle/fab 属性值互斥(会话头
4365
+ // toggle 挂 header.actions 槽、hero 页 header 为空壳不渲染槽内容),统一
4366
+ // 寻址取首个在屏者;抽屉打开期间壳层在 drawerOpen 翻转的同一 reconciler 遍
4367
+ // 内移除 ⊡(overlay-backdrop-fab ensure),锚不可测走保留分支——开关已被
4368
+ // 抽屉与遮罩在层级上盖住,保留原位即「一直在抽屉后面被挡住」(T-168)。
4369
+ let fabEl = null;
4370
+ for (const fab of document.querySelectorAll('button[data-mobile-nav="toggle"], button[data-mobile-nav="fab"]')) {
4371
+ const rect = fab.getBoundingClientRect();
4372
+ if (rect.width > 0 && rect.x > ON_SCREEN_X) {
4373
+ fabEl = fab;
4374
+ break;
4375
+ }
4376
+ }
4377
+ const frame = document.querySelector("[data-shell-overlay]")?.parentElement ?? null;
4378
+ if (fabEl === null || frame === null) {
4379
+ // 锚或挂载点不可得:已落位开关保留原位几何——宿主抽屉打开窗口内壳层移除
4380
+ // ⊡,开关由抽屉(1300)与遮罩(1250)遮挡,摘除会在关抽屉后经复检凭空
4381
+ // 重现(T-168 东家否决);随失效行/旧 frame detach 的开关不可见,由下一
4382
+ // 次成功落位回收。未落位开关不在此进入文档(不渲染,C-093);壳层结构
4383
+ // 缺失(frame 不可得)时仍摘除已挂载开关。宿主改版致锚长期缺失时开关
4384
+ // 残留于最后有效位置,记录为边界(T-168 验证矩阵)。
4385
+ if (frame === null && toggle.parentElement !== null) toggle.remove();
4386
+ return;
4387
+ }
4388
+ if (toggle.parentElement !== frame) frame.appendChild(toggle);
4389
+ toggle.removeAttribute("data-embedded");
4390
+ toggle.setAttribute("data-hero", "");
4391
+ // 定位关键量内联钉死(T-164 策略):absolute 几何不依赖样式表状态;
4392
+ // 38 为样式表 height 缺省,量测失败时保底。
4393
+ // 几何取锚的布局坐标(offsetLeft/Top/Width/Height)而非视口 rect(T-172):
4394
+ // 两者同为 frame 直系子项、offsetParent 同为 frame,布局坐标即 frame 内
4395
+ // absolute 坐标本身;视口 rect 含 frame 自身位移(iOS rubber-band/滚动期
4396
+ // 合成层位移),烘进 frame 内坐标等于把该位移重复计入一次——位移期间每次
4397
+ // 重落位都让开关相对 ⊡ 多漂一份位移(实测 40px 位移重落位后总位移 80px),
4398
+ // 且离散重落位呈顿挫。布局坐标不受祖先 transform 影响,重落位幂等稳定。
4399
+ const toggleHeight = toggle.offsetHeight || 38;
4400
+ toggle.style.position = "absolute";
4401
+ toggle.style.top = `${Math.round(fabEl.offsetTop + (fabEl.offsetHeight - toggleHeight) / 2)}px`;
4402
+ toggle.style.left = `${Math.round(fabEl.offsetLeft + fabEl.offsetWidth + 8)}px`;
4403
+ }
4404
+
4179
4405
  // 桌面判定与"真实参与布局":中间列切为行方向,窗格固定宽、会话根弹性填充
4180
4406
  // 剩余宽度(flex:1 + min-width:0),让会话内容(标题/tabs/滚动区/输入框)
4181
4407
  // 被真实挤到窗格右侧,且聊天列在增宽的会话带内自动居中。
@@ -4491,34 +4717,37 @@ function apply(ctx) {
4491
4717
  throw response?.error ?? new Error("remote request failed");
4492
4718
  }
4493
4719
 
4494
- /** 母会话子代理目录条目查找(T-151 单源):open 地址与深翻分页地址共用同一取法,
4495
- * 防条目形状或键名演化时两处漂移。 */
4496
- function subagentCatalogEntry(subagentsByParent, parentId, id) {
4497
- const entries = subagentsByParent?.[String(parentId)]?.entries;
4498
- return Array.isArray(entries)
4499
- ? entries.find((candidate) => String(candidate?.id) === String(id))
4500
- : undefined;
4720
+ /** 母会话子代理目录条目查找(T-151 单源;T-170 改读 0.2.0 投影):open 地址与
4721
+ * 深翻分页地址共用同一取法,防条目形状或键名演化时两处漂移。 */
4722
+ function subagentCatalogEntry(projectionsBySession, parentId, id) {
4723
+ const entries = subagentCatalogOf(projectionsBySession, parentId);
4724
+ return entries?.find((candidate) => String(candidate?.id) === String(id));
4501
4725
  }
4502
4726
 
4503
4727
  /** 会话日志分页地址(R-01-012):主会话 `{kind:"session", sessionId}`;子代理
4504
- * `{kind:"subagent", parentSessionId, childSessionId, mode}`——母会话 id 兼容
4505
- * `parentSessionId` / `parentId` 两种条目键名。mode 必须与子代理描述符一致,
4506
- * 不一致被宿主以 subagent/unauthorized 拒绝(T-151):优先取母会话目录条目的
4507
- * mode,目录条目缺失(未加载、在途空窗或无该子条目)时回退行 `continuable`
4508
- * 启发(读取失败由 pagedHistoryEvents 以 null 收敛为空白详情,R-01-013)。 */
4509
- function sessionPageAddress(id, byId, subagentsByParent = null) {
4728
+ * `{kind:"subagent", parentSessionId, childSessionId, mode}`。mode 必须与子代理
4729
+ * 描述符一致,不一致被宿主以 subagent/unauthorized 拒绝(T-151):优先取母会话
4730
+ * 目录条目的 mode;目录条目缺失(未加载、在途空窗或无该子条目)时以 `unknown`
4731
+ * 兜底——0.2.0 SessionAddress 新增该合法值(旧行 `continuable` 字段已随 0.2.0
4732
+ * 行形状移除,启发不再可靠;读取失败仍由 pagedHistoryEvents 以 null 收敛为空白
4733
+ * 详情,R-01-013)。 */
4734
+ /** 子代理行的母会话 id(T-170 审核发现 7):0.2.0 行形状 parentSessionId,
4735
+ * 兼容旧 parentId 键,单点解析避免三处漂移。 */
4736
+ function parentSessionIdOf(row) {
4737
+ return row?.parentSessionId ?? row?.parentId;
4738
+ }
4739
+
4740
+ function sessionPageAddress(id, byId, projectionsBySession = null) {
4510
4741
  const row = byId[id] ?? {};
4511
4742
  if (row?.origin === "subagent") {
4512
- const parentId = row.parentSessionId ?? row.parentId;
4743
+ const parentId = parentSessionIdOf(row);
4513
4744
  if (parentId !== undefined && parentId !== null) {
4514
- const entry = subagentCatalogEntry(subagentsByParent, String(parentId), id);
4745
+ const entry = subagentCatalogEntry(projectionsBySession, String(parentId), id);
4515
4746
  return {
4516
4747
  kind: "subagent",
4517
4748
  parentSessionId: String(parentId),
4518
4749
  childSessionId: String(id),
4519
- mode: typeof entry?.mode === "string" && entry.mode !== ""
4520
- ? entry.mode
4521
- : row.continuable === true ? "continuable" : "one-shot",
4750
+ mode: typeof entry?.mode === "string" && entry.mode !== "" ? entry.mode : "unknown",
4522
4751
  };
4523
4752
  }
4524
4753
  }
@@ -4662,7 +4891,21 @@ function apply(ctx) {
4662
4891
  if (deepReadNeeded && !historyLoads.has(id)) {
4663
4892
  detail.previewFallbackLoaded = true;
4664
4893
  detail.durationFallbackLoaded = true;
4665
- const address = sessionPageAddress(id, byId, listSnap?.subagentsByParent ?? null);
4894
+ // 子代理目录未加载时先拉母会话投影再重试(T-170 审核):0.2.0 起
4895
+ // 子代理分页地址的 mode 取自 projectionsBySession 目录,缺目录时
4896
+ // 以 mode:"unknown" 贸然翻页可能被宿主拒绝、首读收敛为空白;
4897
+ // 与 retain 路径同款单发拉取(ensureParentProjections),下一轮
4898
+ // 渲染目录就位后按真实 mode 重试。
4899
+ const rowById = byId[id] ?? {};
4900
+ if (rowById?.origin === "subagent") {
4901
+ const rowParentId = parentSessionIdOf(rowById);
4902
+ if (rowParentId != null && subagentCatalogOf(listSnap?.projectionsBySession ?? null, String(rowParentId)) === undefined) {
4903
+ ensureParentProjections(String(rowParentId));
4904
+ queueSync();
4905
+ continue;
4906
+ }
4907
+ }
4908
+ const address = sessionPageAddress(id, byId, listSnap?.projectionsBySession ?? null);
4666
4909
  const promise = enqueueDetailLoad(() => Promise.resolve()
4667
4910
  .then(async () => {
4668
4911
  const { events, error } = await pagedHistoryEvents({
@@ -4716,15 +4959,16 @@ function apply(ctx) {
4716
4959
  * 不 dispose、不拆订阅(由本函数在渲染期持续执法:其切走后的下一轮渲染
4717
4960
  * 照常释放);当前会话不可判定(列表快照未就绪)时整轮跳过,不误释放。
4718
4961
  * 子代理日志窗口承载标题行可见的模型上下文,保留。切回更高档位时经既有
4719
- * 渐进路径(captureSessionLog 重订阅 + session.open 重新水合)就绪,
4962
+ * 渐进路径(captureSessionLog 重订阅 + retain 重新水合)就绪,
4720
4963
  * 不新增恢复机制。 */
4721
4964
  function enforceDensityDataDiscipline() {
4722
4965
  if (densityLevel !== "compact") return;
4723
4966
  const listSnap = getSnapshot(sessions, "list");
4724
- if (listSnap?.current == null) return;
4967
+ const currentId = currentIdOf(listSnap);
4968
+ if (currentId == null) return;
4725
4969
  for (const [id, unsubscribe] of [...logSourceSubs]) {
4726
4970
  if (!logWindowSuppressedFor(id, listSnap?.byId ?? {})) continue;
4727
- if (String(id) === String(listSnap.current)) continue;
4971
+ if (String(id) === String(currentId)) continue;
4728
4972
  try {
4729
4973
  unsubscribe?.();
4730
4974
  } catch {
@@ -4733,11 +4977,18 @@ function apply(ctx) {
4733
4977
  logSourceSubs.delete(id);
4734
4978
  const detail = sessionDetailsById.get(id) ?? null;
4735
4979
  if (detail !== null) detail.log = null;
4736
- try {
4737
- sessions?.binding?.(id)?.session?.dispose?.();
4738
- } catch {
4739
- /* dispose 不可用:订阅已断即达成本档位纪律的可用部分 */
4740
- }
4980
+ // dsh 0.2.0 retain 生命周期(T-170):拆窗即释放插件引用——旧
4981
+ // sessions.binding(id).session.dispose() 随 open/dispose 移出会话面而删除。
4982
+ releaseSessionRef(id);
4983
+ }
4984
+ // 无窗口引用的兜底释放(T-170 审核):引用可先于窗口存在(轮内状态订阅
4985
+ // 借用、切档在途),被抑制且非当前会话的残留引用一并释放,不留常驻
4986
+ // retain(R-01-025/AC-01)。
4987
+ for (const id of [...sessionRefs.keys()]) {
4988
+ if (logSourceSubs.has(id)) continue;
4989
+ if (!logWindowSuppressedFor(id, listSnap?.byId ?? {})) continue;
4990
+ if (String(id) === String(currentId)) continue;
4991
+ releaseSessionRef(id);
4741
4992
  }
4742
4993
  }
4743
4994
 
@@ -4765,33 +5016,149 @@ function apply(ctx) {
4765
5016
  // 当前选择),不可得时保持空白,不以日志历史取值或部署默认冒充(R-01-012/AC-18)。
4766
5017
  }
4767
5018
 
4768
- /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
4769
- * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
4770
- * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
4771
- function captureSessionLog(id, { cwd } = {}) {
4772
- const detail = sessionDetailsById.get(id) ?? {};
4773
- sessionDetailsById.set(id, detail);
4774
- let session = null;
5019
+ /** 会话引用获取(dsh 0.2.0 retain 生命周期,T-170):为可见卡片以独立 source
5020
+ * 持有会话代际。子代理行先经 sessions.subagentAddress 解析持久父地址(旧
5021
+ * 会话面地址安装路径随重构删除);地址不可得时先拉取母会话投影
5022
+ * 并返回 null 下轮重试。reference.ready 落定后接线 binding.eventSource 与
5023
+ * session 面(ObservableSnapshot 半面)。返回 { session, eventSource } 或
5024
+ * null(在途/不可得——调用方下轮渲染重试)。 */
5025
+ function liveSessionFace(id) {
5026
+ const key = String(id);
5027
+ const existing = sessionRefs.get(key);
5028
+ if (existing !== undefined) return existing.pending ? null : existing;
5029
+ const listSnap = getSnapshot(sessions, "list");
5030
+ const byId = listSnap?.byId ?? {};
5031
+ let target = id;
5032
+ if (isSubagentRow(byId[id], byId)) {
5033
+ const parentId = String(parentSessionIdOf(byId[id]));
5034
+ let address = null;
5035
+ try {
5036
+ address = sessions?.subagentAddress?.(String(id)) ?? null;
5037
+ } catch {
5038
+ address = null;
5039
+ }
5040
+ if (address === null || address === undefined) {
5041
+ ensureParentProjections(parentId);
5042
+ return null;
5043
+ }
5044
+ target = address;
5045
+ }
5046
+ let reference = null;
5047
+ try {
5048
+ reference = sessions?.retain?.(target, { source: PLUGIN_RETAIN_SOURCE }) ?? null;
5049
+ } catch {
5050
+ reference = null;
5051
+ }
5052
+ if (reference === null) return null;
5053
+ const record = { reference, session: null, eventSource: null, pending: true };
5054
+ sessionRefs.set(key, record);
5055
+ Promise.resolve(reference.ready)
5056
+ .then(() => {
5057
+ if (disposed) {
5058
+ try {
5059
+ reference.release();
5060
+ } catch {
5061
+ /* 已卸载:引用兜底释放 */
5062
+ }
5063
+ return;
5064
+ }
5065
+ let binding = null;
5066
+ try {
5067
+ binding = reference.binding ?? null;
5068
+ } catch {
5069
+ binding = null; // 代际已终结:下次渲染重新 retain
5070
+ }
5071
+ if (binding !== null) {
5072
+ record.session = binding.session ?? null;
5073
+ record.eventSource = binding.eventSource ?? null;
5074
+ }
5075
+ record.pending = false;
5076
+ queueSync();
5077
+ })
5078
+ .catch(() => {
5079
+ record.pending = false;
5080
+ if (!disposed) queueSync();
5081
+ });
5082
+ return null;
5083
+ }
5084
+
5085
+ /** 只借用已接线会话面,不发起 retain(T-170 审核发现 1):运行状态订阅的
5086
+ * 面必须来自既有引用(日志窗口或外壳主视图 retain 的会话)。无引用时返回
5087
+ * null,调用方降级到统一状态面(sessionStatus)与 busy 记账,不得为
5088
+ * 轮内状态订阅单独水合紧凑档非当前主会话(R-01-025/AC-01)。 */
5089
+ function peekSessionFace(id) {
5090
+ const record = sessionRefs.get(String(id));
5091
+ if (record === undefined || record.pending) return null;
5092
+ const session = record.session;
5093
+ const eventSource = record.eventSource;
5094
+ if ((session === null || session === undefined) && (eventSource === null || eventSource === undefined)) return null;
5095
+ return record;
5096
+ }
5097
+
5098
+ /** 释放一个会话引用:release retain 引用(dsh 0.2.0),断流由宿主随引用计数收敛。 */
5099
+ function releaseSessionRef(id) {
5100
+ const key = String(id);
5101
+ const record = sessionRefs.get(key);
5102
+ if (record === undefined) return;
5103
+ sessionRefs.delete(key);
4775
5104
  try {
4776
- session = sessions?.binding?.(id)?.session ?? null;
5105
+ record.reference?.release?.();
4777
5106
  } catch {
4778
- session = null;
5107
+ /* 引用已释放 */
4779
5108
  }
4780
- if (session === null) return;
5109
+ }
5110
+
5111
+ /** 母会话投影按需拉取(T-170):0.2.0 起子代理目录随列表快照的
5112
+ * projectionsBySession 下发,未加载时经 refreshProjections 拉取后重试。 */
5113
+ function ensureParentProjections(parentId) {
5114
+ if (typeof sessions?.refreshProjections !== "function") return;
5115
+ const key = String(parentId);
5116
+ if (projectionRefreshes.has(key)) return;
5117
+ projectionRefreshes.add(key);
5118
+ try {
5119
+ Promise.resolve(sessions.refreshProjections(key))
5120
+ .catch(() => {})
5121
+ .finally(() => {
5122
+ projectionRefreshes.delete(key);
5123
+ if (!disposed) queueSync();
5124
+ });
5125
+ } catch {
5126
+ projectionRefreshes.delete(key);
5127
+ }
5128
+ }
5129
+
5130
+ // 绑定会话并水合事件源(dsh 0.2.0 retain 生命周期,T-170):retain 的 ready
5131
+ // 承载共享初始历史打开(旧显式会话打开调用随 open 移出会话面而删除),
5132
+ // binding.eventSource 打开即收完整日志窗口 + 实时尾(原生 Conversation 同源)。
5133
+ // 日志窗口快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。
5134
+ function captureSessionLog(id, { cwd } = {}) {
4781
5135
  // 档位资源纪律(R-01-025/AC-01):紧凑档主会话不建立或保持会话日志窗口——
4782
- // 不订阅事件流、不发起 open 水合、不读窗口快照。判定在函数入口用时重读,
5136
+ // 不订阅事件流、不发起水合、不读窗口快照。判定必须先于 liveSessionFace:
5137
+ // dsh 0.2.0 起 retain.ready 承载共享初始历史打开,先 retain 后判档会让
5138
+ // 紧凑档非当前主会话被水合(T-170 审核发现 1)。判定在函数入口用时重读,
4783
5139
  // 切档在途的回调与重试不落地新窗口。子代理日志窗口承载标题行可见的
4784
5140
  // 模型上下文(R-01-012/AC-17),恒不裁剪(单点判定见 logWindowSuppressedFor)。
4785
5141
  if (logWindowSuppressedFor(id)) return;
5142
+ const detail = sessionDetailsById.get(id) ?? {};
5143
+ sessionDetailsById.set(id, detail);
5144
+ const live = liveSessionFace(id);
5145
+ if (live === null) return; // retain 在途/子代理地址未就绪:下轮渲染重试
5146
+ const session = live.session;
5147
+ const eventSource = live.eventSource;
5148
+ if ((session === null || session === undefined) && (eventSource === null || eventSource === undefined)) {
5149
+ // 代际已终结(binding 访问失败):释放登记,下轮渲染重新 retain。
5150
+ releaseSessionRef(id);
5151
+ return;
5152
+ }
4786
5153
  // 日志窗口无效化订阅(事件驱动,不构成轮询,R-02-004):等待/历史卡无会话状态
4787
5154
  // 订阅(syncLiveness 只订阅运行中),窗口推进(回合收尾、流式尾)必须经此回调
4788
5155
  // 重读快照并重绘;否则窗口更新只能靠渲染期重读兜底——渲染一旦静默(一次性
4789
5156
  // load 修复后),完成提醒耗时等窗口派生数据将永久停摆。随可见性 prune。
4790
- if (typeof session.eventSource?.subscribe === "function" && !logSourceSubs.has(id)) {
5157
+ if (typeof eventSource?.subscribe === "function" && !logSourceSubs.has(id)) {
4791
5158
  try {
4792
5159
  logSourceSubs.set(
4793
5160
  id,
4794
- session.eventSource.subscribe(() => {
5161
+ eventSource.subscribe(() => {
4795
5162
  if (disposed) return;
4796
5163
  const listSnap = getSnapshot(sessions, "list");
4797
5164
  captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
@@ -4802,24 +5169,7 @@ function apply(ctx) {
4802
5169
  // 订阅不可用:回退渲染期重读。
4803
5170
  }
4804
5171
  }
4805
- try {
4806
- // 日志窗口缺席才发起 open:非订阅会话(等待/历史卡)无快照推送,openState 永远
4807
- // 不可知,只以 openState 把关会对已水合会话逐帧重发 open,settle→delete→重发
4808
- // 的在途翻转同样驱动加载指示抖动(docsim 卡闪烁根因之一)。
4809
- if (!detail.log && detail.snapshot?.openState !== "open" && !sessionOpenLoads.has(id) && typeof session.open === "function") {
4810
- // 子代理事件流仅接受持久父地址:open 前先安装(T-151),否则宿主拒绝、窗口永不水合。
4811
- ensureSubagentAddress(id, session);
4812
- const opening = Promise.resolve(session.open()).catch(() => {});
4813
- sessionOpenLoads.set(id, opening);
4814
- opening.finally(() => {
4815
- if (sessionOpenLoads.get(id) === opening) sessionOpenLoads.delete(id);
4816
- queueSync();
4817
- });
4818
- }
4819
- } catch {
4820
- // open 不可用:保持当前窗口。
4821
- }
4822
- const log = session.eventSource?.getSnapshot?.() ?? null;
5172
+ const log = eventSource?.getSnapshot?.() ?? null;
4823
5173
  if (log === detail.log) return;
4824
5174
  detail.log = log;
4825
5175
  // 流式派生合并(T-127):事件到达只更新引用并标脏,applyLogEvents 的 O(日志窗口)
@@ -4848,53 +5198,6 @@ function apply(ctx) {
4848
5198
  queueSync();
4849
5199
  }
4850
5200
 
4851
- /** 触发母会话子代理目录单发读取(T-151):目录条目是地址 mode 的唯一可靠来源,
4852
- * 未加载时先拉取,目录随下一轮快照到达;每次拉取经原生 sessions.refreshSubagents
4853
- * 走既有 remote 通道,单父会话单飞、不重试,不构成轮询(R-02-004)。 */
4854
- function requestSubagentCatalog(parentId) {
4855
- if (typeof sessions?.refreshSubagents !== "function") return;
4856
- if (subagentCatalogRequests.has(parentId)) return;
4857
- subagentCatalogRequests.add(parentId);
4858
- try {
4859
- Promise.resolve(sessions?.refreshSubagents?.(parentId))
4860
- .catch(() => {})
4861
- .finally(() => {
4862
- if (!disposed) queueSync();
4863
- });
4864
- } catch {
4865
- subagentCatalogRequests.delete(parentId);
4866
- }
4867
- }
4868
-
4869
- /** 子代理会话事件流打开前置(T-151 缺陷修复):dsh 0.1.5 起宿主按地址校验会话事件
4870
- * 流路由——子代理会话仅接受持久父地址,且地址 mode 必须与其描述符一致(不一致被
4871
- * subagent/unauthorized 拒绝),持久地址仅经原生导航留存。mode 唯一可靠来源是母
4872
- * 会话的子代理目录条目:在册即经 configureSubagent 安装(与原生 selectSubagent
4873
- * 同构,但不切换当前会话);目录未加载时先单发拉取并跳过本轮安装,避免装入必被
4874
- * 拒绝的错误 mode 地址;目录拉取失败沿用既有空白详情路径(深翻以 null 收敛),
4875
- * 不阻断其余卡片。非子代理行或会话对象缺 configureSubagent 时为无操作。 */
4876
- function ensureSubagentAddress(id, session) {
4877
- const listSnap = getSnapshot(sessions, "list");
4878
- const byId = listSnap?.byId ?? {};
4879
- if (!isSubagentRow(byId[id], byId)) return;
4880
- if (typeof session?.configureSubagent !== "function") return;
4881
- const parentId = String(byId[id].parentSessionId ?? byId[id].parentId);
4882
- const catalog = isRecord(listSnap?.subagentsByParent) ? listSnap.subagentsByParent[parentId] : null;
4883
- const entry = subagentCatalogEntry(listSnap?.subagentsByParent ?? null, parentId, id);
4884
- if (!isRecord(entry) || typeof entry.mode !== "string" || entry.mode === "") {
4885
- requestSubagentCatalog(parentId);
4886
- return;
4887
- }
4888
- try {
4889
- session.configureSubagent(
4890
- { kind: "subagent", parentSessionId: parentId, childSessionId: String(id), mode: entry.mode },
4891
- typeof catalog?.parentAvailable === "boolean" ? catalog.parentAvailable : undefined,
4892
- );
4893
- } catch {
4894
- // 安装失败沿用既有空白详情路径,不阻断其余卡片。
4895
- }
4896
- }
4897
-
4898
5201
  /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
4899
5202
  * RPC 移除,目录经 `remote.session.modelCatalog()` 无参数读取;成功即缓存不再
4900
5203
  * 重发,失败留待下轮详情读取重试。 */
@@ -4911,18 +5214,43 @@ function apply(ctx) {
4911
5214
  });
4912
5215
  }
4913
5216
 
5217
+ /** 释放全部会话引用并停流 jobs roster 订阅(T-170 审核发现 5):服务代际更替
5218
+ * 与卸载两个释放点共用同一形状,抽单点避免漂移。 */
5219
+ function releaseAllSessionRefs() {
5220
+ for (const id of [...sessionRefs.keys()]) releaseSessionRef(id);
5221
+ }
5222
+
5223
+ function stopAllJobWatches() {
5224
+ for (const [, stop] of jobWatchById) {
5225
+ try {
5226
+ stop?.();
5227
+ } catch {
5228
+ /* 流已随旧代际关闭或已卸载 */
5229
+ }
5230
+ }
5231
+ jobWatchById.clear();
5232
+ }
5233
+
4914
5234
  function installServiceSubscriptions() {
4915
5235
  const nextSessions = ctx.get("sessions");
4916
5236
  const nextWorkspaces = ctx.get("workspaces");
4917
5237
  const nextUiSession = ctx.get("uiSession");
4918
- if (nextSessions === sessions && nextWorkspaces === workspaces && nextUiSession === uiSession) return;
5238
+ const nextJobs = ctx.get("jobs");
5239
+ if (nextSessions === sessions && nextWorkspaces === workspaces && nextUiSession === uiSession && nextJobs === jobs) return;
5240
+
5241
+ // 服务代际更替:旧代际上登记的 retain 引用与 jobs roster 订阅一并释放——
5242
+ // 引用的 binding 在代际终结后访问即失败(dsh 0.2.0 retain 语义)。
5243
+ releaseAllSessionRefs();
5244
+ stopAllJobWatches();
4919
5245
 
4920
5246
  sessionUnsubscribe?.();
4921
5247
  workspaceUnsubscribe?.();
4922
5248
  pendingUnsubscribe?.();
5249
+ jobsUnsubscribe?.();
4923
5250
  sessions = nextSessions ?? null;
4924
5251
  workspaces = nextWorkspaces ?? null;
4925
5252
  uiSession = nextUiSession ?? null;
5253
+ jobs = nextJobs ?? null;
4926
5254
  try {
4927
5255
  sessionUnsubscribe = sessions?.list?.subscribe?.(queueSync) ?? null;
4928
5256
  } catch {
@@ -4934,13 +5262,20 @@ function apply(ctx) {
4934
5262
  workspaceUnsubscribe = null;
4935
5263
  }
4936
5264
  try {
4937
- // 0.1.5 起 sessions 行不再承载 pendingInteraction:待确认/待审查/待回复改由
4938
- // uiSession.pendingInteractions(Map<sessionId, interaction>)独立承载,
5265
+ // dsh 0.2.0(T-170):统一会话状态面 sessionStatus 取代 pendingInteractions
5266
+ // (Map<sessionId, {running, pendingInteraction, completionUnread}>),
4939
5267
  // 订阅其快照变化驱动等待卡重算(R-01-002/AC-03)。
4940
- pendingUnsubscribe = uiSession?.pendingInteractions?.subscribe?.(queueSync) ?? null;
5268
+ pendingUnsubscribe = uiSession?.sessionStatus?.subscribe?.(queueSync) ?? null;
4941
5269
  } catch {
4942
5270
  pendingUnsubscribe = null;
4943
5271
  }
5272
+ try {
5273
+ // dsh 0.2.0(T-170):后台任务 roster 经 jobs 服务的 state 快照推送
5274
+ // (rows: Record<sessionId, JobView[]>),订阅驱动在跑任务卡重算(R-01-023)。
5275
+ jobsUnsubscribe = jobs?.state?.subscribe?.(queueSync) ?? null;
5276
+ } catch {
5277
+ jobsUnsubscribe = null;
5278
+ }
4944
5279
 
4945
5280
  queueSync();
4946
5281
  }
@@ -4955,6 +5290,41 @@ function apply(ctx) {
4955
5290
  toggle.toggleAttribute("data-drawer-open", open);
4956
5291
  queueSync();
4957
5292
  }
5293
+ // 帧状态观察(T-167/T-171):键盘(Escape)与滑动手势关抽屉不派发 pointer 事件;
5294
+ // 壳层抽屉状态机本体是 frame 的 data-sidebar-collapsed 属性,观察其翻转是
5295
+ // 关抽屉后 ⊡ 重挂、hero 落位重锚的必然唤醒点——回调仅在状态翻转与 frame
5296
+ // 直系子节点变动(锚/开关挂载摘除,T-171)时发生,无轮询成本。按元素记挂:
5297
+ // frame 就绪前延迟挂载,外壳重挂载出新 frame 时换挂。
5298
+ // 开关在锚缺失期间保留原位(T-168),翻转即检与 400ms 复检均为同值重写或
5299
+ // 重锚,无显隐突变;宿主抽屉隐藏检测(T-165)随层级归位失去必要性,整体
5300
+ // 摘除(T-168)。
5301
+ let frameObserver = null;
5302
+ let observedFrame = null;
5303
+ function armFrameObserver() {
5304
+ if (disposed) return;
5305
+ const frame = document.querySelector("[data-shell-overlay]")?.parentElement;
5306
+ if (frame == null) return;
5307
+ if (frameObserver !== null && observedFrame === frame) return;
5308
+ frameObserver?.disconnect();
5309
+ frameObserver = new MutationObserver(() => {
5310
+ if (disposed) return;
5311
+ // 翻转即检 + 400ms 复检:壳层重挂 ⊡ 晚于属性翻转(reconcile 异步),
5312
+ // 仅翻转即检会扑空且再无唤醒点;开关未摘除,复检为重锚同值。
5313
+ placeToggle();
5314
+ setTimeout(() => {
5315
+ if (disposed) return;
5316
+ placeToggle();
5317
+ }, 400);
5318
+ });
5319
+ // childList(T-171):锚(⊡/壳层开关)与开关自身的挂载摘除都是 frame 直接
5320
+ // 子节点变动——锚在静默页面上出现(壳层延迟挂 ⊡、e2e 注入)没有任何其它
5321
+ // 唤醒点(seat 突变、抽屉翻转、订阅均不发生),落位会悬到下一个任意时钟。
5322
+ // 观察直系子节点变动即检,落位从事件驱动变为确定;回调幂等无反馈环。
5323
+ frameObserver.observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed"], childList: true });
5324
+ observedFrame = frame;
5325
+ }
5326
+ armFrameObserver();
5327
+ placeToggle();
4958
5328
  function notifyLayoutChange() {
4959
5329
  try {
4960
5330
  window.dispatchEvent(new Event("resize"));
@@ -5097,9 +5467,10 @@ function apply(ctx) {
5097
5467
  enforceDensityDataDiscipline();
5098
5468
  applyDensity();
5099
5469
  // 档位已纳入渲染签名:触发一轮同步让时间线按新档位以 lastOnly 重建;
5100
- // 锚定补偿在本轮渲染提交后执行,此时量测才含新行高
5470
+ // 锚定补偿在渲染提交后逐帧执行至沉降窗结束,此时量测才含新行高
5101
5471
  //(R-01-021/AC-01、AC-08)。
5102
5472
  pendingDensityAnchor = currentCard && anchorTop !== null ? anchorTop : null;
5473
+ densityAnchorDeadline = Date.now() + 1500;
5103
5474
  queueSync();
5104
5475
  };
5105
5476
  // 档位按钮位于标题行右侧的工具区(标题区折叠控件的兄弟节点),激活天然不会
@@ -5133,6 +5504,10 @@ function apply(ctx) {
5133
5504
 
5134
5505
  // ---- 窗格容器(conversation 槽座的前置兄弟列;外壳重挂载后重插) ----
5135
5506
  function ensurePane() {
5507
+ // 开关落位守卫先于窗格守卫:头部行存在与否独立于窗格挂载态(T-160)。
5508
+ placeToggle();
5509
+ // 帧观察器在 frame 就绪后补挂(外壳重挂载后 frame 为新元素,须重挂)。
5510
+ armFrameObserver();
5136
5511
  const seat = document.querySelector(CONVERSATION_SELECTOR);
5137
5512
  if (seat === null || seat.parentElement === null) return null;
5138
5513
  // [data-slot="conversation"] 的父级是 AppFrame 的中间列(flex column)。
@@ -6095,72 +6470,65 @@ function apply(ctx) {
6095
6470
  }
6096
6471
 
6097
6472
  // ---- 轮内状态订阅生命周期(R-01-009、R-02-004) ----
6473
+ // dsh 0.2.0(T-170 审核发现 1):状态订阅只借用既有引用的会话面——日志窗口
6474
+ // 豁免(高档位/当前/子代理)与外壳主视图已 retain 的会话免费获得;紧凑档
6475
+ // 非当前主会话不 retain(retain.ready 即共享初始历史打开,等价水合,
6476
+ // R-01-025/AC-01),其运行呈现降级到统一状态面 sessionStatus(running,
6477
+ // 宿主级单订阅已随 installServiceSubscriptions 建立)与 busy 记账
6478
+ // openTurnStart(回合起点,供运行卡时钟)。
6098
6479
  function syncLiveness(runningIds, clockWanted) {
6099
6480
  for (const id of runningIds) {
6100
- if (livenessById.has(id)) continue;
6101
- let session = null;
6102
- try {
6103
- session = sessions?.binding?.(id)?.session ?? null;
6104
- } catch {
6105
- session = null;
6106
- }
6107
- if (session === null) continue; // 下次渲染再试
6108
- let unsubscribe = null;
6109
- try {
6110
- unsubscribe = session.subscribe(() => {
6111
- if (disposed) return;
6112
- const snapshot = getSessionSnapshot(session);
6113
- const live = livenessFromSnapshot(snapshot);
6114
- livenessById.set(id, { unsubscribe, liveness: live, snapshot });
6481
+ const existing = livenessById.get(id);
6482
+ const face = peekSessionFace(id);
6483
+ // 幂等调和:已订阅的记录不动;降级记录(无订阅无快照)在引用出现时
6484
+ // 升级为会话订阅,引用缺席时保持降级。
6485
+ if (existing !== undefined && ((existing.unsubscribe ?? null) !== null || existing.snapshot !== null || face === null)) continue;
6486
+ if (face !== null) {
6487
+ const session = face.session;
6488
+ let unsubscribe = null;
6489
+ if (session !== null && session !== undefined) {
6490
+ try {
6491
+ unsubscribe = session.subscribe(() => {
6492
+ if (disposed) return;
6493
+ const snapshot = getSessionSnapshot(session);
6494
+ const live = livenessFromSnapshot(snapshot);
6495
+ livenessById.set(id, { unsubscribe, liveness: live, snapshot });
6496
+ const detail = sessionDetailsById.get(id) ?? {};
6497
+ detail.snapshot = snapshot;
6498
+ sessionDetailsById.set(id, detail);
6499
+ // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
6500
+ // 就地重派生(R-01-009)。
6501
+ const listSnap = getSnapshot(sessions, "list");
6502
+ captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
6503
+ queueSync();
6504
+ });
6505
+ } catch {
6506
+ unsubscribe = null; // 订阅不可用:降级到统一状态面路径
6507
+ }
6508
+ }
6509
+ const snapshot = face.session ? getSessionSnapshot(face.session) : null;
6510
+ if (snapshot !== null) {
6511
+ livenessById.set(id, {
6512
+ unsubscribe,
6513
+ liveness: livenessFromSnapshot(snapshot),
6514
+ snapshot,
6515
+ });
6115
6516
  const detail = sessionDetailsById.get(id) ?? {};
6116
6517
  detail.snapshot = snapshot;
6117
6518
  sessionDetailsById.set(id, detail);
6118
- // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
6119
- // 就地重派生(R-01-009)。
6120
- const listSnap = getSnapshot(sessions, "list");
6121
- captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
6122
- queueSync();
6123
- });
6124
- } catch {
6125
- continue; // 订阅失败:本次跳过,下次渲染重试
6126
- }
6127
- // 子代理事件流仅接受持久父地址:open 前先安装(T-151),否则宿主拒绝、窗口永不水合。
6128
- ensureSubagentAddress(id, session);
6129
- // 档位资源纪律(R-01-025/AC-01):紧凑档主会话不发起 open 水合——轮内状态
6130
- // 订阅(本函数职责,R-02-004)保留,liveness 记账与正常路径共用下方代码,
6131
- // 日志窗口水合在切回更高档位后经渲染期重试重建。子代理模型上下文依赖其
6132
- // 窗口,不裁剪(单点判定见 logWindowSuppressedFor)。
6133
- const livenessSuppressed = logWindowSuppressedFor(id);
6134
- let opening = null;
6135
- if (!livenessSuppressed) {
6136
- try {
6137
- opening = session.open?.();
6138
- } catch {
6139
- opening = null;
6519
+ } else {
6520
+ livenessById.set(id, { unsubscribe: null, liveness: { startTime: null }, snapshot: null });
6140
6521
  }
6522
+ } else {
6523
+ // 无引用(紧凑档非当前主会话):不 retain、不订阅。运行起点由消费点
6524
+ // 逐帧从 busy 记账现取(hostStartTime 回退链既有路径),起点不落记录
6525
+ // 冻结;快照派生退回 history 补读路径(既有 fallback)。
6526
+ livenessById.set(id, {
6527
+ unsubscribe: null,
6528
+ liveness: { startTime: null },
6529
+ snapshot: null,
6530
+ });
6141
6531
  }
6142
- if (opening && typeof opening.then === "function") {
6143
- const tracked = Promise.resolve(opening).then(
6144
- () => {
6145
- sessionOpenLoads.delete(id);
6146
- queueSync();
6147
- },
6148
- () => {
6149
- sessionOpenLoads.delete(id);
6150
- queueSync();
6151
- },
6152
- );
6153
- sessionOpenLoads.set(id, tracked);
6154
- }
6155
- const snapshot = getSessionSnapshot(session);
6156
- livenessById.set(id, {
6157
- unsubscribe,
6158
- liveness: livenessFromSnapshot(snapshot),
6159
- snapshot,
6160
- });
6161
- const detail = sessionDetailsById.get(id) ?? {};
6162
- detail.snapshot = snapshot;
6163
- sessionDetailsById.set(id, detail);
6164
6532
  }
6165
6533
  for (const [id, rec] of livenessById) {
6166
6534
  if (runningIds.has(id)) continue;
@@ -6188,6 +6556,33 @@ function apply(ctx) {
6188
6556
  }
6189
6557
  }
6190
6558
 
6559
+ /** jobs roster 订阅同步(R-01-023,T-170):dsh 0.2.0 起任务 roster 经 jobs 服务
6560
+ * 按会话 watchRows 拉取(引用计数共享流),不再随列表快照推送。对列表全会话
6561
+ * 建立订阅(与旧 jobsBySession 全量推送同覆盖),离开列表即停流;幂等,每帧
6562
+ * 重入只处理差集。服务缺失时整轮跳过。 */
6563
+ function syncJobsWatches(listSnapshot) {
6564
+ if (typeof jobs?.watchRows !== "function") return;
6565
+ const ids = Array.isArray(listSnapshot?.ids) ? listSnapshot.ids : [];
6566
+ const wanted = new Set(ids.map((id) => String(id)));
6567
+ for (const [key, stop] of [...jobWatchById]) {
6568
+ if (wanted.has(key)) continue;
6569
+ jobWatchById.delete(key);
6570
+ try {
6571
+ stop?.();
6572
+ } catch {
6573
+ /* 流已断 */
6574
+ }
6575
+ }
6576
+ for (const id of wanted) {
6577
+ if (jobWatchById.has(id)) continue;
6578
+ try {
6579
+ jobWatchById.set(id, jobs.watchRows(id));
6580
+ } catch {
6581
+ /* 单会话订流失败:下帧重试 */
6582
+ }
6583
+ }
6584
+ }
6585
+
6191
6586
  function getSessionSnapshot(session) {
6192
6587
  try {
6193
6588
  return session?.getSnapshot?.() ?? null;
@@ -6361,7 +6756,7 @@ function apply(ctx) {
6361
6756
  // 按条目 id 复用,同一 card 元素不会跨 kind 复用——创建时按 entry.kind 一次性
6362
6757
  // 决定是否绑激活是安全的;若 id 规则变化须同步复核此处。
6363
6758
  const unbind = entry.kind === "job" ? null : bindCardActivation(el, (sessionId) => {
6364
- if (typeof sessions?.open !== "function") return;
6759
+ if (typeof ctx.get("uiWorkspace")?.openSession !== "function") return;
6365
6760
  lastActivatedId = sessionId;
6366
6761
  // 新激活意图取代一切旧重试链,避免过期链条稍后把当前会话拽回旧目标;
6367
6762
  // 收起抽屉的分支同样是最新意图,必须先取消挂起链条再 return。
@@ -6371,7 +6766,7 @@ function apply(ctx) {
6371
6766
  if (
6372
6767
  shouldDismissDrawerOnActivation({
6373
6768
  targetId: sessionId,
6374
- currentId: getSnapshot(sessions, "list")?.current ?? null,
6769
+ currentId: currentIdOf(getSnapshot(sessions, "list")),
6375
6770
  mobile: window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`).matches,
6376
6771
  drawerOpen:
6377
6772
  document.querySelector(`[${PANE_ATTR}]`)?.getAttribute("data-open") === "true",
@@ -6667,16 +7062,18 @@ function apply(ctx) {
6667
7062
  }
6668
7063
  }
6669
7064
  const listState = listLoadState(snapshot);
6670
- // 0.1.5 sessions 行不再承载 pendingInteraction:从 uiSession.pendingInteractions
6671
- // 快照(Map<sessionId, interaction>,kind ∈ approval/plan-review/question)回填到
6672
- // 行副本上,等待卡分类(awaiting/blocked/提问中)保持既有单一口径(R-01-002/AC-03)。
6673
- const pendingSnapshot = uiSession?.pendingInteractions?.getSnapshot?.() ?? null;
6674
- if (pendingSnapshot instanceof Map && pendingSnapshot.size > 0 && snapshot?.byId) {
7065
+ // dsh 0.2.0(T-170):统一会话状态面 sessionStatus(Map<sessionId,
7066
+ // {running, pendingInteraction, completionUnread}>)取代 pendingInteractions:
7067
+ // 从状态快照把 pendingInteraction.kind 回填到行副本上,等待卡分类
7068
+ // (awaiting/blocked/提问中)保持既有单一口径(R-01-002/AC-03)。
7069
+ const statusSnapshot = uiSession?.sessionStatus?.getSnapshot?.() ?? null;
7070
+ if (statusSnapshot instanceof Map && statusSnapshot.size > 0 && snapshot?.byId) {
6675
7071
  const byId = { ...snapshot.byId };
6676
- for (const [id, interaction] of pendingSnapshot) {
7072
+ for (const [id, status] of statusSnapshot) {
6677
7073
  const row = byId[id];
6678
- if (!isRecord(row) || typeof interaction?.kind !== "string") continue;
6679
- byId[id] = { ...row, pendingInteraction: interaction.kind };
7074
+ const kind = status?.pendingInteraction?.kind;
7075
+ if (!isRecord(row) || typeof kind !== "string") continue;
7076
+ byId[id] = { ...row, pendingInteraction: kind };
6680
7077
  }
6681
7078
  snapshot = { ...snapshot, byId };
6682
7079
  }
@@ -6698,9 +7095,12 @@ function apply(ctx) {
6698
7095
  for (const [id, record] of busyById) {
6699
7096
  if (typeof record?.openWaitStart === "number") waitingStarts.set(String(id), record.openWaitStart);
6700
7097
  }
6701
- // 在跑后台任务(R-01-023):jobsBySession 快照注入 buildEntries/buildRecent——
6702
- // liveJobs 非空的主会话保留活动区、归运行组并抑制完成/错误提醒。
6703
- const jobsBySession = isRecord(snapshot) && isRecord(snapshot.jobsBySession) ? snapshot.jobsBySession : null;
7098
+ // 在跑后台任务(R-01-023,T-170):dsh 0.2.0 起 jobsBySession 自列表快照移除,
7099
+ // 改由 jobs 服务的 state.rows(Record<sessionId, JobView[]>)注入
7100
+ // buildEntries/buildRecent——liveJobs 非空的主会话保留活动区、归运行组并抑制
7101
+ // 完成/错误提醒;roster 订阅经 syncJobsWatches 对列表全会话建立。
7102
+ syncJobsWatches(snapshot);
7103
+ const jobsBySession = jobs?.state?.getSnapshot?.()?.rows ?? null;
6704
7104
  const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds, waitingStarts, jobsBySession);
6705
7105
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
6706
7106
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
@@ -6808,7 +7208,7 @@ function apply(ctx) {
6808
7208
  entry.loadingModel = !detail?.model && (modelLoads.has(entry.id) || (entry.kind === "subagent" && historyLoads.has(entry.id)));
6809
7209
  entry.loadingTimeline =
6810
7210
  entry.timeline.length === 0 &&
6811
- (historyLoads.has(entry.id) || sessionOpenLoads.has(entry.id) || (runLikeIds.has(entry.id) && !liveRecord));
7211
+ (historyLoads.has(entry.id) || sessionRefs.get(String(entry.id))?.pending === true || (runLikeIds.has(entry.id) && !liveRecord));
6812
7212
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
6813
7213
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
6814
7214
  const projection = snapshot?.byId?.[entry.id]?.projectionValues;
@@ -6937,7 +7337,8 @@ function apply(ctx) {
6937
7337
  if (chatLatestAssistantSettled(detailSnapshot)) subagentModelReadIds.add(entry.id);
6938
7338
  }
6939
7339
  const detailIds = [...active, ...recent].map((entry) => entry.id);
6940
- detailIds.sort((a, b) => Number(String(b) === String(snapshot?.current)) - Number(String(a) === String(snapshot?.current)));
7340
+ const currentId = currentIdOf(snapshot);
7341
+ detailIds.sort((a, b) => Number(String(b) === String(currentId)) - Number(String(a) === String(currentId)));
6941
7342
  // 档位资源纪律(R-01-025/AC-01):渲染期持续执法——进入紧凑档即拆主会话窗口;
6942
7343
  // 拆窗时被豁免的当前会话在切走后的下一轮渲染照常释放。幂等:主会话拆窗后
6943
7344
  // 不在 logSourceSubs,重复执法为无操作。
@@ -6950,12 +7351,17 @@ function apply(ctx) {
6950
7351
  // 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
6951
7352
  // 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
6952
7353
  // 耗尽宽限与 turnStart 不匹配自校正,仅在 dispose 时整体清除。
6953
- pruneInvisibleEntries([sessionDetailsById, modelLoads, historyLoads, sessionOpenLoads], visibleIds);
7354
+ pruneInvisibleEntries([sessionDetailsById, modelLoads, historyLoads], visibleIds);
6954
7355
  // 模型目录订阅同生命周期:不可见即先 unsubscribe 再除名,监听器不残留(R-01-012/AC-16)。
6955
7356
  pruneSubscriptions(modelDirectorySubs, visibleIds);
6956
7357
  pruneSubscriptions(logSourceSubs, visibleIds);
7358
+ // 会话引用同生命周期(T-170):离开可见集合即 release retain 引用——retain 的
7359
+ // 共享代际与流随引用计数收敛,对应旧 dispose 语义。
7360
+ for (const refId of [...sessionRefs.keys()]) {
7361
+ if (!visibleIds.has(refId)) releaseSessionRef(refId);
7362
+ }
6957
7363
  // 重试链目标已成为当前会话(他途到达)即取消,避免过期链条拽回会话。
6958
- cancelStaleOpenRetries({ currentId: snapshot?.current ?? null, activatedId: lastActivatedId });
7364
+ cancelStaleOpenRetries({ currentId, activatedId: lastActivatedId });
6959
7365
 
6960
7366
  const visibleEntries = [...active, ...recent];
6961
7367
  const pulseSurface = desktopQuery.matches
@@ -7053,7 +7459,7 @@ function apply(ctx) {
7053
7459
  }
7054
7460
  runMoveGhosts(movePlans);
7055
7461
  if (shiftRects !== null) runShiftAnimations(shiftRects);
7056
- ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
7462
+ ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), currentIdOf(snapshot));
7057
7463
  if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
7058
7464
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
7059
7465
  const headerEl = pane.querySelector(".dap-titlebar");
@@ -7126,7 +7532,10 @@ function apply(ctx) {
7126
7532
  }
7127
7533
  // 锚定补偿(R-01-021/AC-01):档位切换的渲染落地后量测当前选中卡的新位置并
7128
7534
  // 补偿 scrollTop,使当前卡顶部回到切换前相对视口的位置;补偿量超滚动边界时
7129
- // 由浏览器钳制(以当前卡不滚出可视范围为准)。
7535
+ // 由浏览器钳制(以当前卡不滚出可视范围为准)。沉降窗内逐帧补偿:dsh 0.2.0
7536
+ // 起切回高档位的窗口经 retain→ready 异步重建,时间线内容分多帧长回,单帧
7537
+ // 补偿会漏掉后期增长(T-170 审核修复回归);位置稳定(残差 ≤1px)或超窗
7538
+ // (1500ms)即撤,避免与用户手动滚动长期对抗。
7130
7539
  if (pendingDensityAnchor !== null) {
7131
7540
  const scrollEl = pane.querySelector(".dap-scroll");
7132
7541
  // 补偿按当前 DOM 的 data-current 卡实时定位:跨区迁移等渲染可能重建卡片
@@ -7135,13 +7544,21 @@ function apply(ctx) {
7135
7544
  if (scrollEl !== null && currentCard !== null) {
7136
7545
  const viewportTop = scrollEl.getBoundingClientRect().top;
7137
7546
  const shiftedTop = currentCard.getBoundingClientRect().top - viewportTop;
7547
+ // 仅按沉降窗超时撤除(T-170):窗口重建分多帧长回且成簇到达,帧间
7548
+ // 空隙的「稳定帧」不可作为撤除依据——提前撤除会让后续突增失偿。
7138
7549
  scrollEl.scrollTop += shiftedTop - pendingDensityAnchor;
7550
+ if (Date.now() > densityAnchorDeadline) {
7551
+ pendingDensityAnchor = null;
7552
+ densityAnchorDeadline = 0;
7553
+ }
7554
+ } else {
7555
+ pendingDensityAnchor = null;
7556
+ densityAnchorDeadline = 0;
7139
7557
  }
7140
- pendingDensityAnchor = null;
7141
7558
  }
7142
7559
  }
7143
7560
 
7144
- // ---- 打开会话(让 sessions.open 自己校验列表,失败时 refresh + 重试) ----
7561
+ // ---- 打开会话(dsh 0.2.0 经 uiWorkspace.openSession 导航,currentId 驱动重试链) ----
7145
7562
  const MAX_OPEN_ATTEMPTS = 60;
7146
7563
  function cardElFor(sessionId) {
7147
7564
  return document.querySelector(`[${PANE_ATTR}] [data-session-id="${escapeCssString(sessionId)}"]`);
@@ -7184,31 +7601,44 @@ function apply(ctx) {
7184
7601
  }, 500);
7185
7602
  });
7186
7603
  }
7604
+ // dsh 0.2.0 导航目标(T-170):子代理行以持久父地址寻址(T-151 语义后继——宿主
7605
+ // 仍按地址校验子代理会话路由);地址不可得(母会话投影未拉取)时以裸 id 发起,
7606
+ // 重试链在投影到达后自然命中。
7607
+ function openTargetFor(sessionId) {
7608
+ const listSnap = getSnapshot(sessions, "list");
7609
+ const byId = listSnap?.byId ?? {};
7610
+ if (!isSubagentRow(byId[sessionId], byId)) return sessionId;
7611
+ try {
7612
+ return sessions?.subagentAddress?.(String(sessionId)) ?? sessionId;
7613
+ } catch {
7614
+ return sessionId;
7615
+ }
7616
+ }
7187
7617
  function attemptOpen(sessionId, attempt) {
7188
- // 过期链条(目标已到达 / 已被新激活意图取代)不再发起 open,防止拽回会话。
7189
- if (
7190
- shouldCancelOpenRetry({
7191
- targetId: sessionId,
7192
- currentId: getSnapshot(sessions, "list")?.current ?? null,
7193
- activatedId: lastActivatedId,
7194
- })
7195
- ) {
7618
+ // 过期链条(目标已到达 / 已被新激活意图取代)不再发起 open,防止拽回会话;
7619
+ // 到达(currentId 命中本目标)即视为导航成功并收链。
7620
+ const currentId = currentIdOf(getSnapshot(sessions, "list"));
7621
+ if (shouldCancelOpenRetry({ targetId: sessionId, currentId, activatedId: lastActivatedId })) {
7196
7622
  cancelOpenRetry(sessionId);
7197
7623
  return;
7198
7624
  }
7199
7625
  const el = cardElFor(sessionId);
7200
7626
  if (el !== null) el.setAttribute("data-opening", "");
7201
7627
 
7202
- if (openSession(sessions, sessionId)) {
7203
- // 移动视口下抑制原生 composer 在 sessionId 变化后的自动聚焦,
7204
- // 避免切换会话弹出软键盘(桌面保持原生自动聚焦)。
7205
- if (!desktopQuery.matches) suppressComposerAutofocus(document);
7206
- // 到达新目标后取消全部剩余链条:任何旧链成功都会把会话从新目标拽走。
7207
- cancelStaleOpenRetries({ activatedId: sessionId });
7208
- cancelOpenRetry(sessionId);
7209
- el?.removeAttribute("data-opening");
7210
- return;
7211
- }
7628
+ // dsh 0.2.0(T-170):导航经 uiWorkspace.openSession(内部以 source "mainView"
7629
+ // retain 并驱动外壳 selection,替代已删除的 sessions.open)。发起不等于到达:
7630
+ // 到达判定交给 currentId 驱动的取消链(同步循环每帧 cancelStaleOpenRetries
7631
+ // 与本重试链共用 shouldCancelOpenRetry),data-opening 随取消/到达摘除;
7632
+ // 未到达则 500ms 后重发导航(MAX_OPEN_ATTEMPTS 封顶)。
7633
+ // 移动端抑制(R-01-005/AC-01):宿主 composer 聚焦 effect 与 selection 同一
7634
+ // 提交周期发生,抑制必须在发起导航时即武装(blur + 捕获期 focusin 拦截窗),
7635
+ // 不能等到达判定——0.1.5 的「成功后抑制」时序在 0.2.0 会晚于宿主聚焦。
7636
+ // 窗宽分档(T-170 审核发现 5):首发沿用 1200ms 全窗;重试只武装 60ms
7637
+ // 窄窗——失败的尝试不触发 selection 变更、宿主不聚焦,宽窗只会在失败链
7638
+ // 期间连续吞掉用户聚焦;窄窗仍覆盖成功尝试的同一提交周期聚焦 effect。
7639
+ const suppressFocus = !desktopQuery.matches;
7640
+ openSession(ctx.get("uiWorkspace"), openTargetFor(sessionId));
7641
+ if (suppressFocus) suppressComposerAutofocus(document, setTimeout, attempt === 0 ? 1200 : 60);
7212
7642
  if (attempt < MAX_OPEN_ATTEMPTS) scheduleOpenRetry(sessionId, attempt + 1);
7213
7643
  else cancelOpenRetry(sessionId);
7214
7644
  }
@@ -7324,6 +7754,9 @@ function apply(ctx) {
7324
7754
 
7325
7755
  const cleanup = () => {
7326
7756
  disposed = true;
7757
+ frameObserver?.disconnect();
7758
+ frameObserver = null;
7759
+ observedFrame = null;
7327
7760
  sessionUnsubscribe?.();
7328
7761
  workspaceUnsubscribe?.();
7329
7762
  pendingUnsubscribe?.();
@@ -7363,8 +7796,12 @@ function apply(ctx) {
7363
7796
  // 卸载即全量退订:空可见集合驱动 pruneSubscriptions 先 unsubscribe 再除名。
7364
7797
  pruneSubscriptions(modelDirectorySubs, new Set());
7365
7798
  pruneSubscriptions(logSourceSubs, new Set());
7799
+ // 会话引用与 jobs roster 订阅随卸载全量释放(dsh 0.2.0 retain/流语义,T-170)。
7800
+ releaseAllSessionRefs();
7801
+ stopAllJobWatches();
7802
+ jobsUnsubscribe?.();
7803
+ jobsUnsubscribe = null;
7366
7804
  progressAnchorById.clear();
7367
- sessionOpenLoads.clear();
7368
7805
  loadQueue.length = 0;
7369
7806
  loadInflight = 0;
7370
7807
  for (const rec of cardsById.values()) rec.unbind?.();