dsh-activity-pane 0.14.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
  // 展示规则:
@@ -152,6 +155,15 @@ function nextDensity(value) {
152
155
  return DENSITY_ORDER[(index + 1) % DENSITY_ORDER.length];
153
156
  }
154
157
 
158
+ /**
159
+ * 档位资源纪律谓词(R-01-025/AC-01):紧凑呈现档下主会话日志窗口不建立或保持。
160
+ * 子代理日志窗口承载标题行可见的模型上下文(R-01-012/AC-17),恒不裁剪;
161
+ * 输入档位经 normalizeDensity 归一,非法值视作中间档即不裁剪(R-01-025/AC-01)。
162
+ */
163
+ function logWindowSuppressed(densityLevel, isSubagent) {
164
+ return normalizeDensity(densityLevel) === "compact" && isSubagent !== true;
165
+ }
166
+
155
167
  function isRecord(value) {
156
168
  return value !== null && typeof value === "object" && !Array.isArray(value);
157
169
  }
@@ -1517,12 +1529,42 @@ function instructionTime(row) {
1517
1529
  return Number.isFinite(time) ? time : -1;
1518
1530
  }
1519
1531
 
1520
- /** 子代理的展示标题:优先目录 label,其次 displayTitle,兜底 "子任务"。 */
1521
- function subagentTitle(parentId, id, byId, subagentsByParent = {}) {
1522
- const entries = subagentsByParent[parentId]?.entries;
1523
- const entry = Array.isArray(entries)
1524
- ? entries.find((candidate) => String(candidate?.id) === String(id))
1525
- : 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
+ );
1526
1568
  const label = cleanText(entry?.label);
1527
1569
  if (label) return label;
1528
1570
  const display = cleanText(byId[id]?.displayTitle);
@@ -1566,14 +1608,14 @@ function mainTitle(byId, id) {
1566
1608
  * jobsBySession(R-01-023):快照携带的 `jobsBySession` 任务视图映射——存在在跑后台任务
1567
1609
  * (status ∈ {running, stopping})的主会话获得第三种自身活动来源:保留在活动区归入运行组、
1568
1610
  * 完成提醒与错误提醒被抑制(AC-01、AC-02);条目携带 liveJobs(startedAt 升序)供渲染层
1569
- * 标注数量与构建任务行(R-01-024)。子代理行不派生 liveJobs(后台任务当前仅主会话呈现)。
1611
+ * 标注数量与构建任务行(R-01-023)。子代理行不派生 liveJobs(后台任务当前仅主会话呈现)。
1570
1612
  */
1571
1613
  function buildEntries(snapshot, workspaceItems, detailsById = {}, completions = null, delegatingIds = null, archivedIds = [], waitingStarts = null, jobsBySession = null) {
1572
1614
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
1573
1615
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1574
- const current = snapshot?.current ?? null;
1575
- const subagentsByParent = isRecord(snapshot?.subagentsByParent)
1576
- ? snapshot.subagentsByParent
1616
+ const current = currentIdOf(snapshot);
1617
+ const projectionsBySession = isRecord(snapshot?.projectionsBySession)
1618
+ ? snapshot.projectionsBySession
1577
1619
  : {};
1578
1620
  const archived = archivedIds instanceof Set
1579
1621
  ? new Set([...archivedIds].map((id) => String(id)))
@@ -1707,7 +1749,7 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1707
1749
  : "awaiting",
1708
1750
  descendantActive: m.descendantActive,
1709
1751
  title: m.isSub
1710
- ? subagentTitle(parentId, id, byId, subagentsByParent)
1752
+ ? subagentTitle(parentId, id, byId, projectionsBySession)
1711
1753
  : mainTitle(byId, id),
1712
1754
  workspaceTitle: workspace.title,
1713
1755
  workspaceKey: workspace.key,
@@ -1743,7 +1785,7 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1743
1785
  stateAt,
1744
1786
  questionPreview: m.pending && m.row.pendingInteraction === "question" ? (questionPreview ?? null) : undefined,
1745
1787
  });
1746
- // 后台任务子条目(R-01-024):与子代理同形的缩进子卡,跟随母会话在其全部
1788
+ // 后台任务子条目(R-01-023):与子代理同形的缩进子卡,跟随母会话在其全部
1747
1789
  // 子代理之前(即母亲条目的直接后继);结束即随 liveJobs 清空而消失(R-01-023/AC-03)。
1748
1790
  // 复合 id 在去重视野(entries/visited/cardsById)中唯一,且携 jobs 前缀与子代理
1749
1791
  // 会话 id 空间天然隔离;层级连接线与子代理同规则参与 trackRuns(R-01-003/AC-04),
@@ -1765,7 +1807,6 @@ function buildEntries(snapshot, workspaceItems, detailsById = {}, completions =
1765
1807
  userPreview: "",
1766
1808
  agentPreview: "",
1767
1809
  isCurrent: false,
1768
- jobId: job.id,
1769
1810
  jobStatus: job.status,
1770
1811
  jobStartedAt: job.startedAt,
1771
1812
  });
@@ -2003,11 +2044,7 @@ function entryErrorNote(completion) {
2003
2044
  return typeof message === "string" && message !== "" ? message : ERROR_NOTE_FALLBACK;
2004
2045
  }
2005
2046
 
2006
- // ---- 后台任务(R-01-023、R-01-024):在跑活性归一与 job_output 读取回放 ----
2007
-
2008
- /** 后台任务输出回放的字符上限(R-01-024/AC-04):模型每次 job_output 读取的
2009
- * 已定案文本拼接后超出即截断并置 truncated;与单条错误信息截断同量级考虑。 */
2010
- const JOB_OUTPUT_MAX_CHARS = 20000;
2047
+ // ---- 后台任务(R-01-023):在跑活性归一 ----
2011
2048
 
2012
2049
  /** 在跑后台任务的活性状态全集(R-01-023):stopping 视同在跑——停止请求已发出但
2013
2050
  * 任务尚未结束,呈现与提醒抑制口径与 running 一致。 */
@@ -2053,99 +2090,6 @@ function jobKindLabel(kind) {
2053
2090
  return JOB_KIND_LABELS[kind] ?? kind;
2054
2091
  }
2055
2092
 
2056
- /** 从 tool-result 消息提取纯文本:tool-result 内容块内的 text 片段按换行拼接;
2057
- * 非数组内容或无文本块返回 undefined(错误结果是否纳入由调用方按 isError 判定)。 */
2058
- function jobResultText(message) {
2059
- if (!Array.isArray(message.content)) return undefined;
2060
- const parts = [];
2061
- for (const block of message.content) {
2062
- if (!isRecord(block) || block.type !== "tool-result") continue;
2063
- if (!Array.isArray(block.content)) continue;
2064
- for (const item of block.content) {
2065
- if (isRecord(item) && item.type === "text" && typeof item.text === "string") parts.push(item.text);
2066
- }
2067
- }
2068
- return parts.length > 0 ? parts.join("\n") : undefined;
2069
- }
2070
-
2071
- /** tool-result 是否为错误结果(内层 tool-result 块的 isError 标志)。 */
2072
- function jobResultIsError(message) {
2073
- if (!Array.isArray(message.content)) return false;
2074
- return message.content.some((block) => isRecord(block) && block.type === "tool-result" && block.isError === true);
2075
- }
2076
-
2077
- /**
2078
- * 从事件记录列表提取 job_output 读写轨迹(R-01-024):`tool/call` 名为 `job_output`
2079
- * 的行经 `arguments.job_id`(JSON 字符串或已解析对象)登记 callId → jobId 映射,
2080
- * `tool/result` 行按 `message.source.callId` 配对并携带模型收到的定案文本与错误标志。
2081
- * 兼容包装记录 `{ seq, event }` 与平面事件两种形状(seq 取事件或记录顶层);seq 非法的
2082
- * 事件跳过。返回按 seq 升序的去重轨迹数组(同 seq 后到者胜——实时镜像与日志重放合并
2083
- * 时的自然口径)。
2084
- */
2085
- function jobOutputTraces(records) {
2086
- const bySeq = new Map();
2087
- for (const record of Array.isArray(records) ? records : []) {
2088
- const event = eventOf(record);
2089
- if (!isRecord(event) || (event.type !== "tool/call" && event.type !== "tool/result")) continue;
2090
- const seq = Number(event.seq ?? (isRecord(record) ? record.seq : undefined));
2091
- if (!Number.isFinite(seq)) continue;
2092
- const data = isRecord(event.data) ? event.data : {};
2093
- if (event.type === "tool/call") {
2094
- if (data.name !== "job_output" || typeof data.callId !== "string") continue;
2095
- let jobId = null;
2096
- if (typeof data.arguments === "string") {
2097
- try {
2098
- const parsed = JSON.parse(data.arguments);
2099
- if (isRecord(parsed) && typeof parsed.job_id === "string") jobId = parsed.job_id;
2100
- } catch {
2101
- // 参数不可解析:不构成有效的 job_output 调用轨迹。
2102
- }
2103
- } else if (isRecord(data.arguments) && typeof data.arguments.job_id === "string") {
2104
- jobId = data.arguments.job_id;
2105
- }
2106
- if (jobId === null) continue;
2107
- bySeq.set(seq, { seq, kind: "call", callId: data.callId, jobId });
2108
- } else {
2109
- const message = isRecord(data.message) ? data.message : null;
2110
- const callId = typeof message?.source?.callId === "string" ? message.source.callId : null;
2111
- if (callId === null) continue;
2112
- bySeq.set(seq, { seq, kind: "result", callId, text: jobResultText(message), isError: jobResultIsError(message) });
2113
- }
2114
- }
2115
- return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
2116
- }
2117
-
2118
- /**
2119
- * 从轨迹回放指定后台任务的模型已读输出(R-01-024/AC-01):按 seq 序配对 call → result,
2120
- * 拼接归属 jobId 的全部读取文本;剔除错误结果与 `(no new output)` 占位(模型视角的
2121
- * 「无新内容」对人无展示价值)。返回 `{ text, truncated, read }`——read = 存在已配对
2122
- * 读取(无配对时 text 为空,客户端以「尚未被读取」承接,R-01-024/AC-02);超出
2123
- * limit 按字符截断并置 truncated(R-01-024/AC-04)。同 seq 轨迹去重(后到者胜),
2124
- * 供日志重放与实时镜像两源合并。jobId 非法返回未读取空结果。
2125
- */
2126
- function jobOutputFromTraces(traces, jobId, limit = JOB_OUTPUT_MAX_CHARS) {
2127
- if (typeof jobId !== "string" || jobId === "") return { text: "", truncated: false, read: false };
2128
- const bySeq = new Map();
2129
- for (const trace of Array.isArray(traces) ? traces : []) {
2130
- if (isRecord(trace) && Number.isFinite(Number(trace?.seq))) bySeq.set(Number(trace.seq), trace);
2131
- }
2132
- const jobOf = new Map();
2133
- const parts = [];
2134
- let read = false;
2135
- for (const trace of [...bySeq.values()].sort((a, b) => a.seq - b.seq)) {
2136
- if (trace?.kind === "call") {
2137
- if (typeof trace.jobId === "string") jobOf.set(trace.callId, trace.jobId);
2138
- } else if (jobOf.get(trace.callId) === jobId) {
2139
- read = true;
2140
- if (trace.isError !== true && typeof trace.text === "string" && !trace.text.startsWith("(no new output)")) {
2141
- parts.push(trace.text);
2142
- }
2143
- }
2144
- }
2145
- const text = parts.join("\n");
2146
- return { text: text.length > limit ? text.slice(0, limit) : text, truncated: text.length > limit, read };
2147
- }
2148
-
2149
2093
  /**
2150
2094
  * 活动区→历史区迁移检测(R-01-010/AC-07):上一帧活动区 id 在本帧离开活动区且出现于
2151
2095
  * 历史区即判定为一次迁移;彻底消失(归档、不再被会话服务列出)不判定。prevActiveIds 为上一帧
@@ -2496,7 +2440,7 @@ function createSessionEventSerializer() {
2496
2440
  function buildRecent(snapshot, workspaceItems, now, detailsById = {}, archivedIds = [], completions = null, delegatingIds = null, turnEnds = null, jobsBySession = null) {
2497
2441
  const byId = isRecord(snapshot) && isRecord(snapshot.byId) ? snapshot.byId : {};
2498
2442
  const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
2499
- const current = snapshot?.current ?? null;
2443
+ const current = currentIdOf(snapshot);
2500
2444
  const items = Array.isArray(workspaceItems) ? workspaceItems : [];
2501
2445
  const activeIds = activeSessionIds(byId);
2502
2446
  const archived = archivedIds instanceof Set ? archivedIds : new Set(archivedIds ?? []);
@@ -2743,13 +2687,16 @@ function shouldDismissDrawerOnActivation({ targetId, currentId, mobile, drawerOp
2743
2687
  }
2744
2688
 
2745
2689
  /**
2746
- * 调用 DSH 原生会话导航;由调用方决定失败后的 refresh/retry 策略。
2747
- * 不读取 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,由调用方决定重试策略。
2748
2695
  */
2749
- function openSession(sessions, sessionId) {
2750
- if (typeof sessions?.open !== "function") return false;
2696
+ function openSession(uiWorkspace, target) {
2697
+ if (typeof uiWorkspace?.openSession !== "function") return false;
2751
2698
  try {
2752
- sessions.open(sessionId);
2699
+ uiWorkspace.openSession(target);
2753
2700
  return true;
2754
2701
  } catch {
2755
2702
  return false;
@@ -2846,15 +2793,26 @@ function bindBackdropDismiss(backdrop, dismiss) {
2846
2793
  //
2847
2794
  // 数据来源:DSH 原生 `sessions` / `workspaces` 客户端服务(推送式快照)+ `remote`
2848
2795
  // 服务的部署级模型目录(session/modelCatalog)与会话日志分页(session/page,
2849
- // Session 格式 V3——dsh 0.1.5 起快照不再携带会话内容、connection.api 门面移除)+
2850
- // 运行中会话的原生订阅(binding().session,运行状态面)+ 可选 `modelDirectories`
2851
- // 目录 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、紧凑档拆窗、服务代际更替与卸载释放。
2852
2808
 
2853
2809
  const name = "dsh-activity-pane";
2854
- // remote.session:dsh 0.1.5 起会话 RPC(目录/日志分页)经 api-remotes 的点分命名空间
2855
- // 注入(cordis 代理对未注入属性直接抛错,父服务声明不代表子命名空间可用;仅声明
2856
- // 子命名空间即可解析,无需再注入父面)。
2857
- 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"];
2858
2816
 
2859
2817
  const CONVERSATION_SELECTOR = "#root [data-slot=\"main\"]";
2860
2818
  const PANE_ATTR = "data-dsh-activity-pane";
@@ -2977,8 +2935,10 @@ const CSS = `
2977
2935
  -webkit-overflow-scrolling: touch;
2978
2936
  padding: 0 0 10px;
2979
2937
  /* Keep the native scrollbar just left of the edge handle, as in the host
2980
- sidebar; stable keeps the list geometry unchanged when overflow appears. */
2981
- 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);
2982
2942
  scrollbar-gutter: stable;
2983
2943
  }
2984
2944
  /* 滚动条在滚动或鼠标进入窗格时显示(R-01-004/AC-03,与外壳侧栏一致):thumb 默认透明,
@@ -3566,24 +3526,39 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3566
3526
  }
3567
3527
  [data-dsh-activity-pane] .dap-jobs-chip[hidden] { display: none; }
3568
3528
  /* 后台任务子卡(R-01-023/AC-05、AC-07):与子代理卡同构的两行卡面,底色在子代理卡
3569
- 底色上轻染任务状态点同族的蓝以相互可辨;状态点色随任务状态翻转。 */
3529
+ 底色上轻染任务状态点同族的蓝以相互可辨;状态点色随任务状态翻转。
3530
+ 子卡为纯展示(R-01-023/AC-08):默认光标,悬停不提供可点反馈。 */
3570
3531
  [data-dsh-activity-pane] .dap-card[data-kind="job"] {
3571
3532
  padding: 6px 10px;
3572
3533
  border-radius: 12px;
3573
3534
  background: color-mix(in srgb, #65a0ff 8%, rgba(25, 27, 32, 0.95));
3574
- cursor: pointer;
3535
+ cursor: default;
3575
3536
  }
3576
- /* 任务卡行 1(R-01-023/AC-05):状态点 + 工具名称 + 右缘随时钟时长;工具名不可得时
3577
- 文本段隐藏,仅保留状态点与时长。 */
3537
+ [data-dsh-activity-pane] .dap-card[data-kind="job"]:hover {
3538
+ border-color: rgba(255, 255, 255, 0.13);
3539
+ filter: none;
3540
+ }
3541
+ /* 任务卡行 1(R-01-023/AC-05、AC-09):状态点 + 工具名称 + 右缘随时钟时长;工具名不可得时
3542
+ 文本段隐藏,仅保留状态点与时长。状态点活性脉冲(AC-09):与运行中主会话卡标题状态点
3543
+ 同源同节奏(dap-pulse 1.2s),光晕与着色同色相——运行中任务蓝、停止中琥珀。 */
3578
3544
  [data-dsh-activity-pane] .dap-card[data-kind="job"] .dap-job-kind {
3579
3545
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3580
3546
  font-size: 11px; line-height: 15px;
3581
3547
  }
3582
3548
  [data-dsh-activity-pane] .dap-job-dot {
3583
3549
  flex: none; width: 6px; height: 6px; border-radius: 50%;
3550
+ }
3551
+ [data-dsh-activity-pane] .dap-job-dot[data-status="running"] {
3584
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;
3585
3561
  }
3586
- [data-dsh-activity-pane] .dap-job-dot[data-status="stopping"] { background: #f5a524; }
3587
3562
  [data-dsh-activity-pane] .dap-job-elapsed {
3588
3563
  flex: none; margin-left: auto; font-size: 10px; line-height: 15px;
3589
3564
  color: color-mix(in srgb, currentColor 55%, transparent); font-variant-numeric: tabular-nums;
@@ -3599,23 +3574,6 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3599
3574
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3600
3575
  font-family: var(--dsh-font-mono, monospace); font-size: 11px; line-height: 15px;
3601
3576
  }
3602
- /* 子卡输出区(R-01-024/AC-01):展开时追加于卡内底部,终端风回放。 */
3603
- [data-dsh-activity-pane] .dap-jobout {
3604
- min-width: 0; max-height: 160px; overflow: auto;
3605
- margin: 4px 0 0;
3606
- background: color-mix(in srgb, currentColor 7%, transparent);
3607
- border-radius: 6px; padding: 4px 6px;
3608
- }
3609
- [data-dsh-activity-pane] .dap-jobout-pre {
3610
- margin: 0; white-space: pre-wrap; word-break: break-word;
3611
- font-family: var(--dsh-font-mono, monospace); font-size: 10px; line-height: 14px;
3612
- color: color-mix(in srgb, currentColor 78%, transparent);
3613
- }
3614
- [data-dsh-activity-pane] .dap-jobout-hint {
3615
- font-size: 10px; line-height: 14px;
3616
- color: color-mix(in srgb, currentColor 55%, transparent);
3617
- }
3618
- [data-dsh-activity-pane] .dap-jobout-hint:empty { display: none; }
3619
3577
  [data-dsh-activity-pane] .dap-history-line {
3620
3578
  display: flex; align-items: center; gap: 4px; height: 15px;
3621
3579
  min-width: 0; overflow: hidden; white-space: nowrap;
@@ -3822,26 +3780,67 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3822
3780
  .dap-toggle .dap-toggle-count .dap-spinner {
3823
3781
  vertical-align: middle;
3824
3782
  }
3825
- /* 移动端浮动开关按钮:仅在窄屏显示(桌面隐藏)。固定于会话头部左上角、
3826
- 原生左边栏切换按钮(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 宽,较长计数沿既有上限处理。 */
3827
3799
  .dap-toggle {
3828
- position: fixed; top: 12px; left: 44px; z-index: 2147482991;
3829
3800
  display: none;
3830
- align-items: center; gap: 6px;
3831
- min-height: 30px; padding: 0 11px;
3832
- border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
3833
- border-radius: 999px;
3834
- background: rgba(24, 28, 38, 0.94);
3835
- 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;
3836
3806
  font-size: 12px; font-weight: 600;
3837
3807
  cursor: pointer;
3838
- 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;
3839
3834
  }
3840
3835
  .dap-toggle .dap-toggle-count {
3841
- 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;
3842
3840
  background: color-mix(in srgb, currentColor 16%, transparent);
3843
- padding: 0 5px; font-size: 10px; font-weight: 700;
3841
+ padding: 0 8px; font-size: 12px; font-weight: 700;
3844
3842
  }
3843
+ /* 徽标等待态:执行色底 + 呼吸动画(有待执行行动时点亮)。 */
3845
3844
  .dap-toggle[data-awaiting] .dap-toggle-count {
3846
3845
  background: rgba(46, 42, 26, 0.97);
3847
3846
  animation: dap-await-pulse 1.2s ease-in-out infinite;
@@ -3904,12 +3903,18 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {
3904
3903
  body,不落本规则,浮动开关徽标脉冲(R-01-002/AC-06、AC-07)照常。 */
3905
3904
  [data-dsh-activity-pane]:not([data-open="true"]) { content-visibility: hidden; }
3906
3905
  .dap-backdrop[data-drawer-open] { display: block; }
3907
- .dap-toggle { display: flex; }
3908
- .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; }
3909
3914
  }
3910
3915
  /* 浅色主题适配:外壳以 body 上 data-ds-dark-theme 属性标记深色(缺省即浅色),
3911
3916
  并在两个作用域下翻转整套 --dsw-alias-* 变量。本块只覆盖上文暗色专用的硬编码
3912
- 颜色(卡片底色/描边/阴影、时间线文字与轨道、进度轨道、浮动开关),深色主题下
3917
+ 颜色(卡片底色/描边/阴影、时间线文字与轨道、进度轨道),深色主题下
3913
3918
  全部规则保持原值;状态色(绿/蓝/橙/红)与 currentColor 派生色两主题通用,不覆盖。
3914
3919
  ::before 状态圆点不覆盖:基色本就被各 data-status 规则接管,覆盖反而会以更高
3915
3920
  优先级压掉运行/完成/错误状态色。 */
@@ -3925,10 +3930,15 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card:hover {
3925
3930
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="subagent"] {
3926
3931
  background: var(--dsw-specific-sidebar-fill, rgb(249, 250, 251));
3927
3932
  }
3928
- /* 后台任务子卡浅色主题与子代理卡同源(R-01-024):淡侧栏填充底,轻染同族蓝与子代理卡区分(R-01-023/AC-07)。 */
3933
+ /* 后台任务子卡浅色主题与子代理卡同源(R-01-023):淡侧栏填充底,轻染同族蓝与子代理卡区分(R-01-023/AC-07)。 */
3929
3934
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="job"] {
3930
3935
  background: color-mix(in srgb, #65a0ff 8%, var(--dsw-specific-sidebar-fill, rgb(249, 250, 251)));
3931
3936
  }
3937
+ /* 纯展示子卡(R-01-023/AC-08):浅色主题同样不提供悬停反馈(回归基态描边)。 */
3938
+ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="job"]:hover {
3939
+ border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
3940
+ filter: none;
3941
+ }
3932
3942
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="recent"] {
3933
3943
  /* 暗于活动卡的 --dsw-alias-bg-layer-2 纯白、深于窗格底色(R-01-013/AC-10、AC-11)。 */
3934
3944
  background: rgb(243, 244, 246);
@@ -3997,9 +4007,6 @@ body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace-item[data-ico
3997
4007
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {
3998
4008
  background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08));
3999
4009
  }
4000
- body:not([data-ds-dark-theme]) .dap-toggle {
4001
- background: var(--dsw-alias-button-floating-fill, rgba(255, 255, 255, 0.94));
4002
- }
4003
4010
  /* 「回到顶部」与标题行档位按钮及左侧仓库入口的浅色覆盖:不透明层-2 底色与外壳描边别名
4004
4011
  (R-01-018/AC-05、R-01-021/AC-05、R-01-022/AC-01);仓库入口无描边(T-139),只并入底色组。 */
4005
4012
  body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top,
@@ -4125,9 +4132,11 @@ function apply(ctx) {
4125
4132
  let sessions = null;
4126
4133
  let workspaces = null;
4127
4134
  let uiSession = null;
4135
+ let jobs = null;
4128
4136
  let sessionUnsubscribe = null;
4129
4137
  let workspaceUnsubscribe = null;
4130
4138
  let pendingUnsubscribe = null;
4139
+ let jobsUnsubscribe = null;
4131
4140
  let clockTimer = null;
4132
4141
  let recentTimeTimer = null;
4133
4142
  let syncScheduled = false;
@@ -4173,8 +4182,13 @@ function apply(ctx) {
4173
4182
  * 重挂载后保留(R-01-021/AC-06)。 */
4174
4183
  let densityLevel = readStoredDensity();
4175
4184
  /** 待执行的锚定补偿:档位切换时登记当前选中卡片顶部的视口相对位置,本轮渲染
4176
- * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。 */
4185
+ * 提交后量测该卡新位置并补偿 scrollTop(R-01-021/AC-01)。
4186
+ * dsh 0.2.0(T-170 审核):切回更高档位时窗口经 retain→ready 异步重建,时间线
4187
+ * 内容分多帧长回——补偿在沉降窗内逐帧执行,位置稳定或超窗即撤(单帧补偿
4188
+ * 会漏掉后期增长,锚点漂移)。 */
4177
4189
  let pendingDensityAnchor = null;
4190
+ /** 锚定补偿沉降窗截止时刻:窗内逐帧补偿,位置稳定提前撤,超窗强制撤。 */
4191
+ let densityAnchorDeadline = 0;
4178
4192
  /** 用户最近一次激活的卡片 id;打开重试链被更新的激活意图取代即取消。 */
4179
4193
  let lastActivatedId = null;
4180
4194
  /** 最近一次已处理的当前卡片;同一卡片的运行时重绘不反复打断用户手动滚动。 */
@@ -4228,7 +4242,6 @@ function apply(ctx) {
4228
4242
  let catalogGroups = [];
4229
4243
  /** 部署级 modelCatalog 一次性读取的在途 Promise:成功即缓存、失败允许重试。 */
4230
4244
  let catalogPromise = null;
4231
- /** native session.open() requests in flight; avoid duplicate cold history reads. */
4232
4245
  /** 冷数据读取并发池:队列顺序即优先级(调用方已排序),逐个完成逐个重绘。 */
4233
4246
  const loadQueue = [];
4234
4247
  let loadInflight = 0;
@@ -4249,29 +4262,146 @@ function apply(ctx) {
4249
4262
  pumpDetailLoads();
4250
4263
  return promise;
4251
4264
  }
4252
- const sessionOpenLoads = new Map();
4253
- /** 子代理目录按需拉取记账(T-151):parentId → 已触发;失败不热重试(行为有界)。 */
4254
- 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();
4255
4278
 
4256
4279
  const style = document.createElement("style");
4257
4280
  style.id = STYLE_ID;
4258
4281
  style.textContent = CSS;
4259
4282
  document.head.appendChild(style);
4260
4283
 
4261
- // 移动端浮动开关按钮(桌面隐藏见 CSS)。
4284
+ // 移动端浮动开关按钮(桌面隐藏见 CSS):紧凑形骨架——仅计数徽标,无图标与
4285
+ // 文字标签(R-01-008/AC-04,T-162;面板图标被东家误读为边栏按钮,撤销);
4286
+ // 可访问名称由 aria-label 承担。不预挂 DOM:开关仅在 placeToggle 落位时进入
4287
+ // 文档,未落位即不渲染(C-093,fail-visible——嵌入失败表现为按钮消失)。
4262
4288
  const toggle = document.createElement("button");
4263
4289
  toggle.className = "dap-toggle";
4264
4290
  toggle.type = "button";
4265
4291
  toggle.setAttribute("aria-label", "切换活动会话窗格");
4266
- toggle.innerHTML =
4267
- "<span>活动</span><span class=\"dap-toggle-count\"></span>";
4268
- document.body.appendChild(toggle);
4292
+ toggle.innerHTML = "<span class=\"dap-toggle-count\"></span>";
4269
4293
 
4270
4294
  // 移动端抽屉透明遮罩:仅窄屏且抽屉打开时显示,点击收起抽屉(R-01-008/AC-03)。
4271
4295
  const backdrop = document.createElement("div");
4272
4296
  backdrop.className = "dap-backdrop";
4273
4297
  document.body.appendChild(backdrop);
4274
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
+
4275
4405
  // 桌面判定与"真实参与布局":中间列切为行方向,窗格固定宽、会话根弹性填充
4276
4406
  // 剩余宽度(flex:1 + min-width:0),让会话内容(标题/tabs/滚动区/输入框)
4277
4407
  // 被真实挤到窗格右侧,且聊天列在增宽的会话带内自动居中。
@@ -4414,12 +4544,10 @@ function apply(ctx) {
4414
4544
  }
4415
4545
  }
4416
4546
 
4417
- /** 回到前台(含 bfcache 还原)时 ack 与任务输出通道自愈:重建 SSE(jobs 无全量快照,
4418
- * 重连后由下一次轨迹通知或重新选中收敛;选中输出随卡片渲染帧刷新)。 */
4547
+ /** 回到前台(含 bfcache 还原)时 ack 通道自愈:重建 SSE(连接即发全量,状态必然收敛)。 */
4419
4548
  function resumePushChannels() {
4420
4549
  if (!disposed) {
4421
4550
  connectAcksStream();
4422
- connectJobsStream();
4423
4551
  }
4424
4552
  }
4425
4553
  const onVisibilityResume = () => {
@@ -4432,51 +4560,6 @@ function apply(ctx) {
4432
4560
  window.addEventListener("pageshow", onPageShow);
4433
4561
  connectAcksStream();
4434
4562
 
4435
- // ---- 后台任务输出通道(R-01-024) ----
4436
- // SSE 订阅宿主侧 job_output 轨迹通知:连接即发空快照(轨迹不入库、无全量快照语义),
4437
- // 此后每个新轨迹广播 { sessionId, jobId };客户端仅对「已选中该任务」的可见卡片
4438
- // 回读输出(R-01-024/AC-03)。无 EventSource 环境静默降级为选中时不自动刷新,
4439
- // 不引入轮询。
4440
- let jobsSource = null;
4441
-
4442
- /** 轨迹通知应用:命中已展开该任务的 job 子卡即置脏并回读一次;其余通知忽略。 */
4443
- function applyJobTraceNotice(raw) {
4444
- if (disposed) return;
4445
- let notice = null;
4446
- try {
4447
- notice = JSON.parse(raw);
4448
- } catch {
4449
- notice = null;
4450
- }
4451
- const sessionId = typeof notice?.sessionId === "string" ? notice.sessionId : null;
4452
- const jobId = typeof notice?.jobId === "string" ? notice.jobId : null;
4453
- if (sessionId === null || jobId === null) return;
4454
- for (const [, card] of cardsById) {
4455
- const el = card?.el;
4456
- if (el?.dataset?.kind !== "job" || el.dataset.jobOwner !== sessionId || el.dataset.jobId !== jobId) continue;
4457
- // 脏标记 + 回读钩子:仅展开中的输出区需要拉取新轨迹(R-01-024/AC-03)。
4458
- el.__jobOutDirty = true;
4459
- el.__jobOutReload?.();
4460
- }
4461
- }
4462
-
4463
- /** (重)建 jobs SSE 连接:与 acks 通道同模式的断连自愈口径。 */
4464
- function connectJobsStream() {
4465
- try {
4466
- jobsSource?.close();
4467
- } catch {}
4468
- jobsSource = null;
4469
- if (disposed || typeof window.EventSource !== "function") return;
4470
- try {
4471
- const source = new window.EventSource(`${PANE_API_BASE}/jobs/stream`);
4472
- source.addEventListener("state", (event) => applyJobTraceNotice(event.data ?? ""));
4473
- jobsSource = source;
4474
- } catch {
4475
- jobsSource = null;
4476
- }
4477
- }
4478
- connectJobsStream();
4479
-
4480
4563
  /** 确认写回(R-01-002/AC-10~AC-12):乐观更新本地游标(签名驱动即时解除),
4481
4564
  * 再 POST 宿主侧持久化并广播;写回失败回滚本地游标(提醒恢复),不吞异常。 */
4482
4565
  async function ackCompletion(sessionId) {
@@ -4634,34 +4717,37 @@ function apply(ctx) {
4634
4717
  throw response?.error ?? new Error("remote request failed");
4635
4718
  }
4636
4719
 
4637
- /** 母会话子代理目录条目查找(T-151 单源):open 地址与深翻分页地址共用同一取法,
4638
- * 防条目形状或键名演化时两处漂移。 */
4639
- function subagentCatalogEntry(subagentsByParent, parentId, id) {
4640
- const entries = subagentsByParent?.[String(parentId)]?.entries;
4641
- return Array.isArray(entries)
4642
- ? entries.find((candidate) => String(candidate?.id) === String(id))
4643
- : 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));
4644
4725
  }
4645
4726
 
4646
4727
  /** 会话日志分页地址(R-01-012):主会话 `{kind:"session", sessionId}`;子代理
4647
- * `{kind:"subagent", parentSessionId, childSessionId, mode}`——母会话 id 兼容
4648
- * `parentSessionId` / `parentId` 两种条目键名。mode 必须与子代理描述符一致,
4649
- * 不一致被宿主以 subagent/unauthorized 拒绝(T-151):优先取母会话目录条目的
4650
- * mode,目录条目缺失(未加载、在途空窗或无该子条目)时回退行 `continuable`
4651
- * 启发(读取失败由 pagedHistoryEvents 以 null 收敛为空白详情,R-01-013)。 */
4652
- 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) {
4653
4741
  const row = byId[id] ?? {};
4654
4742
  if (row?.origin === "subagent") {
4655
- const parentId = row.parentSessionId ?? row.parentId;
4743
+ const parentId = parentSessionIdOf(row);
4656
4744
  if (parentId !== undefined && parentId !== null) {
4657
- const entry = subagentCatalogEntry(subagentsByParent, String(parentId), id);
4745
+ const entry = subagentCatalogEntry(projectionsBySession, String(parentId), id);
4658
4746
  return {
4659
4747
  kind: "subagent",
4660
4748
  parentSessionId: String(parentId),
4661
4749
  childSessionId: String(id),
4662
- mode: typeof entry?.mode === "string" && entry.mode !== ""
4663
- ? entry.mode
4664
- : row.continuable === true ? "continuable" : "one-shot",
4750
+ mode: typeof entry?.mode === "string" && entry.mode !== "" ? entry.mode : "unknown",
4665
4751
  };
4666
4752
  }
4667
4753
  }
@@ -4770,7 +4856,10 @@ function apply(ctx) {
4770
4856
  // 选择),订阅与一次性 load 一并触发;子代理的目录不可用(宿主以 agent-busy
4771
4857
  // 拒绝其模型 RPC),不建立订阅。e2e 接缝把订阅与 load 整体延后,使模型上下文
4772
4858
  // 严格晚于时间线呈现(R-01-014/AC-03 渐进语义可观察)。
4773
- if (!subagent) {
4859
+ // 档位资源纪律(R-01-025/AC-06):紧凑档主会话的模型区域在头部行内、整体
4860
+ // 隐藏——模型上下文无可见消费者,不订阅不 load;切回更高档位后经既有
4861
+ // 一次性读取语义补齐。logWindowSuppressed 单点判定。
4862
+ if (!subagent && !logWindowSuppressedFor(id, byId)) {
4774
4863
  if (e2eModelDelayMs === 0) {
4775
4864
  subscribeModelDirectory(id, detail);
4776
4865
  loadDirectoryOnce(id);
@@ -4789,6 +4878,10 @@ function apply(ctx) {
4789
4878
  const lastSeq = Number(windowEntries.at(-1)?.event?.seq);
4790
4879
  const deepReadNeeded =
4791
4880
  (previewFallbackIds.has(id) || durationFallbackIds.has(id) || (subagent && subagentModelReadIds.has(id))) &&
4881
+ // 档位资源纪律(R-01-025/AC-03):紧凑档主会话不发起日志分页深翻——
4882
+ // 预览行与最近回合耗时在该档位均不可见,最近历史排序以既有可用时刻
4883
+ // 承载(R-01-010/AC-09 档位限定);子代理模型溯源读取不受影响。
4884
+ (subagent || !logWindowSuppressed(densityLevel, subagent)) &&
4792
4885
  detail.log?.hasMore === true &&
4793
4886
  Number.isFinite(lastSeq) &&
4794
4887
  detail.historyDeepReadDone !== true;
@@ -4798,11 +4891,29 @@ function apply(ctx) {
4798
4891
  if (deepReadNeeded && !historyLoads.has(id)) {
4799
4892
  detail.previewFallbackLoaded = true;
4800
4893
  detail.durationFallbackLoaded = true;
4801
- 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);
4802
4909
  const promise = enqueueDetailLoad(() => Promise.resolve()
4803
4910
  .then(async () => {
4804
4911
  const { events, error } = await pagedHistoryEvents({
4805
4912
  fetchPage: async (beforeSeq) => {
4913
+ // 档位资源纪律(R-01-025/AC-03):分页响应点用时重读档位——
4914
+ // 切入紧凑档后,在途深翻不再发起下一页;中止时已取页不落地、
4915
+ // 保持既有详情,切回后重新入队重取。子代理模型溯源读取不受影响。
4916
+ if (logWindowSuppressed(densityLevel, subagent)) return null;
4806
4917
  const value = remoteValue(
4807
4918
  await sessionRemote.page({ address, throughSeq: lastSeq, beforeSeq, maxMessages: 50 }),
4808
4919
  );
@@ -4810,6 +4921,10 @@ function apply(ctx) {
4810
4921
  return { events: Array.isArray(value.records) ? value.records : [], hasMore: value.hasMore === true };
4811
4922
  },
4812
4923
  });
4924
+ // 切档中止的在途深翻不落地、不标记完成——historyDeepReadDone 无复位
4925
+ // 路径,中止即置位会让切回高档位后的预览/耗时补齐永久失效;中止时
4926
+ // 保持既有详情不变,切回后经 deepReadNeeded 重新入队(R-01-025/AC-01)。
4927
+ if (logWindowSuppressed(densityLevel, subagent)) return;
4813
4928
  if (error) detail.historyError = error instanceof Error ? error.message : String(error);
4814
4929
  detail.history = events;
4815
4930
  detail.historyDeepReadDone = true;
@@ -4831,6 +4946,52 @@ function apply(ctx) {
4831
4946
  }
4832
4947
  }
4833
4948
 
4949
+ /** 档位资源纪律单点判定(R-01-025):档位谓词与子代理判定的唯一组合。
4950
+ * byId 缺省时读当帧列表快照;调用方已持有快照时传入以免重复读取。 */
4951
+ function logWindowSuppressedFor(id, byId = getSnapshot(sessions, "list")?.byId ?? {}) {
4952
+ return logWindowSuppressed(densityLevel, isSubagentRow(byId[id], byId));
4953
+ }
4954
+
4955
+ /** 档位资源纪律拆窗(R-01-025/AC-01):对非当前主会话的日志窗口订阅先
4956
+ * unsubscribe 再除名并清除窗口引用,并 dispose 会话运行时的 live 源
4957
+ * (openState 归 cold,底层 session/follow 流终止)——订阅回调移除不终止
4958
+ * 共享传输流,dispose 才真正停带宽。当前会话的窗口由外壳会话视图持有,
4959
+ * 不 dispose、不拆订阅(由本函数在渲染期持续执法:其切走后的下一轮渲染
4960
+ * 照常释放);当前会话不可判定(列表快照未就绪)时整轮跳过,不误释放。
4961
+ * 子代理日志窗口承载标题行可见的模型上下文,保留。切回更高档位时经既有
4962
+ * 渐进路径(captureSessionLog 重订阅 + retain 重新水合)就绪,
4963
+ * 不新增恢复机制。 */
4964
+ function enforceDensityDataDiscipline() {
4965
+ if (densityLevel !== "compact") return;
4966
+ const listSnap = getSnapshot(sessions, "list");
4967
+ const currentId = currentIdOf(listSnap);
4968
+ if (currentId == null) return;
4969
+ for (const [id, unsubscribe] of [...logSourceSubs]) {
4970
+ if (!logWindowSuppressedFor(id, listSnap?.byId ?? {})) continue;
4971
+ if (String(id) === String(currentId)) continue;
4972
+ try {
4973
+ unsubscribe?.();
4974
+ } catch {
4975
+ /* 监听器已失效:除名即可 */
4976
+ }
4977
+ logSourceSubs.delete(id);
4978
+ const detail = sessionDetailsById.get(id) ?? null;
4979
+ if (detail !== null) detail.log = null;
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);
4992
+ }
4993
+ }
4994
+
4834
4995
  /** 日志页落地后的详情派生(R-01-012、R-01-017):时间线折叠、消息预览与子代理模型上下文。
4835
4996
  * 子代理沿用溯源链(assistant/message source.model + 请求头 effort,T-122);主会话模型
4836
4997
  * 不经日志提取(目录链订阅 + 一次性 load 承载,见 installServiceSubscriptions 注)。
@@ -4855,28 +5016,149 @@ function apply(ctx) {
4855
5016
  // 当前选择),不可得时保持空白,不以日志历史取值或部署默认冒充(R-01-012/AC-18)。
4856
5017
  }
4857
5018
 
4858
- /** 绑定会话并水合事件源(dsh 0.1.5 起会话内容经 eventSource 流式下发:打开即收
4859
- * 完整日志窗口 + 实时尾,原生 Conversation 同源)。冷会话补一次 open(),日志窗口
4860
- * 快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。 */
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);
5104
+ try {
5105
+ record.reference?.release?.();
5106
+ } catch {
5107
+ /* 引用已释放 */
5108
+ }
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
+ // 日志窗口快照引用变化即重派生详情(时间线/预览/模型),窗口由宿主按消息对齐分页。
4861
5134
  function captureSessionLog(id, { cwd } = {}) {
5135
+ // 档位资源纪律(R-01-025/AC-01):紧凑档主会话不建立或保持会话日志窗口——
5136
+ // 不订阅事件流、不发起水合、不读窗口快照。判定必须先于 liveSessionFace:
5137
+ // dsh 0.2.0 起 retain.ready 承载共享初始历史打开,先 retain 后判档会让
5138
+ // 紧凑档非当前主会话被水合(T-170 审核发现 1)。判定在函数入口用时重读,
5139
+ // 切档在途的回调与重试不落地新窗口。子代理日志窗口承载标题行可见的
5140
+ // 模型上下文(R-01-012/AC-17),恒不裁剪(单点判定见 logWindowSuppressedFor)。
5141
+ if (logWindowSuppressedFor(id)) return;
4862
5142
  const detail = sessionDetailsById.get(id) ?? {};
4863
5143
  sessionDetailsById.set(id, detail);
4864
- let session = null;
4865
- try {
4866
- session = sessions?.binding?.(id)?.session ?? null;
4867
- } catch {
4868
- session = null;
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;
4869
5152
  }
4870
- if (session === null) return;
4871
5153
  // 日志窗口无效化订阅(事件驱动,不构成轮询,R-02-004):等待/历史卡无会话状态
4872
5154
  // 订阅(syncLiveness 只订阅运行中),窗口推进(回合收尾、流式尾)必须经此回调
4873
5155
  // 重读快照并重绘;否则窗口更新只能靠渲染期重读兜底——渲染一旦静默(一次性
4874
5156
  // load 修复后),完成提醒耗时等窗口派生数据将永久停摆。随可见性 prune。
4875
- if (typeof session.eventSource?.subscribe === "function" && !logSourceSubs.has(id)) {
5157
+ if (typeof eventSource?.subscribe === "function" && !logSourceSubs.has(id)) {
4876
5158
  try {
4877
5159
  logSourceSubs.set(
4878
5160
  id,
4879
- session.eventSource.subscribe(() => {
5161
+ eventSource.subscribe(() => {
4880
5162
  if (disposed) return;
4881
5163
  const listSnap = getSnapshot(sessions, "list");
4882
5164
  captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
@@ -4887,24 +5169,7 @@ function apply(ctx) {
4887
5169
  // 订阅不可用:回退渲染期重读。
4888
5170
  }
4889
5171
  }
4890
- try {
4891
- // 日志窗口缺席才发起 open:非订阅会话(等待/历史卡)无快照推送,openState 永远
4892
- // 不可知,只以 openState 把关会对已水合会话逐帧重发 open,settle→delete→重发
4893
- // 的在途翻转同样驱动加载指示抖动(docsim 卡闪烁根因之一)。
4894
- if (!detail.log && detail.snapshot?.openState !== "open" && !sessionOpenLoads.has(id) && typeof session.open === "function") {
4895
- // 子代理事件流仅接受持久父地址:open 前先安装(T-151),否则宿主拒绝、窗口永不水合。
4896
- ensureSubagentAddress(id, session);
4897
- const opening = Promise.resolve(session.open()).catch(() => {});
4898
- sessionOpenLoads.set(id, opening);
4899
- opening.finally(() => {
4900
- if (sessionOpenLoads.get(id) === opening) sessionOpenLoads.delete(id);
4901
- queueSync();
4902
- });
4903
- }
4904
- } catch {
4905
- // open 不可用:保持当前窗口。
4906
- }
4907
- const log = session.eventSource?.getSnapshot?.() ?? null;
5172
+ const log = eventSource?.getSnapshot?.() ?? null;
4908
5173
  if (log === detail.log) return;
4909
5174
  detail.log = log;
4910
5175
  // 流式派生合并(T-127):事件到达只更新引用并标脏,applyLogEvents 的 O(日志窗口)
@@ -4933,53 +5198,6 @@ function apply(ctx) {
4933
5198
  queueSync();
4934
5199
  }
4935
5200
 
4936
- /** 触发母会话子代理目录单发读取(T-151):目录条目是地址 mode 的唯一可靠来源,
4937
- * 未加载时先拉取,目录随下一轮快照到达;每次拉取经原生 sessions.refreshSubagents
4938
- * 走既有 remote 通道,单父会话单飞、不重试,不构成轮询(R-02-004)。 */
4939
- function requestSubagentCatalog(parentId) {
4940
- if (typeof sessions?.refreshSubagents !== "function") return;
4941
- if (subagentCatalogRequests.has(parentId)) return;
4942
- subagentCatalogRequests.add(parentId);
4943
- try {
4944
- Promise.resolve(sessions?.refreshSubagents?.(parentId))
4945
- .catch(() => {})
4946
- .finally(() => {
4947
- if (!disposed) queueSync();
4948
- });
4949
- } catch {
4950
- subagentCatalogRequests.delete(parentId);
4951
- }
4952
- }
4953
-
4954
- /** 子代理会话事件流打开前置(T-151 缺陷修复):dsh 0.1.5 起宿主按地址校验会话事件
4955
- * 流路由——子代理会话仅接受持久父地址,且地址 mode 必须与其描述符一致(不一致被
4956
- * subagent/unauthorized 拒绝),持久地址仅经原生导航留存。mode 唯一可靠来源是母
4957
- * 会话的子代理目录条目:在册即经 configureSubagent 安装(与原生 selectSubagent
4958
- * 同构,但不切换当前会话);目录未加载时先单发拉取并跳过本轮安装,避免装入必被
4959
- * 拒绝的错误 mode 地址;目录拉取失败沿用既有空白详情路径(深翻以 null 收敛),
4960
- * 不阻断其余卡片。非子代理行或会话对象缺 configureSubagent 时为无操作。 */
4961
- function ensureSubagentAddress(id, session) {
4962
- const listSnap = getSnapshot(sessions, "list");
4963
- const byId = listSnap?.byId ?? {};
4964
- if (!isSubagentRow(byId[id], byId)) return;
4965
- if (typeof session?.configureSubagent !== "function") return;
4966
- const parentId = String(byId[id].parentSessionId ?? byId[id].parentId);
4967
- const catalog = isRecord(listSnap?.subagentsByParent) ? listSnap.subagentsByParent[parentId] : null;
4968
- const entry = subagentCatalogEntry(listSnap?.subagentsByParent ?? null, parentId, id);
4969
- if (!isRecord(entry) || typeof entry.mode !== "string" || entry.mode === "") {
4970
- requestSubagentCatalog(parentId);
4971
- return;
4972
- }
4973
- try {
4974
- session.configureSubagent(
4975
- { kind: "subagent", parentSessionId: parentId, childSessionId: String(id), mode: entry.mode },
4976
- typeof catalog?.parentAvailable === "boolean" ? catalog.parentAvailable : undefined,
4977
- );
4978
- } catch {
4979
- // 安装失败沿用既有空白详情路径,不阻断其余卡片。
4980
- }
4981
- }
4982
-
4983
5201
  /** 部署级模型目录一次性读取(R-01-012/AC-01):dsh 0.1.5 起 per-session models
4984
5202
  * RPC 移除,目录经 `remote.session.modelCatalog()` 无参数读取;成功即缓存不再
4985
5203
  * 重发,失败留待下轮详情读取重试。 */
@@ -4996,18 +5214,43 @@ function apply(ctx) {
4996
5214
  });
4997
5215
  }
4998
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
+
4999
5234
  function installServiceSubscriptions() {
5000
5235
  const nextSessions = ctx.get("sessions");
5001
5236
  const nextWorkspaces = ctx.get("workspaces");
5002
5237
  const nextUiSession = ctx.get("uiSession");
5003
- 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();
5004
5245
 
5005
5246
  sessionUnsubscribe?.();
5006
5247
  workspaceUnsubscribe?.();
5007
5248
  pendingUnsubscribe?.();
5249
+ jobsUnsubscribe?.();
5008
5250
  sessions = nextSessions ?? null;
5009
5251
  workspaces = nextWorkspaces ?? null;
5010
5252
  uiSession = nextUiSession ?? null;
5253
+ jobs = nextJobs ?? null;
5011
5254
  try {
5012
5255
  sessionUnsubscribe = sessions?.list?.subscribe?.(queueSync) ?? null;
5013
5256
  } catch {
@@ -5019,13 +5262,20 @@ function apply(ctx) {
5019
5262
  workspaceUnsubscribe = null;
5020
5263
  }
5021
5264
  try {
5022
- // 0.1.5 起 sessions 行不再承载 pendingInteraction:待确认/待审查/待回复改由
5023
- // uiSession.pendingInteractions(Map<sessionId, interaction>)独立承载,
5265
+ // dsh 0.2.0(T-170):统一会话状态面 sessionStatus 取代 pendingInteractions
5266
+ // (Map<sessionId, {running, pendingInteraction, completionUnread}>),
5024
5267
  // 订阅其快照变化驱动等待卡重算(R-01-002/AC-03)。
5025
- pendingUnsubscribe = uiSession?.pendingInteractions?.subscribe?.(queueSync) ?? null;
5268
+ pendingUnsubscribe = uiSession?.sessionStatus?.subscribe?.(queueSync) ?? null;
5026
5269
  } catch {
5027
5270
  pendingUnsubscribe = null;
5028
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
+ }
5029
5279
 
5030
5280
  queueSync();
5031
5281
  }
@@ -5040,6 +5290,41 @@ function apply(ctx) {
5040
5290
  toggle.toggleAttribute("data-drawer-open", open);
5041
5291
  queueSync();
5042
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();
5043
5328
  function notifyLayoutChange() {
5044
5329
  try {
5045
5330
  window.dispatchEvent(new Event("resize"));
@@ -5177,11 +5462,15 @@ function apply(ctx) {
5177
5462
  const anchorTop = currentCard ? currentCard.getBoundingClientRect().top - viewportTop : null;
5178
5463
  densityLevel = nextDensity(densityLevel);
5179
5464
  writeStoredDensity(densityLevel);
5465
+ // 档位资源纪律(R-01-025/AC-01):进入紧凑档即拆主会话日志窗口订阅;
5466
+ // captureSessionLog 入口门控阻止在途回调重建。
5467
+ enforceDensityDataDiscipline();
5180
5468
  applyDensity();
5181
5469
  // 档位已纳入渲染签名:触发一轮同步让时间线按新档位以 lastOnly 重建;
5182
- // 锚定补偿在本轮渲染提交后执行,此时量测才含新行高
5470
+ // 锚定补偿在渲染提交后逐帧执行至沉降窗结束,此时量测才含新行高
5183
5471
  //(R-01-021/AC-01、AC-08)。
5184
5472
  pendingDensityAnchor = currentCard && anchorTop !== null ? anchorTop : null;
5473
+ densityAnchorDeadline = Date.now() + 1500;
5185
5474
  queueSync();
5186
5475
  };
5187
5476
  // 档位按钮位于标题行右侧的工具区(标题区折叠控件的兄弟节点),激活天然不会
@@ -5215,6 +5504,10 @@ function apply(ctx) {
5215
5504
 
5216
5505
  // ---- 窗格容器(conversation 槽座的前置兄弟列;外壳重挂载后重插) ----
5217
5506
  function ensurePane() {
5507
+ // 开关落位守卫先于窗格守卫:头部行存在与否独立于窗格挂载态(T-160)。
5508
+ placeToggle();
5509
+ // 帧观察器在 frame 就绪后补挂(外壳重挂载后 frame 为新元素,须重挂)。
5510
+ armFrameObserver();
5218
5511
  const seat = document.querySelector(CONVERSATION_SELECTOR);
5219
5512
  if (seat === null || seat.parentElement === null) return null;
5220
5513
  // [data-slot="conversation"] 的父级是 AppFrame 的中间列(flex column)。
@@ -5404,8 +5697,8 @@ function apply(ctx) {
5404
5697
  return [head, row, makeEl("div", "dap-trace"), makeStatsRow(), foot];
5405
5698
  }
5406
5699
  if (kind === "job") {
5407
- // 后台任务子卡(R-01-023/AC-05、R-01-024):行 1 = 状态点 + 工具名称 + 随时钟
5408
- // 时长;行 2 = 任务内容原文(mono 省略 + tooltip);输出区在展开时追加。
5700
+ // 后台任务子卡(R-01-023/AC-05):行 1 = 状态点 + 工具名称 + 随时钟时长;
5701
+ // 行 2 = 任务内容原文(mono 省略 + tooltip)。
5409
5702
  const row = makeEl("div", "dap-row");
5410
5703
  row.append(makeEl("span", "dap-job-dot"), makeEl("span", "dap-job-kind"), makeEl("span", "dap-job-elapsed"));
5411
5704
  const content = makeEl("div", "dap-job-content");
@@ -5898,7 +6191,7 @@ function apply(ctx) {
5898
6191
  const JOB_STATUS_LABELS = { running: "运行中", stopping: "停止中", completed: "已完成", killed: "已终止", failed: "失败" };
5899
6192
 
5900
6193
  /** 后台任务数量注(R-01-023/AC-01):母卡标题行内 `后台 ×N` 小注;任务本体以 job
5901
- * 子卡呈现(R-01-024),本注仅承载数量语义。无 liveJobs 时隐藏节点。 */
6194
+ * 子卡呈现(R-01-023),本注仅承载数量语义。无 liveJobs 时隐藏节点。 */
5902
6195
  function renderJobsChip(el, entry) {
5903
6196
  const chip = el.querySelector(".dap-jobs-chip");
5904
6197
  if (chip === null) return;
@@ -5909,84 +6202,9 @@ function apply(ctx) {
5909
6202
  if (chip.hidden !== hidden) chip.hidden = hidden;
5910
6203
  }
5911
6204
 
5912
- /** 后台任务子卡的输出展开/收起(R-01-024/AC-01):激活 job 卡即在卡内展开输出区,
5913
- * 再次激活收起;同一母会话下至多展开一张(点击新卡先收兄弟,稳定性与共享坞等价)。 */
5914
- function toggleJobExpanded(el) {
5915
- if (el.hasAttribute("data-expanded")) {
5916
- el.removeAttribute("data-expanded");
5917
- el.querySelector(".dap-jobout")?.remove();
5918
- delete el.dataset.loadedFor;
5919
- el.__jobOutReload = null;
5920
- return;
5921
- }
5922
- const ownerId = el.dataset.jobOwner ?? "";
5923
- for (const [, card] of cardsById) {
5924
- const other = card?.el;
5925
- if (other === el || other?.dataset?.kind !== "job" || other.dataset.jobOwner !== ownerId) continue;
5926
- other.removeAttribute("data-expanded");
5927
- other.querySelector(".dap-jobout")?.remove();
5928
- delete other.dataset.loadedFor;
5929
- other.__jobOutReload = null;
5930
- }
5931
- el.setAttribute("data-expanded", "");
5932
- // 展开视为显式回读请求:置脏令 renderJobOutput 必发一次(含重复点选的重试语义)。
5933
- el.__jobOutDirty = true;
5934
- renderJobOutput(el);
5935
- }
5936
-
5937
- /** 输出区装载/刷新(R-01-024/AC-01~AC-04):按卡上归属与任务 id 回读一次模型已读
5938
- * 输出——未被读取显示「尚未被读取」(AC-02),超限截断提示(AC-04),失败诚实降级。
5939
- * 回读仅在展开、选中切换(=重新展开)或轨迹通知置脏时发出(R-01-024/AC-03):
5940
- * loadedFor 未变且未置脏时渲染帧直达 return,不构成周期性拉取(R-02-004)。
5941
- * token 使迟到的响应失效(快速切换不串内容)。 */
5942
- function renderJobOutput(el) {
5943
- if (!el.hasAttribute("data-expanded")) return;
5944
- const sessionId = el.dataset.jobOwner ?? null;
5945
- const jobId = el.dataset.jobId ?? null;
5946
- if (sessionId === null || sessionId === "" || jobId === null || jobId === "") return;
5947
- let out = el.querySelector(".dap-jobout");
5948
- if (out === null) {
5949
- out = makeEl("div", "dap-jobout");
5950
- out.append(makeEl("pre", "dap-jobout-pre"), makeEl("div", "dap-jobout-hint"));
5951
- el.append(out);
5952
- }
5953
- const loadedFor = el.dataset.loadedFor;
5954
- const key = `${sessionId}/${jobId}`;
5955
- if (loadedFor === key && el.__jobOutDirty !== true) return;
5956
- el.dataset.loadedFor = key;
5957
- el.__jobOutDirty = false;
5958
- const pre = out.querySelector(".dap-jobout-pre");
5959
- const hint = out.querySelector(".dap-jobout-hint");
5960
- const token = `${sessionId} ${jobId} ${Date.now()}`;
5961
- el.__jobOutToken = token;
5962
- // jobs SSE 轨迹通知按此钩子触发回读(R-01-024/AC-03);随卡片重建自然失效。
5963
- el.__jobOutReload = () => renderJobOutput(el);
5964
- pre.textContent = "";
5965
- hint.textContent = "读取中…";
5966
- fetch(`${PANE_API_BASE}/jobs-output?sessionId=${encodeURIComponent(sessionId)}&jobId=${encodeURIComponent(jobId)}`)
5967
- .then((response) => (response.ok ? response.json() : null))
5968
- .then((payload) => {
5969
- if (el.__jobOutToken !== token || disposed) return;
5970
- if (payload === null || typeof payload !== "object") {
5971
- hint.textContent = "输出读取失败";
5972
- return;
5973
- }
5974
- pre.textContent = typeof payload.text === "string" ? payload.text : "";
5975
- hint.textContent =
5976
- payload.read === false
5977
- ? "尚未被读取"
5978
- : payload.truncated === true
5979
- ? "输出过长,已截断"
5980
- : "";
5981
- })
5982
- .catch(() => {
5983
- if (el.__jobOutToken === token && !disposed) hint.textContent = "输出读取失败";
5984
- });
5985
- }
5986
-
5987
- /** 后台任务子卡渲染(R-01-023/AC-01、R-01-024/AC-01):状态点着色、标题与随时钟
5988
- * 推进的已运行时长;展开态下装载/刷新输出区。任务消失由条目派生侧收卡(随
5989
- * liveJobs 清空整个条目消失),此处只呈现当帧状态。 */
6205
+ /** 后台任务子卡渲染(R-01-023/AC-01、AC-05):状态点着色、标题与随时钟推进的
6206
+ * 已运行时长。任务消失由条目派生侧收卡(随 liveJobs 清空整个条目消失),
6207
+ * 此处只呈现当帧状态。 */
5990
6208
  function renderJobCardInto(el, entry) {
5991
6209
  const dot = el.querySelector(".dap-job-dot");
5992
6210
  if (dot !== null && dot.dataset.status !== entry.jobStatus) dot.dataset.status = entry.jobStatus;
@@ -5999,8 +6217,7 @@ function apply(ctx) {
5999
6217
  if (kindEl.hidden !== kindHidden) kindEl.hidden = kindHidden;
6000
6218
  }
6001
6219
  // 任务内容行(R-01-023/AC-05、AC-06):mono 原文单行省略,完整原文以原生 tooltip
6002
- // 显示(沿用 R-01-024 呈现细化语义,tooltip 归内容行);内容不可得时整行隐藏,
6003
- // 不补空白或占位。
6220
+ // 显示(tooltip 归内容行);内容不可得时整行隐藏,不补空白或占位。
6004
6221
  const contentRow = el.querySelector(".dap-job-content");
6005
6222
  const labelText = String(entry.title ?? "");
6006
6223
  if (contentRow !== null) {
@@ -6022,9 +6239,6 @@ function apply(ctx) {
6022
6239
  : "";
6023
6240
  if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText;
6024
6241
  }
6025
- if (el.dataset.jobOwner !== String(entry.parentId)) el.dataset.jobOwner = String(entry.parentId);
6026
- if (el.dataset.jobId !== String(entry.jobId)) el.dataset.jobId = String(entry.jobId);
6027
- renderJobOutput(el);
6028
6242
  }
6029
6243
 
6030
6244
  function renderCardInto(el, entry, colorByWorkspace) {
@@ -6145,7 +6359,9 @@ function apply(ctx) {
6145
6359
  const jobsOnly = Array.isArray(entry.liveJobs) && entry.liveJobs.length > 0 && entry.selfRunning !== true;
6146
6360
  renderJobsChip(el, entry);
6147
6361
  const progressRow = el.querySelector(".dap-progress");
6148
- if (progressRow !== null) {
6362
+ // 档位资源纪律(R-01-025/AC-05):进度行在中间/紧凑档被 CSS 隐藏,不写入;
6363
+ // 切回完整档后随档位签名重渲染恢复。
6364
+ if (progressRow !== null && densityLevel === "full") {
6149
6365
  if (jobsOnly) {
6150
6366
  if (!progressRow.hidden) progressRow.hidden = true;
6151
6367
  } else {
@@ -6154,8 +6370,10 @@ function apply(ctx) {
6154
6370
  }
6155
6371
  }
6156
6372
  const traceContainer = el.querySelector(".dap-trace");
6157
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
6158
- renderTokenStats(el, entry);
6373
+ // 档位资源纪律(R-01-025/AC-05):紧凑档不构建被隐藏的时间线内容;
6374
+ // 中间档 lastOnly 单行语义不变(R-01-021/AC-08)。切回后随档位签名重渲染恢复。
6375
+ if (traceContainer !== null && densityLevel !== "compact") renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
6376
+ if (densityLevel === "full") renderTokenStats(el, entry);
6159
6377
  return;
6160
6378
  }
6161
6379
 
@@ -6166,10 +6384,13 @@ function apply(ctx) {
6166
6384
 
6167
6385
  if (entry.kind === "subagent") {
6168
6386
  const traceContainer = el.querySelector(".dap-subtrace");
6169
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: true });
6387
+ // 档位资源纪律(R-01-025/AC-05):紧凑档不构建被隐藏的子卡时间线内容;
6388
+ // 标题行内联模型上下文(R-01-012/AC-17)不受影响。
6389
+ if (traceContainer !== null && densityLevel !== "compact") renderTimelineArea(traceContainer, entry, { lastOnly: true });
6170
6390
  // 运行中呈现与运行卡同构的进度行;锚点空闲(暂停等待)时整行隐藏(R-01-009/AC-15)。
6171
6391
  const progressRow = el.querySelector(".dap-progress");
6172
- if (progressRow !== null) {
6392
+ // 档位资源纪律(R-01-025/AC-05):进度行与统计行在中间/紧凑档不写入。
6393
+ if (progressRow !== null && densityLevel === "full") {
6173
6394
  if (Number.isFinite(entry.progress)) {
6174
6395
  renderProgressRow(el, entry.progress);
6175
6396
  if (progressRow.hidden) progressRow.hidden = false;
@@ -6177,7 +6398,7 @@ function apply(ctx) {
6177
6398
  progressRow.hidden = true;
6178
6399
  }
6179
6400
  }
6180
- renderTokenStats(el, entry);
6401
+ if (densityLevel === "full") renderTokenStats(el, entry);
6181
6402
  return;
6182
6403
  }
6183
6404
 
@@ -6198,14 +6419,18 @@ function apply(ctx) {
6198
6419
  restoreTextField(text, previews[i]);
6199
6420
  }
6200
6421
  }
6201
- renderTokenStats(el, entry);
6422
+ // 档位资源纪律(R-01-025/AC-05):最近卡统计行在中间/紧凑档不写入。
6423
+ if (densityLevel === "full") renderTokenStats(el, entry);
6202
6424
  }
6203
6425
 
6204
6426
  if (entry.kind === "awaiting") {
6205
6427
  const traceContainer = el.querySelector(".dap-trace");
6206
- if (traceContainer !== null) renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
6428
+ // 档位资源纪律(R-01-025/AC-05):紧凑档不构建被隐藏的时间线内容;
6429
+ // 中间档 lastOnly 单行语义不变(R-01-021/AC-08)。
6430
+ if (traceContainer !== null && densityLevel !== "compact") renderTimelineArea(traceContainer, entry, { lastOnly: densityLevel === "medium" });
6207
6431
  removeAwaitingHeadDuration(el);
6208
- renderTokenStats(el, entry);
6432
+ // 档位资源纪律(R-01-025/AC-05):等待卡统计行在中间/紧凑档不写入。
6433
+ if (densityLevel === "full") renderTokenStats(el, entry);
6209
6434
  const confirm = el.querySelector(".dap-confirm");
6210
6435
  if (confirm !== null) {
6211
6436
  // 激活锚点:只在结构重建时绑一次(卡片按 id 复用,kind 变化会重建骨架)。
@@ -6245,65 +6470,65 @@ function apply(ctx) {
6245
6470
  }
6246
6471
 
6247
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(回合起点,供运行卡时钟)。
6248
6479
  function syncLiveness(runningIds, clockWanted) {
6249
6480
  for (const id of runningIds) {
6250
- if (livenessById.has(id)) continue;
6251
- let session = null;
6252
- try {
6253
- session = sessions?.binding?.(id)?.session ?? null;
6254
- } catch {
6255
- session = null;
6256
- }
6257
- if (session === null) continue; // 下次渲染再试
6258
- let unsubscribe = null;
6259
- try {
6260
- unsubscribe = session.subscribe(() => {
6261
- if (disposed) return;
6262
- const snapshot = getSessionSnapshot(session);
6263
- const live = livenessFromSnapshot(snapshot);
6264
- 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
+ });
6265
6516
  const detail = sessionDetailsById.get(id) ?? {};
6266
6517
  detail.snapshot = snapshot;
6267
6518
  sessionDetailsById.set(id, detail);
6268
- // dsh 0.1.5 起快照不再携带会话内容:时间线经 eventSource 日志窗口
6269
- // 就地重派生(R-01-009)。
6270
- const listSnap = getSnapshot(sessions, "list");
6271
- captureSessionLog(id, { cwd: listSnap?.byId?.[id]?.cwd ?? "" });
6272
- queueSync();
6519
+ } else {
6520
+ livenessById.set(id, { unsubscribe: null, liveness: { startTime: null }, snapshot: null });
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,
6273
6530
  });
6274
- } catch {
6275
- continue; // 订阅失败:本次跳过,下次渲染重试
6276
6531
  }
6277
- // 子代理事件流仅接受持久父地址:open 前先安装(T-151),否则宿主拒绝、窗口永不水合。
6278
- ensureSubagentAddress(id, session);
6279
- let opening = null;
6280
- try {
6281
- opening = session.open?.();
6282
- } catch {
6283
- opening = null;
6284
- }
6285
- if (opening && typeof opening.then === "function") {
6286
- const tracked = Promise.resolve(opening).then(
6287
- () => {
6288
- sessionOpenLoads.delete(id);
6289
- queueSync();
6290
- },
6291
- () => {
6292
- sessionOpenLoads.delete(id);
6293
- queueSync();
6294
- },
6295
- );
6296
- sessionOpenLoads.set(id, tracked);
6297
- }
6298
- const snapshot = getSessionSnapshot(session);
6299
- livenessById.set(id, {
6300
- unsubscribe,
6301
- liveness: livenessFromSnapshot(snapshot),
6302
- snapshot,
6303
- });
6304
- const detail = sessionDetailsById.get(id) ?? {};
6305
- detail.snapshot = snapshot;
6306
- sessionDetailsById.set(id, detail);
6307
6532
  }
6308
6533
  for (const [id, rec] of livenessById) {
6309
6534
  if (runningIds.has(id)) continue;
@@ -6331,6 +6556,33 @@ function apply(ctx) {
6331
6556
  }
6332
6557
  }
6333
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
+
6334
6586
  function getSessionSnapshot(session) {
6335
6587
  try {
6336
6588
  return session?.getSnapshot?.() ?? null;
@@ -6499,14 +6751,12 @@ function apply(ctx) {
6499
6751
  if (rec === undefined) {
6500
6752
  const el = document.createElement("div");
6501
6753
  el.className = CARD_CLASS;
6502
- const unbind = bindCardActivation(el, (sessionId) => {
6503
- // 后台任务子卡(R-01-024/AC-01):激活即切换卡内输出展开,不发起会话跳转
6504
- //(job 复合 id 非会话 id,无可打开目标)。
6505
- if (el.dataset.kind === "job") {
6506
- toggleJobExpanded(el);
6507
- return;
6508
- }
6509
- if (typeof sessions?.open !== "function") return;
6754
+ // 后台任务子卡为纯展示(R-01-023/AC-08):不绑激活监听,job 复合 id 亦非会话
6755
+ // id,不存在可跳转目标。job 复合 id 与会话 id 空间隔离(`job:` 前缀),reuseMap
6756
+ // 按条目 id 复用,同一 card 元素不会跨 kind 复用——创建时按 entry.kind 一次性
6757
+ // 决定是否绑激活是安全的;若 id 规则变化须同步复核此处。
6758
+ const unbind = entry.kind === "job" ? null : bindCardActivation(el, (sessionId) => {
6759
+ if (typeof ctx.get("uiWorkspace")?.openSession !== "function") return;
6510
6760
  lastActivatedId = sessionId;
6511
6761
  // 新激活意图取代一切旧重试链,避免过期链条稍后把当前会话拽回旧目标;
6512
6762
  // 收起抽屉的分支同样是最新意图,必须先取消挂起链条再 return。
@@ -6516,7 +6766,7 @@ function apply(ctx) {
6516
6766
  if (
6517
6767
  shouldDismissDrawerOnActivation({
6518
6768
  targetId: sessionId,
6519
- currentId: getSnapshot(sessions, "list")?.current ?? null,
6769
+ currentId: currentIdOf(getSnapshot(sessions, "list")),
6520
6770
  mobile: window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`).matches,
6521
6771
  drawerOpen:
6522
6772
  document.querySelector(`[${PANE_ATTR}]`)?.getAttribute("data-open") === "true",
@@ -6534,8 +6784,14 @@ function apply(ctx) {
6534
6784
  rec.el.dataset.kind = entry.kind;
6535
6785
  rec.el.dataset.sessionId = entry.id;
6536
6786
  rec.el.dataset.depth = String(entry.depth ?? 0);
6537
- rec.el.setAttribute("role", "button");
6538
- rec.el.tabIndex = 0;
6787
+ // 纯展示子卡(R-01-023/AC-08)不进入按钮语义与 tab 序。
6788
+ if (entry.kind === "job") {
6789
+ rec.el.removeAttribute("role");
6790
+ rec.el.removeAttribute("tabindex");
6791
+ } else {
6792
+ rec.el.setAttribute("role", "button");
6793
+ rec.el.tabIndex = 0;
6794
+ }
6539
6795
  rec.el.replaceChildren(...cardChildren(entry.kind));
6540
6796
  rec.kind = entry.kind;
6541
6797
  }
@@ -6806,16 +7062,18 @@ function apply(ctx) {
6806
7062
  }
6807
7063
  }
6808
7064
  const listState = listLoadState(snapshot);
6809
- // 0.1.5 sessions 行不再承载 pendingInteraction:从 uiSession.pendingInteractions
6810
- // 快照(Map<sessionId, interaction>,kind ∈ approval/plan-review/question)回填到
6811
- // 行副本上,等待卡分类(awaiting/blocked/提问中)保持既有单一口径(R-01-002/AC-03)。
6812
- const pendingSnapshot = uiSession?.pendingInteractions?.getSnapshot?.() ?? null;
6813
- 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) {
6814
7071
  const byId = { ...snapshot.byId };
6815
- for (const [id, interaction] of pendingSnapshot) {
7072
+ for (const [id, status] of statusSnapshot) {
6816
7073
  const row = byId[id];
6817
- if (!isRecord(row) || typeof interaction?.kind !== "string") continue;
6818
- 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 };
6819
7077
  }
6820
7078
  snapshot = { ...snapshot, byId };
6821
7079
  }
@@ -6837,9 +7095,12 @@ function apply(ctx) {
6837
7095
  for (const [id, record] of busyById) {
6838
7096
  if (typeof record?.openWaitStart === "number") waitingStarts.set(String(id), record.openWaitStart);
6839
7097
  }
6840
- // 在跑后台任务(R-01-023):jobsBySession 快照注入 buildEntries/buildRecent——
6841
- // liveJobs 非空的主会话保留活动区、归运行组并抑制完成/错误提醒。
6842
- 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;
6843
7104
  const active = buildEntries(snapshot, workspaceItems, sessionDetailsById, completeAcksById, delegatingIds, archivedSessionIds, waitingStarts, jobsBySession);
6844
7105
  // 轮内订阅仅对"运行中"会话建立(主会话 + 运行中的子代理),保持在运行中的订阅
6845
7106
  // 数量 == 运行中会话数量(R-02-004/AC-01);暂停等待的子代理只显示标题。
@@ -6947,7 +7208,7 @@ function apply(ctx) {
6947
7208
  entry.loadingModel = !detail?.model && (modelLoads.has(entry.id) || (entry.kind === "subagent" && historyLoads.has(entry.id)));
6948
7209
  entry.loadingTimeline =
6949
7210
  entry.timeline.length === 0 &&
6950
- (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));
6951
7212
  // token/速率取自 sessions.list 条目的投影(tokenUsage/sessionStats),
6952
7213
  // 复用既有列表订阅,无新增轮询(R-01-009/AC-05、R-02-004/AC-02)。
6953
7214
  const projection = snapshot?.byId?.[entry.id]?.projectionValues;
@@ -7076,7 +7337,12 @@ function apply(ctx) {
7076
7337
  if (chatLatestAssistantSettled(detailSnapshot)) subagentModelReadIds.add(entry.id);
7077
7338
  }
7078
7339
  const detailIds = [...active, ...recent].map((entry) => entry.id);
7079
- 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)));
7342
+ // 档位资源纪律(R-01-025/AC-01):渲染期持续执法——进入紧凑档即拆主会话窗口;
7343
+ // 拆窗时被豁免的当前会话在切走后的下一轮渲染照常释放。幂等:主会话拆窗后
7344
+ // 不在 logSourceSubs,重复执法为无操作。
7345
+ enforceDensityDataDiscipline();
7080
7346
  loadNativeDetails({ ids: detailIds, previewFallbackIds, durationFallbackIds, subagentModelReadIds });
7081
7347
  // 累计运行时长懒回填(R-01-020/AC-05):对可见主会话触发宿主侧存量回合补齐;
7082
7348
  // 宿主侧每会话单飞,重复触发由宿主合并;完成后经 busy SSE 广播推送。
@@ -7085,12 +7351,17 @@ function apply(ctx) {
7085
7351
  // 详情与 loads 记账同生命周期:离开可见集合即放行,重回可见时允许重拉/重试。
7086
7352
  // 锚点记账不随可见性 prune:瞬时 loading 空帧不得误清(进度重置);陈旧条目靠
7087
7353
  // 耗尽宽限与 turnStart 不匹配自校正,仅在 dispose 时整体清除。
7088
- pruneInvisibleEntries([sessionDetailsById, modelLoads, historyLoads, sessionOpenLoads], visibleIds);
7354
+ pruneInvisibleEntries([sessionDetailsById, modelLoads, historyLoads], visibleIds);
7089
7355
  // 模型目录订阅同生命周期:不可见即先 unsubscribe 再除名,监听器不残留(R-01-012/AC-16)。
7090
7356
  pruneSubscriptions(modelDirectorySubs, visibleIds);
7091
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
+ }
7092
7363
  // 重试链目标已成为当前会话(他途到达)即取消,避免过期链条拽回会话。
7093
- cancelStaleOpenRetries({ currentId: snapshot?.current ?? null, activatedId: lastActivatedId });
7364
+ cancelStaleOpenRetries({ currentId, activatedId: lastActivatedId });
7094
7365
 
7095
7366
  const visibleEntries = [...active, ...recent];
7096
7367
  const pulseSurface = desktopQuery.matches
@@ -7188,7 +7459,7 @@ function apply(ctx) {
7188
7459
  }
7189
7460
  runMoveGhosts(movePlans);
7190
7461
  if (shiftRects !== null) runShiftAnimations(shiftRects);
7191
- ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), snapshot?.current ?? null);
7462
+ ensureCurrentCardVisible(pane.querySelector(".dap-scroll"), currentIdOf(snapshot));
7192
7463
  if (focusAfterMigrationId !== null) cardsById.get(focusAfterMigrationId)?.el.focus();
7193
7464
  // 区域已有条目但列表仍在途时,在区头部显示行内加载指示(R-01-014/AC-01)。
7194
7465
  const headerEl = pane.querySelector(".dap-titlebar");
@@ -7261,7 +7532,10 @@ function apply(ctx) {
7261
7532
  }
7262
7533
  // 锚定补偿(R-01-021/AC-01):档位切换的渲染落地后量测当前选中卡的新位置并
7263
7534
  // 补偿 scrollTop,使当前卡顶部回到切换前相对视口的位置;补偿量超滚动边界时
7264
- // 由浏览器钳制(以当前卡不滚出可视范围为准)。
7535
+ // 由浏览器钳制(以当前卡不滚出可视范围为准)。沉降窗内逐帧补偿:dsh 0.2.0
7536
+ // 起切回高档位的窗口经 retain→ready 异步重建,时间线内容分多帧长回,单帧
7537
+ // 补偿会漏掉后期增长(T-170 审核修复回归);位置稳定(残差 ≤1px)或超窗
7538
+ // (1500ms)即撤,避免与用户手动滚动长期对抗。
7265
7539
  if (pendingDensityAnchor !== null) {
7266
7540
  const scrollEl = pane.querySelector(".dap-scroll");
7267
7541
  // 补偿按当前 DOM 的 data-current 卡实时定位:跨区迁移等渲染可能重建卡片
@@ -7270,13 +7544,21 @@ function apply(ctx) {
7270
7544
  if (scrollEl !== null && currentCard !== null) {
7271
7545
  const viewportTop = scrollEl.getBoundingClientRect().top;
7272
7546
  const shiftedTop = currentCard.getBoundingClientRect().top - viewportTop;
7547
+ // 仅按沉降窗超时撤除(T-170):窗口重建分多帧长回且成簇到达,帧间
7548
+ // 空隙的「稳定帧」不可作为撤除依据——提前撤除会让后续突增失偿。
7273
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;
7274
7557
  }
7275
- pendingDensityAnchor = null;
7276
7558
  }
7277
7559
  }
7278
7560
 
7279
- // ---- 打开会话(让 sessions.open 自己校验列表,失败时 refresh + 重试) ----
7561
+ // ---- 打开会话(dsh 0.2.0 经 uiWorkspace.openSession 导航,currentId 驱动重试链) ----
7280
7562
  const MAX_OPEN_ATTEMPTS = 60;
7281
7563
  function cardElFor(sessionId) {
7282
7564
  return document.querySelector(`[${PANE_ATTR}] [data-session-id="${escapeCssString(sessionId)}"]`);
@@ -7319,31 +7601,44 @@ function apply(ctx) {
7319
7601
  }, 500);
7320
7602
  });
7321
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
+ }
7322
7617
  function attemptOpen(sessionId, attempt) {
7323
- // 过期链条(目标已到达 / 已被新激活意图取代)不再发起 open,防止拽回会话。
7324
- if (
7325
- shouldCancelOpenRetry({
7326
- targetId: sessionId,
7327
- currentId: getSnapshot(sessions, "list")?.current ?? null,
7328
- activatedId: lastActivatedId,
7329
- })
7330
- ) {
7618
+ // 过期链条(目标已到达 / 已被新激活意图取代)不再发起 open,防止拽回会话;
7619
+ // 到达(currentId 命中本目标)即视为导航成功并收链。
7620
+ const currentId = currentIdOf(getSnapshot(sessions, "list"));
7621
+ if (shouldCancelOpenRetry({ targetId: sessionId, currentId, activatedId: lastActivatedId })) {
7331
7622
  cancelOpenRetry(sessionId);
7332
7623
  return;
7333
7624
  }
7334
7625
  const el = cardElFor(sessionId);
7335
7626
  if (el !== null) el.setAttribute("data-opening", "");
7336
7627
 
7337
- if (openSession(sessions, sessionId)) {
7338
- // 移动视口下抑制原生 composer 在 sessionId 变化后的自动聚焦,
7339
- // 避免切换会话弹出软键盘(桌面保持原生自动聚焦)。
7340
- if (!desktopQuery.matches) suppressComposerAutofocus(document);
7341
- // 到达新目标后取消全部剩余链条:任何旧链成功都会把会话从新目标拽走。
7342
- cancelStaleOpenRetries({ activatedId: sessionId });
7343
- cancelOpenRetry(sessionId);
7344
- el?.removeAttribute("data-opening");
7345
- return;
7346
- }
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);
7347
7642
  if (attempt < MAX_OPEN_ATTEMPTS) scheduleOpenRetry(sessionId, attempt + 1);
7348
7643
  else cancelOpenRetry(sessionId);
7349
7644
  }
@@ -7459,13 +7754,14 @@ function apply(ctx) {
7459
7754
 
7460
7755
  const cleanup = () => {
7461
7756
  disposed = true;
7757
+ frameObserver?.disconnect();
7758
+ frameObserver = null;
7759
+ observedFrame = null;
7462
7760
  sessionUnsubscribe?.();
7463
7761
  workspaceUnsubscribe?.();
7464
7762
  pendingUnsubscribe?.();
7465
7763
  acksSource?.close();
7466
7764
  acksSource = null;
7467
- jobsSource?.close();
7468
- jobsSource = null;
7469
7765
  busySource?.close();
7470
7766
  busySource = null;
7471
7767
  document.removeEventListener("visibilitychange", onBusyVisibilityResume);
@@ -7500,8 +7796,12 @@ function apply(ctx) {
7500
7796
  // 卸载即全量退订:空可见集合驱动 pruneSubscriptions 先 unsubscribe 再除名。
7501
7797
  pruneSubscriptions(modelDirectorySubs, new Set());
7502
7798
  pruneSubscriptions(logSourceSubs, new Set());
7799
+ // 会话引用与 jobs roster 订阅随卸载全量释放(dsh 0.2.0 retain/流语义,T-170)。
7800
+ releaseAllSessionRefs();
7801
+ stopAllJobWatches();
7802
+ jobsUnsubscribe?.();
7803
+ jobsUnsubscribe = null;
7503
7804
  progressAnchorById.clear();
7504
- sessionOpenLoads.clear();
7505
7805
  loadQueue.length = 0;
7506
7806
  loadInflight = 0;
7507
7807
  for (const rec of cardsById.values()) rec.unbind?.();