dsh-compact-relay 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * 职责:输入框左侧指示灯 / 悬停卡 / 菜单 / 压缩确认卡 / 交接单面板 / toast,
5
5
  * 输入框上方接力条(0.5.0:已接力自 … + 下拉菜单),
6
6
  * 以及 /handoff 命令卡(命令成功后自动切到新会话)。
7
- * 数据:GET /api/compact-relay-state、SSE /api/compact-relay-stream;
7
+ * 数据:GET /api/compact-relay-state、SSE /api/compact-relay-stream、
8
+ * GET /api/compact-relay-chain(0.5.1 DCR-05 整条接力链,一次读取);
8
9
  * 动作:POST /api/compact-relay-action;交接单:GET /api/compact-relay-handoffs。
9
10
  *
10
11
  * 为什么是单文件:DSH 运行时没有打包器,package.json exports["./client"]
@@ -1090,7 +1091,20 @@ window.__ModuleLoader__.load({
1090
1091
  function CompactRelayIndicator(props) {
1091
1092
  const { sessionId, waitAndOpen } = props;
1092
1093
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
1093
- const [state, setState] = useState({ advice: 'none', pressure: null, window: null, summaries: 0, colors: null, compactRange: undefined, busy: false, pins: { count: 0, seqs: [], marks: [] }, compressHint: null, native: null });
1094
+ const [state, setState] = useState({
1095
+ advice: 'none',
1096
+ pressure: null,
1097
+ window: null,
1098
+ summaries: 0,
1099
+ colors: null,
1100
+ compactRange: undefined,
1101
+ busy: false,
1102
+ // 0.5.1 批 2:stale = 被压缩带走的 pin(失效标注);atRisk = 下一次原生压缩会覆盖的 pin。
1103
+ pins: { count: 0, seqs: [], marks: [], stale: { count: 0, seqs: [], native: 0, other: 0, unknown: 0 }, atRisk: null },
1104
+ compressHint: null,
1105
+ native: null,
1106
+ sessionPinned: null,
1107
+ });
1094
1108
  // 0.4 P2-3:原生自动压缩的「有效线」——优先真实触发线(thresholdTokens/window),
1095
1109
  // 老宿主/算不出时回落 thresholdRatio(窗口比例)。
1096
1110
  const effectiveNativeRatio = (native) => {
@@ -1160,6 +1174,8 @@ window.__ModuleLoader__.load({
1160
1174
  const [handoffPreview, setHandoffPreview] = useState(null);
1161
1175
  /** 0.4 P2-1:交接预览里「约束 / 下一步」两段的编辑值(键=段标题;空值回落原文)。 */
1162
1176
  const [handoffEditValues, setHandoffEditValues] = useState({});
1177
+ // 0.5.1 批 2:交接后是否归档旧会话(默认开 = 0.5.0 行为;可取消)。
1178
+ const [archiveWanted, setArchiveWanted] = useState(true);
1163
1179
  const handoffPreviewRef = useRef(null);
1164
1180
  const handoffPreviewAbortRef = useRef(null);
1165
1181
  const closeHandoffPreview = useCallback(() => {
@@ -1266,15 +1282,35 @@ window.__ModuleLoader__.load({
1266
1282
  ? {
1267
1283
  count: Number(data.pins.count) || 0,
1268
1284
  seqs: Array.isArray(data.pins.seqs) ? data.pins.seqs.filter((seq) => Number.isInteger(seq)) : [],
1269
- // 0.3.1:锁定消息在历史里的相对位置(0–1)——环上按位置画块。
1285
+ // 0.3.1 起下发、0.5.1 起环上不再画:锁定消息在历史里的相对位置(0–1)。
1286
+ // 位置标记归 0.6 时间线(数据面保留,0.6 的 timeline 直接消费)。
1270
1287
  marks: Array.isArray(data.pins.marks)
1271
1288
  ? data.pins.marks
1272
1289
  .filter((item) => item !== null && typeof item === 'object' && typeof item.ratio === 'number' && Number.isFinite(item.ratio))
1273
1290
  .map((item) => ({ seq: Number.isInteger(item.seq) ? item.seq : -1, ratio: Math.max(0, Math.min(1, item.ratio)) }))
1274
1291
  .slice(0, 20)
1275
1292
  : [],
1293
+ // 0.5.1 批 2(C 方案):被压缩带走、已不在 surface 上的 pin(不假装还在,也不消失)。
1294
+ stale:
1295
+ data.pins.stale && typeof data.pins.stale === 'object'
1296
+ ? {
1297
+ count: Number(data.pins.stale.count) || 0,
1298
+ seqs: Array.isArray(data.pins.stale.seqs) ? data.pins.stale.seqs.filter((seq) => Number.isInteger(seq)) : [],
1299
+ native: Number(data.pins.stale.native) || 0,
1300
+ other: Number(data.pins.stale.other) || 0,
1301
+ unknown: Number(data.pins.stale.unknown) || 0,
1302
+ }
1303
+ : { count: 0, seqs: [], native: 0, other: 0, unknown: 0 },
1304
+ // 0.5.1 批 2:原生压缩预判会覆盖的 pin(事前提示;null = 无风险)。
1305
+ atRisk:
1306
+ data.pins.atRisk && typeof data.pins.atRisk === 'object' && Number(data.pins.atRisk.count) > 0
1307
+ ? {
1308
+ count: Number(data.pins.atRisk.count) || 0,
1309
+ seqs: Array.isArray(data.pins.atRisk.seqs) ? data.pins.atRisk.seqs.filter((seq) => Number.isInteger(seq)) : [],
1310
+ }
1311
+ : null,
1276
1312
  }
1277
- : { count: 0, seqs: [], marks: [] },
1313
+ : { count: 0, seqs: [], marks: [], stale: { count: 0, seqs: [], native: 0, other: 0, unknown: 0 }, atRisk: null },
1278
1314
  // 0.3.1:模型判断的「这里适合压缩」位置(0–1);没有或已失效为 null。
1279
1315
  compressHint:
1280
1316
  data.compressHint && typeof data.compressHint === 'object' && typeof data.compressHint.ratio === 'number' && Number.isFinite(data.compressHint.ratio)
@@ -1334,6 +1370,8 @@ window.__ModuleLoader__.load({
1334
1370
  // 0.3.5:范围存在但会话没有活 agent —— 菜单据此显示「会话未激活…」而不是
1335
1371
  // 「暂无可压缩内容」(applyState 只保留白名单字段,新字段必须显式透传)。
1336
1372
  compactRangeReason: data.compactRangeReason === 'no_agent' ? 'no_agent' : null,
1373
+ // 0.5.1 批 2:本会话是否在侧栏置顶(true/false/null=宿主读不到)。
1374
+ sessionPinned: data.sessionPinned === true ? true : data.sessionPinned === false ? false : null,
1337
1375
  });
1338
1376
  }, []);
1339
1377
 
@@ -1504,6 +1542,29 @@ window.__ModuleLoader__.load({
1504
1542
 
1505
1543
  // M6:锁定集合(宿主下发的 count/seqs;默认空)。
1506
1544
  const pins = state.pins !== null && typeof state.pins === 'object' ? state.pins : { count: 0, seqs: [] };
1545
+ // 0.5.1 批 2(C 方案):失效 pin(快照里有、surface 上已被压缩带走)与原生压缩预判风险。
1546
+ const pinStale =
1547
+ pins.stale !== null && typeof pins.stale === 'object'
1548
+ ? pins.stale
1549
+ : { count: 0, seqs: [], native: 0, other: 0, unknown: 0 };
1550
+ const pinStaleCount = Number.isInteger(pinStale.count) && pinStale.count > 0 ? pinStale.count : 0;
1551
+ // 失效原因口径与 core/pin-health.staleCauseTextOf 一致(客户端只拿计数,不重读日志)。
1552
+ const staleCauseText =
1553
+ pinStale.native > 0 && pinStale.other === 0 && pinStale.unknown === 0
1554
+ ? '随原生压缩移除'
1555
+ : pinStale.native === 0 && pinStale.other > 0 && pinStale.unknown === 0
1556
+ ? '随压缩移除'
1557
+ : pinStale.native === 0 && pinStale.other === 0 && pinStale.unknown > 0
1558
+ ? '随历史变化失效'
1559
+ : '随压缩或历史变化失效';
1560
+ const staleLabel = pinStaleCount > 0 ? `已失效 ${pinStaleCount} 条(${staleCauseText})` : '';
1561
+ const pinRisk =
1562
+ pins.atRisk !== null && typeof pins.atRisk === 'object' && Number(pins.atRisk.count) > 0
1563
+ ? { count: Number(pins.atRisk.count) || 0, seqs: Array.isArray(pins.atRisk.seqs) ? pins.atRisk.seqs : [] }
1564
+ : null;
1565
+ const pinRiskLabel = pinRisk !== null ? `原生压缩预计覆盖 ${pinRisk.count} 条锁定消息` : '';
1566
+ // 0.5.1 批 2:交接前「归档会取消置顶」确认的数据源(null=宿主读不到,不弹确认)。
1567
+ const sessionPinned = state.sessionPinned === true;
1507
1568
  // 0.3.1:阶段标记功能已删(作者拍板),ring level 只看建议。
1508
1569
  const level = typeof state.level === 'string' ? state.level : adviseHandoff ? 'handoff' : adviseCompact ? 'compact' : 'idle';
1509
1570
 
@@ -1645,24 +1706,9 @@ window.__ModuleLoader__.load({
1645
1706
  }
1646
1707
  // 平时给中心一点可见的灰点;交接时红点(外环已分段,红点强调该交接)。
1647
1708
  const dotColor = adviseHandoff ? palette.handoff : palette.idle;
1648
- // 锁定消息在环上的块(方案 A:按消息在历史里的相对位置)。这是「保护内容」的信号,
1649
- // 权重最高:用最显眼的反差色、比外环略粗;多个锁定全部显示。
1650
- const pinMarks = state.pins && Array.isArray(state.pins.marks) ? state.pins.marks : [];
1651
- const pinBlocks = pinMarks.map((mark, index) =>
1652
- h('circle', {
1653
- key: `pin-${mark.seq ?? index}-${index}`,
1654
- cx: 10,
1655
- cy: 10,
1656
- r: RADIUS,
1657
- fill: 'none',
1658
- stroke: palette.pin,
1659
- strokeWidth: 2.9,
1660
- strokeLinecap: 'round',
1661
- strokeDasharray: `2.8 ${CIRC}`,
1662
- transform: `rotate(${-90 + mark.ratio * 360} 10 10)`,
1663
- 'data-cr-pin-mark': String(index),
1664
- }),
1665
- );
1709
+ // 0.5.1 设计定稿(用户拍板):环只表达容量,不再画 pin 位置块。
1710
+ // 位置标记(state.pins.marks)归 0.6 时间线:数据面仍读取(见上方状态归一化),
1711
+ // 环上不画;锁定状态只用悬停卡/菜单的「已锁定 N 条 / 已失效 M 条」表达。
1666
1712
 
1667
1713
  // 忙碌 = 本组件正在跑动作,或宿主侧锁已占用(另一个窗口在压/接)。
1668
1714
  const hostBusy = state.busy === true;
@@ -1706,7 +1752,7 @@ window.__ModuleLoader__.load({
1706
1752
  nativeRatio === null
1707
1753
  ? 'DSH 原生自动压缩已关闭'
1708
1754
  : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(1)}%,到那之前先压缩或交接更稳`;
1709
- return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉。`;
1755
+ return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉(原生压缩不认锁定,真被压走会标「已失效」)。交接后会默认自动归档旧会话(侧栏收起,可取消勾选「交接后归档」;旧会话在侧栏置顶时会先问你,因为归档会取消置顶)。`;
1710
1756
  }, [state.native]);
1711
1757
 
1712
1758
  /** 播放一次引导:呼吸光晕 + 提示。手动重播用更长时间(用户要关掉设置页才看得到)。 */
@@ -1757,6 +1803,19 @@ window.__ModuleLoader__.load({
1757
1803
  return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
1758
1804
  }, [playOnboarding]);
1759
1805
 
1806
+ // 0.5.1 批 2:原生压缩即将覆盖锁定消息 —— 事前主动提示一次(同一风险签名只弹一次)。
1807
+ const pinRiskToastRef = useRef('');
1808
+ useEffect(() => {
1809
+ if (pinRisk === null) return;
1810
+ const signature = `${state.sessionId ?? ''}:${pinRisk.seqs.join(',')}`;
1811
+ if (signature === pinRiskToastRef.current) return;
1812
+ pinRiskToastRef.current = signature;
1813
+ showToast(
1814
+ `⚠ 原生压缩预计覆盖你锁定的 ${pinRisk.count} 条消息(通常在本轮回复收尾时触发)。建议先交接(/handoff)保留原文,或解锁让压缩带走。`,
1815
+ 12000,
1816
+ );
1817
+ }, [pinRisk, state.sessionId, showToast]);
1818
+
1760
1819
  // 0.4.2(P1):换模型后首次发现「交接线已晚」弹一次提示(同路由不重复打扰)。
1761
1820
  // 只在「观察到路由变化」(localStorage 记着上一次的 provider/model)时弹;
1762
1821
  // 首次见到某路由不弹(不是换模型导致的),设置页有常驻提示兜底。
@@ -1852,6 +1911,7 @@ window.__ModuleLoader__.load({
1852
1911
  closeDocs();
1853
1912
  closeDiff();
1854
1913
  setHandoffEditValues({});
1914
+ setArchiveWanted(true);
1855
1915
  setHandoffPreview({ loading: true });
1856
1916
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1857
1917
  handoffPreviewAbortRef.current = controller;
@@ -2081,7 +2141,19 @@ window.__ModuleLoader__.load({
2081
2141
  showToast('交接单已生成,正在打开新会话…', 0);
2082
2142
  try {
2083
2143
  await waitAndOpen(data.childId);
2084
- showToast('已切换到新会话;旧会话仍保留(交接前可能做过压缩,原文都在会话日志里)。', 6000);
2144
+ // 0.5.1 批 2:归档有三种结果(归档 / 按选择不归档 / 置顶未确认不归档),
2145
+ // 成功提示要如实说清旧会话去哪了,不能一律说「仍保留」。
2146
+ const archiveStatus = data.archive !== null && typeof data.archive === 'object' ? data.archive.status : null;
2147
+ showToast(
2148
+ archiveStatus === 'archived' || archiveStatus === 'already'
2149
+ ? '已切换到新会话;旧会话已归档(可在新会话「恢复旧会话」找回)。'
2150
+ : archiveStatus === 'skipped'
2151
+ ? '已切换到新会话;按你的选择未归档旧会话(仍在列表里,置顶保持)。'
2152
+ : archiveStatus === 'pinned'
2153
+ ? '已切换到新会话;旧会话已置顶,本次未自动归档(归档会取消置顶)。'
2154
+ : '已切换到新会话;旧会话归档未完成(仍在列表里)。',
2155
+ 8000,
2156
+ );
2085
2157
  } catch (error) {
2086
2158
  showToast(`交接单已生成,但自动切换新会话失败:${error && error.message ? error.message : '未知原因'}(新会话 ${data.childId})`, 8000);
2087
2159
  }
@@ -2131,6 +2203,8 @@ window.__ModuleLoader__.load({
2131
2203
  (nativeLineText !== '' ? `|${nativeLineText}` : '') +
2132
2204
  (handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
2133
2205
  (pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
2206
+ (pinStaleCount > 0 ? `|${staleLabel}` : '') +
2207
+ (pinRisk !== null ? `|⚠ ${pinRiskLabel}(建议先交接或解锁)` : '') +
2134
2208
  (effectiveBusy ? '|处理中…' : '');
2135
2209
 
2136
2210
  // 浮层定位:fixed(不受 composer 裁剪)+ 视口左右收边 + 上方空间不够时翻到下方。
@@ -2189,6 +2263,18 @@ window.__ModuleLoader__.load({
2189
2263
  { style: { color: theme.subText } },
2190
2264
  `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
2191
2265
  ),
2266
+ // 0.5.1 批 2:失效 pin 明确标出来(不假装还在、不消失)。
2267
+ pinStaleCount > 0
2268
+ ? h('div', { 'data-cr-pin-stale': '1', style: { marginTop: 2, color: theme.subText } }, staleLabel)
2269
+ : null,
2270
+ // 0.5.1 批 2:下一次原生自动压缩会覆盖锁定消息 → 事前提示(先交接/保留或解锁)。
2271
+ pinRisk !== null
2272
+ ? h(
2273
+ 'div',
2274
+ { 'data-cr-pin-risk': '1', style: { marginTop: 2, color: palette.handoff } },
2275
+ `⚠ ${pinRiskLabel}:下一次原生压缩(通常在本轮回复收尾时)会压掉它们。建议先交接(/handoff)保留,或解锁让压缩带走。`,
2276
+ )
2277
+ : null,
2192
2278
  // 0.4.2:交接线晚于原生压缩时,悬停卡里给一行显式警示(只告警,不改设置)。
2193
2279
  handoffWarnText !== ''
2194
2280
  ? h('div', { 'data-cr-handoff-warn-text': '1', style: { marginTop: 2, color: palette.handoff } }, handoffWarnText)
@@ -2217,8 +2303,7 @@ window.__ModuleLoader__.load({
2217
2303
  ...nativeNodes,
2218
2304
  // 0.4.2:交接线已晚的警示标记(交接刻度旁的小短线 + 小点,不改分段逻辑)。
2219
2305
  ...handoffWarnNodes,
2220
- // 0.3.1:锁定消息在环上的块(品红,按历史位置)。
2221
- ...pinBlocks,
2306
+ // 0.5.1 定稿:pin 位置块不画(位置标记归 0.6 时间线;环只表达容量)。
2222
2307
  );
2223
2308
 
2224
2309
  const trigger = h(
@@ -2255,6 +2340,9 @@ window.__ModuleLoader__.load({
2255
2340
  'data-cr-state-session': typeof state.sessionId === 'string' ? state.sessionId : '',
2256
2341
  'data-cr-summaries': String(state.summaries),
2257
2342
  'data-cr-pins': String(pins.count),
2343
+ // 0.5.1 批 2:失效 pin / 原生压缩预判风险(取证 + 端到端断言用)。
2344
+ 'data-cr-pins-stale': String(pinStaleCount),
2345
+ 'data-cr-pins-risk': pinRisk === null ? '' : String(pinRisk.count),
2258
2346
  style: {
2259
2347
  display: 'inline-flex',
2260
2348
  alignItems: 'center',
@@ -2346,6 +2434,16 @@ window.__ModuleLoader__.load({
2346
2434
  );
2347
2435
  };
2348
2436
 
2437
+ // 0.5.1 批 2:交接菜单项的提示(原生压缩预判风险优先于「交接线已晚」,两条都有就并排)。
2438
+ const handoffItemNote =
2439
+ pinRisk !== null
2440
+ ? `⚠ ${pinRiskLabel}:建议先交接保留`
2441
+ : handoffLate
2442
+ ? handoffWarnText
2443
+ : '';
2444
+ const handoffItemTitle =
2445
+ [pinRisk !== null ? `原生压缩会在下一次触发时(通常本轮回复收尾)压掉你锁定的 ${pinRisk.count} 条消息。先交接可保留原文;也可以解锁让压缩带走。` : '',
2446
+ handoffLate ? handoffGuardInfo.message : ''].filter((text) => text !== '').join('\n') || undefined;
2349
2447
  // undefined=宿主没上报(老版本/未知);null=明确没有可压范围;object=可压多少。
2350
2448
  const rangeInfo = state.compactRange;
2351
2449
  // 0.3.5:有可压范围、但会话还没有活 agent(passivized live session)——preview 需要
@@ -2406,16 +2504,28 @@ window.__ModuleLoader__.load({
2406
2504
  },
2407
2505
  ),
2408
2506
  item(
2409
- pins.count > 0 ? `解锁消息(${pins.count} 条)` : '锁定重要消息',
2507
+ pins.count > 0
2508
+ ? `解锁消息(${pins.count} 条)`
2509
+ : pinStaleCount > 0
2510
+ ? `解除失效锁定(${pinStaleCount} 条)`
2511
+ : '锁定重要消息',
2410
2512
  pins.count > 0
2411
2513
  ? '解除锁定,允许后续压缩合并这些消息'
2412
- : '锁定的消息压缩时不会碰(重要结论/路径先锁住,也可 /pin list 挑一条)',
2514
+ : pinStaleCount > 0
2515
+ ? `${staleLabel}:原文已不在当前上下文,点此清空锁定记录`
2516
+ : '锁定的消息压缩时不会碰(重要结论/路径先锁住,也可 /pin list 挑一条)',
2413
2517
  'pin',
2414
2518
  null,
2415
2519
  hasSession
2416
2520
  ? {
2417
2521
  oneLine: true,
2418
- right: pins.count > 0 ? `已锁 ${pins.count} 条` : '未锁定',
2522
+ right:
2523
+ pins.count > 0
2524
+ ? `已锁 ${pins.count} 条${pinStaleCount > 0 ? ` · 失效 ${pinStaleCount}` : ''}`
2525
+ : pinStaleCount > 0
2526
+ ? `失效 ${pinStaleCount} 条`
2527
+ : '未锁定',
2528
+ ...(pinRisk !== null ? { note: `⚠ ${pinRiskLabel}(建议先交接)`, title: pinRiskLabel } : {}),
2419
2529
  onClick: () => {
2420
2530
  void togglePins();
2421
2531
  },
@@ -2433,7 +2543,8 @@ window.__ModuleLoader__.load({
2433
2543
  void startHandoffPreview();
2434
2544
  },
2435
2545
  // 0.4.2(P1):交接线晚于原生压缩时,菜单项旁直接标出来(只告警,仍可点)。
2436
- ...(handoffLate ? { note: handoffWarnText, title: handoffGuardInfo.message } : {}),
2546
+ // 0.5.1 批 2:原生压缩预计覆盖 pin 时,这里优先提示「先交接保留」。
2547
+ ...(handoffItemNote !== '' ? { note: handoffItemNote, ...(handoffItemTitle === undefined ? {} : { title: handoffItemTitle }) } : {}),
2437
2548
  }
2438
2549
  : { disabled: true, note: '会话刚打开,请稍候' },
2439
2550
  ),
@@ -2621,6 +2732,27 @@ window.__ModuleLoader__.load({
2621
2732
  const handoffData = handoffPreview !== null && handoffPreview.loading !== true ? handoffPreview.data : null;
2622
2733
  const handoffReadiness = handoffData !== null && typeof handoffData === 'object' ? handoffData.readiness : null;
2623
2734
  const handoffDraft = handoffData !== null && typeof handoffData === 'object' && typeof handoffData.draft === 'string' ? handoffData.draft : '';
2735
+ /**
2736
+ * 0.5.1 批 2:交接确认统一入口 —— 只把「用户真的改过、且非空」的两段发给宿主;
2737
+ * includeArchive/confirmUnpin 由归档开关和置顶确认决定(服务端还会再兜一层)。
2738
+ */
2739
+ const beginHandoff = (options = {}) => {
2740
+ const editable = handoffData !== null && typeof handoffData === 'object' ? handoffData.editable : null;
2741
+ const edits = {};
2742
+ if (editable !== null && typeof editable === 'object' && Array.isArray(editable.titles)) {
2743
+ for (const title of editable.titles) {
2744
+ const original = typeof editable.sections?.[title] === 'string' ? editable.sections[title].trim() : '';
2745
+ const current = typeof handoffEditValues[title] === 'string' ? handoffEditValues[title].trim() : '';
2746
+ if (current.length > 0 && current !== original) edits[title] = current;
2747
+ }
2748
+ }
2749
+ closeHandoffPreview();
2750
+ void run('handoff', {
2751
+ archive: options.archive !== false,
2752
+ ...(options.confirmUnpin === true ? { confirmUnpin: true } : {}),
2753
+ ...(Object.keys(edits).length > 0 ? { edits } : {}),
2754
+ });
2755
+ };
2624
2756
  const handoffPanel =
2625
2757
  handoffPreview === null
2626
2758
  ? null
@@ -2658,7 +2790,10 @@ window.__ModuleLoader__.load({
2658
2790
  ? h('div', { style: { fontSize: 11, color: theme.subText } }, '正在生成九段草稿并做就绪检查…(只预览,不写盘、不建会话)')
2659
2791
  : h(
2660
2792
  'div',
2661
- { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0 } },
2793
+ {
2794
+ 'data-cr-handoff-body': '1',
2795
+ style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, flexShrink: 0 },
2796
+ },
2662
2797
  h(
2663
2798
  'div',
2664
2799
  {
@@ -2678,11 +2813,13 @@ window.__ModuleLoader__.load({
2678
2813
  h(
2679
2814
  'div',
2680
2815
  {
2816
+ 'data-cr-handoff-draft': '1',
2681
2817
  style: {
2682
2818
  fontSize: 11,
2683
2819
  lineHeight: '16px',
2684
2820
  overflow: 'auto',
2685
2821
  maxHeight: 220,
2822
+ flexShrink: 0,
2686
2823
  border: `1px solid ${theme.panelBorder}`,
2687
2824
  borderRadius: 8,
2688
2825
  padding: '6px 8px',
@@ -2735,10 +2872,53 @@ window.__ModuleLoader__.load({
2735
2872
  h('div', { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } }, handoffData.editable.note ?? '程序生成的段不可改。'),
2736
2873
  )
2737
2874
  : null,
2738
- h('div', { style: { fontSize: 11, color: theme.subText } }, '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。'),
2875
+ // 0.5.1 批 2:归档开关(默认开 = 0.5.0 行为;取消后只交接、不归档)。
2876
+ h(
2877
+ 'label',
2878
+ {
2879
+ 'data-cr-handoff-archive-row': '1',
2880
+ style: { display: 'flex', gap: 6, alignItems: 'flex-start', fontSize: 11, lineHeight: '16px', color: theme.subText },
2881
+ },
2882
+ h('input', {
2883
+ type: 'checkbox',
2884
+ 'data-cr-handoff-archive': '1',
2885
+ checked: archiveWanted,
2886
+ onChange: (event) => setArchiveWanted(event.target.checked === true),
2887
+ style: { marginTop: 2 },
2888
+ }),
2889
+ h(
2890
+ 'span',
2891
+ null,
2892
+ '交接后自动归档旧会话(默认开;新会话建好后旧会话从侧栏收起,可在新会话「恢复旧会话」找回)。取消勾选 = 只交接、旧会话留在列表里。',
2893
+ ),
2894
+ ),
2895
+ // 0.5.1 批 2:置顶保护 —— 宿主归档会在同一次写盘里取消置顶,必须用户确认。
2896
+ sessionPinned && archiveWanted
2897
+ ? h(
2898
+ 'div',
2899
+ {
2900
+ 'data-cr-handoff-pinned-warning': '1',
2901
+ style: {
2902
+ fontSize: 11,
2903
+ lineHeight: '16px',
2904
+ padding: '6px 8px',
2905
+ borderRadius: 8,
2906
+ border: `1px solid ${palette.handoff}`,
2907
+ color: palette.handoff,
2908
+ background: theme.hover,
2909
+ },
2910
+ },
2911
+ '旧会话已在侧栏置顶:归档会取消置顶(宿主行为)。要归档请点「交接并归档(取消置顶)」;想保留置顶就点「只交接,保留置顶」。',
2912
+ )
2913
+ : null,
2739
2914
  h(
2740
2915
  'div',
2741
- { style: { display: 'flex', justifyContent: 'flex-end', gap: 6 } },
2916
+ { style: { fontSize: 11, color: theme.subText } },
2917
+ '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。归档开关只影响旧会话是否从侧栏收起,不影响交接单本身。',
2918
+ ),
2919
+ h(
2920
+ 'div',
2921
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' } },
2742
2922
  h(
2743
2923
  'button',
2744
2924
  {
@@ -2749,25 +2929,25 @@ window.__ModuleLoader__.load({
2749
2929
  },
2750
2930
  '取消',
2751
2931
  ),
2932
+ // 置顶 + 勾选归档:给两个明确出口(归档并取消置顶 / 只交接保留置顶)。
2933
+ sessionPinned && archiveWanted
2934
+ ? h(
2935
+ 'button',
2936
+ {
2937
+ type: 'button',
2938
+ 'data-cr-handoff-keep': '1',
2939
+ onClick: () => beginHandoff({ archive: false }),
2940
+ style: { ...smallBtnStyle, borderRadius: 7, padding: '4px 10px' },
2941
+ },
2942
+ '只交接,保留置顶',
2943
+ )
2944
+ : null,
2752
2945
  h(
2753
2946
  'button',
2754
2947
  {
2755
2948
  type: 'button',
2756
2949
  'data-cr-handoff-run': '1',
2757
- onClick: () => {
2758
- // P2-1:只把「用户真的改过、且非空」的两段发给宿主;空值/未改 = 用重新生成的原文。
2759
- const editable = handoffData !== null && typeof handoffData === 'object' ? handoffData.editable : null;
2760
- const edits = {};
2761
- if (editable !== null && typeof editable === 'object' && Array.isArray(editable.titles)) {
2762
- for (const title of editable.titles) {
2763
- const original = typeof editable.sections?.[title] === 'string' ? editable.sections[title].trim() : '';
2764
- const current = typeof handoffEditValues[title] === 'string' ? handoffEditValues[title].trim() : '';
2765
- if (current.length > 0 && current !== original) edits[title] = current;
2766
- }
2767
- }
2768
- closeHandoffPreview();
2769
- void run('handoff', Object.keys(edits).length > 0 ? { edits } : undefined);
2770
- },
2950
+ onClick: () => beginHandoff(sessionPinned && archiveWanted ? { archive: true, confirmUnpin: true } : { archive: archiveWanted }),
2771
2951
  style: {
2772
2952
  border: 'none',
2773
2953
  borderRadius: 7,
@@ -2779,7 +2959,11 @@ window.__ModuleLoader__.load({
2779
2959
  color: '#ffffff',
2780
2960
  },
2781
2961
  },
2782
- '确认交接',
2962
+ sessionPinned && archiveWanted
2963
+ ? '交接并归档(取消置顶)'
2964
+ : archiveWanted
2965
+ ? '确认交接(归档旧会话)'
2966
+ : '确认交接(不归档)',
2783
2967
  ),
2784
2968
  ),
2785
2969
  ),
@@ -3152,7 +3336,9 @@ window.__ModuleLoader__.load({
3152
3336
  * 显示:`readState.chain` 非 null,且这条记录是**本会话的入站记录**
3153
3337
  * (childId === sessionId)。出站记录(本会话交接给了下一代)不显示
3154
3338
  * 「已接力自」;老宿主/没有 chain 字段时组件返回 null,不占高度。
3155
- * 数据:GET `/api/compact-relay-state?sessionId=…`。会话刚打开、宿主还没装日志
3339
+ * 数据:GET `/api/compact-relay-state?sessionId=…`;
3340
+ * 完整链优先 GET `/api/compact-relay-chain?sessionId=…`(一次读全,祖先未加载也行),
3341
+ * 老宿主回落逐级 state。会话刚打开、宿主还没装日志
3156
3342
  * 时短重试几次(拿到本会话的 sessionId 后就停,不做常驻轮询/SSE)。
3157
3343
  * 子会话的 relay record 不带 path(F3 只写链号),缺 path 时再只读一次父
3158
3344
  * 会话状态,用父记录里的 path 打开交接单(childId/ordinal 校验)。
@@ -3205,6 +3391,42 @@ window.__ModuleLoader__.load({
3205
3391
  };
3206
3392
  }
3207
3393
 
3394
+ /**
3395
+ * 服务端状态里「这一跳」的**出站**投影(0.5.1 DCR-01)。
3396
+ *
3397
+ * 父会话请求拿到的是它自己的状态:`chain` 是**父会话的入站**记录(它的上一代),
3398
+ * `next` 才是它交接给子会话的那条出站记录 —— 子会话要补的交接单 path 在 next 里。
3399
+ * 老插件(0.5.0)没有 next 字段,那时 chain 就是「最近一条记录」,直接回落到它,
3400
+ * 保证旧服务端仍可用。校验仍在调用方(childId/链号必须对得上)。
3401
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3402
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3403
+ */
3404
+ function relayOutboundProjectionOf(data) {
3405
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3406
+ const next = data.next;
3407
+ if (next !== null && next !== undefined) {
3408
+ const projection = relayChainProjectionOf({ ok: true, chain: next });
3409
+ if (projection !== null) return projection;
3410
+ }
3411
+ // 老插件:没有 next,chain 就是最近一条记录(出站)。
3412
+ return relayChainProjectionOf(data);
3413
+ }
3414
+
3415
+ /**
3416
+ * 0.5.1 DCR-03:宿主有没有恢复归档会话的接缝(readState.capabilities.unarchive)。
3417
+ *
3418
+ * 三重取值:true / false / null(老插件没有 capabilities 字段 → 不知道,按「可尝试」
3419
+ * 处理,恢复调用本身幂等)。
3420
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3421
+ * @returns {boolean|null}
3422
+ */
3423
+ function relayBarUnarchiveCapabilityOf(data) {
3424
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3425
+ const capabilities = data.capabilities;
3426
+ if (capabilities === null || typeof capabilities !== 'object') return null;
3427
+ return typeof capabilities.unarchive === 'boolean' ? capabilities.unarchive : null;
3428
+ }
3429
+
3208
3430
  /**
3209
3431
  * 0.5.0:旧会话是否已被交接流程自动归档。
3210
3432
  *
@@ -3218,6 +3440,34 @@ window.__ModuleLoader__.load({
3218
3440
  return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
3219
3441
  }
3220
3442
 
3443
+ /**
3444
+ * 0.5.2 L 项:宿主有没有强制归档的接缝(`readState.capabilities.forceArchive`)。
3445
+ *
3446
+ * 三重取值:true / false / null(老插件没有这个字段 → 不知道,仍显示入口;
3447
+ * 调用失败会给可读错误,不静默)。false = 宿主没有 `archiveSession` → 隐藏死入口。
3448
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3449
+ * @returns {boolean|null}
3450
+ */
3451
+ function relayBarForceArchiveCapabilityOf(data) {
3452
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3453
+ const capabilities = data.capabilities;
3454
+ if (capabilities === null || typeof capabilities !== 'object') return null;
3455
+ return typeof capabilities.forceArchive === 'boolean' ? capabilities.forceArchive : null;
3456
+ }
3457
+
3458
+ /**
3459
+ * 0.5.2 L 项:链上旧会话(入站 parentId)是否在侧栏置顶(`readState.parentPinned`)。
3460
+ *
3461
+ * 置顶与归档互斥:宿主 `archiveSession` 在同一笔写盘里把该 id 从置顶集合移除。
3462
+ * 强制归档的二次确认要在置顶时明确写「会同时取消置顶」,所以这里只读判断。
3463
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3464
+ * @returns {boolean|null}
3465
+ */
3466
+ function relayBarParentPinnedOf(data) {
3467
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3468
+ return typeof data.parentPinned === 'boolean' ? data.parentPinned : null;
3469
+ }
3470
+
3221
3471
  /**
3222
3472
  * 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
3223
3473
  * @param {object} chain relayBarChainOf 的结果
@@ -3228,6 +3478,39 @@ window.__ModuleLoader__.load({
3228
3478
  return chain.scheme === 'history-n' ? `历史 ${chain.ordinal}:${baseTitle}` : `交接第 ${chain.ordinal} 次:${baseTitle}`;
3229
3479
  }
3230
3480
 
3481
+ /**
3482
+ * 0.5.1 批 3:父会话侧的**出站目标**(`readState.next` 的投影,父→子入口用)。
3483
+ *
3484
+ * 与 relayOutboundProjectionOf 的区别:这里**不回落到 chain**。老插件没有 next 字段
3485
+ * (那里 chain 是「最近一条记录」,可能是本会话的入站记录),拿它当出站目标会生出
3486
+ * 「去新会话」却跳回自己的死入口。另外 childId 指回本会话的脏记录也一律不认。
3487
+ *
3488
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3489
+ * @param {string|undefined} sessionId 当前会话 id
3490
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3491
+ */
3492
+ function relayBarNextOf(data, sessionId) {
3493
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3494
+ if (data.next === null || data.next === undefined) return null;
3495
+ const projection = relayChainProjectionOf({ ok: true, chain: data.next });
3496
+ if (projection === null || projection.childId === null) return null;
3497
+ if (typeof sessionId === 'string' && sessionId.length > 0 && projection.childId === sessionId) return null;
3498
+ return projection;
3499
+ }
3500
+
3501
+ /**
3502
+ * 0.5.1 批 3:出站目标(下一代会话)是否已在归档集合里(readState.nextArchived)。
3503
+ *
3504
+ * 三重取值:true / false / null(老插件没有这个字段 → 不知道,按「可尝试恢复」处理,
3505
+ * unarchive 幂等)。父会话侧「去新会话」据此决定要不要先 unarchive 再 open。
3506
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3507
+ * @returns {boolean|null}
3508
+ */
3509
+ function relayBarNextArchivedOf(data) {
3510
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3511
+ return typeof data.nextArchived === 'boolean' ? data.nextArchived : null;
3512
+ }
3513
+
3231
3514
  /**
3232
3515
  * 交接单绝对路径 → `/api/compact-relay-handoffs?file=` 只接受的 handoff-*.md 文件名。
3233
3516
  * 服务端只认文件名(不是全路径),这里取 basename 并做同一形态校验;不合法 → null。
@@ -3240,6 +3523,48 @@ window.__ModuleLoader__.load({
3240
3523
  return typeof name === 'string' && /^handoff-[0-9A-Za-z][0-9A-Za-z.\-_]*\.md$/.test(name) ? name : null;
3241
3524
  }
3242
3525
 
3526
+ /**
3527
+ * 完整交接链最多回看多少跳(0.5.1 批 3)。
3528
+ *
3529
+ * 硬上限:链路异常成环、或从某个 0.4 老链一路接力下来时,不能让界面无限逐级
3530
+ * 打接口。正常用户几跳内就到根,8 跳足够长;到顶时界面明说「只显示最近 8 层」。
3531
+ */
3532
+ const MAX_CHAIN_HOPS = 8;
3533
+
3534
+ /**
3535
+ * 0.5.1 DCR-05:完整交接链一次读完的只读端点。
3536
+ *
3537
+ * 服务端直接读持久化日志/header(不加载祖先会话),因此重启后祖先都没加载也能
3538
+ * 一次拿到整条链;老插件/老宿主没有这个端点时客户端回落到逐级 state 读法。
3539
+ */
3540
+ const RELAY_CHAIN_ENDPOINT = '/api/compact-relay-chain';
3541
+
3542
+ /**
3543
+ * 服务端整链响应 → 行数组;形状不合法返回 null(调用方回落旧逐级读法)。
3544
+ *
3545
+ * 行只保留画一行需要的字段;`sessionId` 是这一层会话自己的 id(不是 childId)。
3546
+ * @param {object|null|undefined} data `/api/compact-relay-chain` 的响应
3547
+ * @returns {Array<{sessionId:string, ordinal:number, baseTitle:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}>|null}
3548
+ */
3549
+ function relayChainRowsOf(data) {
3550
+ if (data === null || typeof data !== 'object' || data.ok !== true || !Array.isArray(data.rows)) return null;
3551
+ const rows = [];
3552
+ for (const row of data.rows) {
3553
+ if (row === null || typeof row !== 'object') continue;
3554
+ if (typeof row.sessionId !== 'string' || row.sessionId.length === 0) continue;
3555
+ const projection = relayChainProjectionOf({ ok: true, chain: row });
3556
+ if (projection === null) continue;
3557
+ rows.push({
3558
+ sessionId: row.sessionId,
3559
+ ordinal: projection.ordinal,
3560
+ baseTitle: projection.baseTitle,
3561
+ scheme: projection.scheme,
3562
+ path: projection.path,
3563
+ });
3564
+ }
3565
+ return rows;
3566
+ }
3567
+
3243
3568
  /** 接力条菜单里的一行(和指示灯菜单同一套视觉,但更轻)。 */
3244
3569
  function relayBarItem(label, sub, key, action, meta = {}) {
3245
3570
  const disabled = meta.disabled === true;
@@ -3302,6 +3627,11 @@ window.__ModuleLoader__.load({
3302
3627
  /**
3303
3628
  * 接力条本体(`conversation.input.dock`,输入卡上方一行)。
3304
3629
  *
3630
+ * 0.5.1 批 3 起这一行有两个方向:
3631
+ * - 入站(`chain`,子会话侧):「已接力自 历史 N:原名」+ 回到原会话 / 完整交接链;
3632
+ * - 出站(`next`,父会话侧):「已交接给新会话」+ 去新会话;
3633
+ * - 两个都有(中段会话):同时显示两个方向的入口。
3634
+ *
3305
3635
  * @param {object} props ctx.slots.register 注入的 props
3306
3636
  * @param {string} [props.sessionId] 当前会话 id(session scope)
3307
3637
  * @param {(sessionId:string)=>unknown} [props.openSession] 切到某个会话(ctx.uiWorkspace.openSession)
@@ -3314,8 +3644,30 @@ window.__ModuleLoader__.load({
3314
3644
  const unarchiveSession = props === null || props === undefined ? undefined : props.unarchiveSession;
3315
3645
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
3316
3646
  const [chain, setChain] = useState(null);
3647
+ // 0.5.1 批 3:出站投影(本会话把接力棒交给了谁)。父会话侧据此显示「去新会话」。
3648
+ const [next, setNext] = useState(null);
3317
3649
  // null = 宿主没给归档状态(老宿主);true/false = readState.parentArchived。
3318
3650
  const [parentArchived, setParentArchived] = useState(null);
3651
+ // 0.5.1 批 3:出站目标(next.childId)的归档状态(readState.nextArchived)。
3652
+ const [nextArchived, setNextArchived] = useState(null);
3653
+ // 0.5.1 DCR-03:服务端能不能恢复归档会话(readState.capabilities.unarchive)。
3654
+ // null = 老插件没有这个字段(不知道 → 仍显示入口,恢复调用幂等)。
3655
+ const [serverUnarchive, setServerUnarchive] = useState(null);
3656
+ // 0.5.2 L 项:服务端能不能强制归档(readState.capabilities.forceArchive)。
3657
+ // null = 老插件没有这个字段(不知道 → 仍显示入口,失败会给可读错误)。
3658
+ const [serverForceArchive, setServerForceArchive] = useState(null);
3659
+ // 0.5.2 L 项:旧会话(入站 parentId)是否在侧栏置顶(readState.parentPinned)。
3660
+ // 置顶时二次确认要提前说明「归档会同时取消置顶」。
3661
+ const [parentPinned, setParentPinned] = useState(null);
3662
+ // 0.5.2 L 项:强制归档面板 —— null = 关;否则
3663
+ // {phase:'confirm'|'busy'|'error'|'done', status?, note?, message?}。
3664
+ const [forceView, setForceView] = useState(null);
3665
+ // 0.5.1 DCR-03:打开菜单时 +1,触发一次静默刷新(别处归档/取消归档能立刻反映)。
3666
+ const [refreshKey, setRefreshKey] = useState(0);
3667
+ // 0.5.1 批 3:完整交接链(null = 没展开;点「完整交接链」后才逐级去读)。
3668
+ const [chainView, setChainView] = useState(null);
3669
+ // 上一次渲染时的 sessionId:只有真的换会话才清链(刷新不清,避免闪一下)。
3670
+ const lastSessionRef = useRef(null);
3319
3671
  const [open, setOpen] = useState(false);
3320
3672
  const [doc, setDoc] = useState(null);
3321
3673
  const [notice, setNotice] = useState(null);
@@ -3326,6 +3678,9 @@ window.__ModuleLoader__.load({
3326
3678
  const menuRef = useRef(null);
3327
3679
  const docRequestRef = useRef(0);
3328
3680
  const docAbortRef = useRef(null);
3681
+ // 完整链接力式的请求序号(关菜单/换会话/重试时让晚到的响应失效)与断点。
3682
+ const chainRequestRef = useRef(0);
3683
+ const chainResumeRef = useRef(null);
3329
3684
 
3330
3685
  /** 取消未完成的交接单读取;同时让晚到的响应失效(requestId 对不上)。 */
3331
3686
  const abortDoc = useCallback(() => {
@@ -3340,12 +3695,21 @@ window.__ModuleLoader__.load({
3340
3695
  }
3341
3696
  }, []);
3342
3697
 
3698
+ // 0.5.1 批 3:完整链的「收摊」——让晚到的逐级响应失效,并丢掉列表与断点。
3699
+ const closeChainView = useCallback(() => {
3700
+ chainRequestRef.current += 1;
3701
+ chainResumeRef.current = null;
3702
+ setChainView(null);
3703
+ }, []);
3704
+
3343
3705
  const closeMenu = useCallback(() => {
3344
3706
  abortDoc();
3707
+ closeChainView();
3345
3708
  setOpen(false);
3346
3709
  setDoc(null);
3347
3710
  setNotice(null);
3348
- }, [abortDoc]);
3711
+ setForceView(null);
3712
+ }, [abortDoc, closeChainView]);
3349
3713
 
3350
3714
  // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
3351
3715
  const backToMenu = useCallback(() => {
@@ -3357,19 +3721,37 @@ window.__ModuleLoader__.load({
3357
3721
  // null),这时要重试几次;一旦宿主确认「就是这个会话且没有 chain」就停,
3358
3722
  // 普通会话不会无限打接口。fetch 失败/老宿主没有 chain → setChain(null) → 不渲染。
3359
3723
  useEffect(() => {
3360
- // 换会话/没有会话时先清掉上一条链(dock 组件可能被复用而不是重挂)。
3361
- setChain(null);
3362
- setParentArchived(null);
3363
- setOpen(false);
3364
- setDoc(null);
3365
- setNotice(null);
3724
+ // 换会话/没有会话时才清掉上一条链(dock 组件可能被复用而不是重挂)。
3725
+ // 0.5.1 DCR-03:`refreshKey`(打开菜单后刷新)**不清链**——否则菜单一打开、
3726
+ // 接力条会先消失再出现。
3727
+ const sessionChanged = lastSessionRef.current !== sessionId;
3728
+ lastSessionRef.current = sessionId;
3729
+ if (sessionChanged) {
3730
+ setChain(null);
3731
+ setNext(null);
3732
+ setParentArchived(null);
3733
+ setNextArchived(null);
3734
+ setServerUnarchive(null);
3735
+ setServerForceArchive(null);
3736
+ setParentPinned(null);
3737
+ setForceView(null);
3738
+ setOpen(false);
3739
+ setDoc(null);
3740
+ setNotice(null);
3741
+ // 换会话:完整链的断点与结果都不再属于当前会话。
3742
+ chainRequestRef.current += 1;
3743
+ chainResumeRef.current = null;
3744
+ setChainView(null);
3745
+ }
3366
3746
  if (!hasSession) return undefined;
3367
3747
  let dead = false;
3368
3748
  let timer = 0;
3369
3749
  let attempts = 0;
3750
+ // 静默刷新(菜单打开时):链已经在显示,失败/未加载就不再排队重试,也不清链。
3751
+ const silent = sessionChanged === false;
3370
3752
  const url = `/api/compact-relay-state?sessionId=${encodeURIComponent(sessionId)}`;
3371
3753
  const schedule = () => {
3372
- if (dead) return;
3754
+ if (dead || silent) return;
3373
3755
  attempts += 1;
3374
3756
  if (attempts > 12) return;
3375
3757
  timer = setTimeout(() => {
@@ -3384,7 +3766,8 @@ window.__ModuleLoader__.load({
3384
3766
  if (dead || !res.ok) return;
3385
3767
  const parentData = await res.json().catch(() => null);
3386
3768
  if (dead) return;
3387
- const parentChain = relayChainProjectionOf(parentData);
3769
+ // 0.5.1 DCR-01:父会话的 chain 是它自己的入站记录,这一跳的出站在 next 里。
3770
+ const parentChain = relayOutboundProjectionOf(parentData);
3388
3771
  if (
3389
3772
  parentChain === null ||
3390
3773
  parentChain.childId !== sessionId ||
@@ -3412,21 +3795,38 @@ window.__ModuleLoader__.load({
3412
3795
  if (dead) return;
3413
3796
  if (data !== null && typeof data === 'object' && data.ok === false) return;
3414
3797
  const parsed = relayBarChainOf(data, sessionId);
3415
- if (parsed !== null) {
3798
+ // 0.5.1 批 3:父会话侧的出站目标。它和 chain 是两条独立的投影:子会话只有
3799
+ // chain,父会话只有 next,中段会话两个都有 —— 任一存在就要渲染接力条。
3800
+ const outbound = relayBarNextOf(data, sessionId);
3801
+ if (parsed !== null || outbound !== null) {
3416
3802
  setChain(parsed);
3803
+ setNext(outbound);
3417
3804
  setParentArchived(relayBarParentArchivedOf(data));
3805
+ setNextArchived(relayBarNextArchivedOf(data));
3806
+ setServerUnarchive(relayBarUnarchiveCapabilityOf(data));
3807
+ // 0.5.2 L 项:强制归档的能力位与旧会话置顶状态(二次确认文案用)。
3808
+ setServerForceArchive(relayBarForceArchiveCapabilityOf(data));
3809
+ setParentPinned(relayBarParentPinnedOf(data));
3418
3810
  // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3419
3811
  // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3420
3812
  // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
3421
- if (parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3813
+ if (parsed !== null && parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3422
3814
  return;
3423
3815
  }
3424
3816
  // sessionId 对不上/为 null → 宿主还没加载本会话,继续等;对得上 → 确定没有链。
3425
3817
  const loaded =
3426
3818
  data !== null && typeof data === 'object' && typeof data.sessionId === 'string' && data.sessionId === sessionId;
3427
3819
  if (loaded) {
3428
- setChain(null);
3429
- setParentArchived(null);
3820
+ // 静默刷新时宿主确认「没有链」也不清(保持在显示的接力条,避免误伤)。
3821
+ if (!silent) {
3822
+ setChain(null);
3823
+ setNext(null);
3824
+ setParentArchived(null);
3825
+ setNextArchived(null);
3826
+ setServerUnarchive(null);
3827
+ setServerForceArchive(null);
3828
+ setParentPinned(null);
3829
+ }
3430
3830
  } else schedule();
3431
3831
  } catch {
3432
3832
  /* 宿主不在/网络失败:保持「没有链」—— 不显示,不占高度;稍后再试 */
@@ -3438,7 +3838,7 @@ window.__ModuleLoader__.load({
3438
3838
  dead = true;
3439
3839
  if (timer !== 0) clearTimeout(timer);
3440
3840
  };
3441
- }, [hasSession, sessionId]);
3841
+ }, [hasSession, sessionId, refreshKey]);
3442
3842
 
3443
3843
  // 菜单打开时按这一行的真实位置重算锚点(滚动/缩放不漂)。
3444
3844
  useEffect(() => {
@@ -3515,8 +3915,15 @@ window.__ModuleLoader__.load({
3515
3915
  abortDoc();
3516
3916
  setNotice(null);
3517
3917
  setDoc(null);
3918
+ // 0.5.2 L 项:重开菜单一律回到主列表(强制归档确认面板不残留、不误触)。
3919
+ setForceView(null);
3920
+ // 每次开/关都把完整链收掉:重开菜单从主列表开始(不预加载,也不留旧会话的链)。
3921
+ closeChainView();
3922
+ // 0.5.1 DCR-03:打开菜单前刷新一次 state —— 归档集合可能在别处被改(侧栏
3923
+ // 取消归档 / 另一个窗口归档),沿用打开前的旧状态会让「恢复旧会话」变死入口。
3924
+ if (!open) setRefreshKey((key) => key + 1);
3518
3925
  setOpen((value) => !value);
3519
- }, [abortDoc]);
3926
+ }, [abortDoc, closeChainView, open]);
3520
3927
 
3521
3928
  const loadDoc = useCallback(async (name) => {
3522
3929
  if (typeof name !== 'string' || name.length === 0) return;
@@ -3558,129 +3965,603 @@ window.__ModuleLoader__.load({
3558
3965
  }
3559
3966
  }, []);
3560
3967
 
3561
- /** 直接切到父会话(不碰归档状态);成功发起返回 true。 */
3562
- const openParentNow = useCallback(() => {
3563
- if (chain === null || chain.parentId === null) return false;
3564
- if (typeof openSession !== 'function') {
3565
- setNotice('这个宿主不支持切换会话,无法自动打开原会话。');
3566
- return false;
3567
- }
3568
- try {
3569
- const result = openSession(chain.parentId);
3570
- if (result !== null && result !== undefined && typeof result.then === 'function') {
3571
- result.catch((error) => {
3572
- setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3573
- setOpen(true);
3574
- });
3968
+ /** 切到某个会话(不碰归档状态);成功发起返回 true。 */
3969
+ const openTarget = useCallback(
3970
+ (target, failureText) => {
3971
+ if (typeof openSession !== 'function') {
3972
+ setNotice('这个宿主不支持切换会话,无法自动打开会话。');
3973
+ setOpen(true);
3974
+ return false;
3575
3975
  }
3576
- return true;
3577
- } catch (error) {
3578
- setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3579
- return false;
3580
- }
3581
- }, [chain, openSession]);
3976
+ const failed = (error) => {
3977
+ setNotice(failureText + ':' + (error && error.message ? error.message : '未知错误'));
3978
+ setOpen(true);
3979
+ };
3980
+ try {
3981
+ const result = openSession(target);
3982
+ if (result !== null && result !== undefined && typeof result.then === 'function') {
3983
+ result.catch(failed);
3984
+ }
3985
+ setOpen(false);
3986
+ setDoc(null);
3987
+ closeChainView();
3988
+ return true;
3989
+ } catch (error) {
3990
+ failed(error);
3991
+ return false;
3992
+ }
3993
+ },
3994
+ [closeChainView, openSession],
3995
+ );
3582
3996
 
3583
3997
  /**
3584
- * 0.5.0:恢复归档的父会话并切过去。
3998
+ * 0.5.0 / 0.5.1 DCR-03:恢复归档会话并切过去(父会话、完整链上任意一层、下一代都走这里)。
3999
+ *
4000
+ * 两条通道:
4001
+ * 1. 宿主 Client 侧 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」同一条
4002
+ * 接缝,会同步更新本地归档集合);
4003
+ * 2. 本插件 HTTP `action:'recover'`(服务端 workspaceRegistry.unarchiveSession)——
4004
+ * 老宿主没有客户端 API 时的兜底。
4005
+ * 失败重试:一轮 unarchive+open 失败后换另一条通道再试一次(只有一条通道时同通道
4006
+ * 重试),仍失败才提示错误。unarchive 幂等:重试不会把会话再归档,未归档时是 no-op。
3585
4007
  *
3586
- * 优先用宿主 Client 侧的 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」
3587
- * 同一条接缝,会同步更新本地归档集合);老宿主没有时走本插件 HTTP
3588
- * `action:'recover'`(服务端 workspaceRegistry.unarchiveSession),成功后同样尝试切换。
3589
- * 恢复本身幂等:父会话没归档时宿主 no-op,仍然可以直接打开。
4008
+ * @param {string} target 目标会话 id
4009
+ * @param {{markParent?:boolean, failureText?:string, restoredText?:string}} [options]
4010
+ * markParent:成功后把本地「父会话归档状态」改成未归档(只有父会话需要)。
3590
4011
  */
3591
- const restoreParent = useCallback(() => {
3592
- setNotice(null);
3593
- if (chain === null || chain.parentId === null) return undefined;
3594
- const target = chain.parentId;
3595
- return (async () => {
3596
- try {
3597
- if (typeof unarchiveSession === 'function') {
3598
- await unarchiveSession(target);
3599
- } else {
3600
- const res = await fetch('/api/compact-relay-action', {
3601
- method: 'POST',
3602
- headers: { 'content-type': 'application/json' },
3603
- cache: 'no-store',
3604
- body: JSON.stringify({ action: 'recover', sessionId: target }),
3605
- });
3606
- const data = await res.json().catch(() => null);
3607
- if (!res.ok || data === null || data.ok === false) {
3608
- throw new Error(data !== null && typeof data.message === 'string' ? data.message : '恢复接口不可用');
3609
- }
4012
+ /**
4013
+ * 恢复归档会话的两条通道(0.5.1 DCR-03 抽出,DCR-05 的「打开并继续」复用)。
4014
+ *
4015
+ * 1. 宿主 Client 侧 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」同一条
4016
+ * 接缝,会同步更新本地归档集合);
4017
+ * 2. 本插件 HTTP `action:'recover'`(服务端 workspaceRegistry.unarchiveSession)——
4018
+ * 老宿主没有客户端 API 时的兜底。
4019
+ *
4020
+ * 返回按优先级排好的尝试序列;调用方逐个 await,失败换下一条。
4021
+ * @param {string} target 目标会话 id
4022
+ * @returns {Array<() => Promise<unknown>>}
4023
+ */
4024
+ const unarchiveChannels = useCallback(
4025
+ (target) => {
4026
+ const unarchiveViaHost = async () => {
4027
+ if (typeof unarchiveSession !== 'function') throw new Error('这个宿主没有 unarchiveSession');
4028
+ await unarchiveSession(target);
4029
+ };
4030
+ const unarchiveViaHttp = async () => {
4031
+ const res = await fetch('/api/compact-relay-action', {
4032
+ method: 'POST',
4033
+ headers: { 'content-type': 'application/json' },
4034
+ cache: 'no-store',
4035
+ body: JSON.stringify({ action: 'recover', sessionId: target }),
4036
+ });
4037
+ const data = await res.json().catch(() => null);
4038
+ if (!res.ok || data === null || data.ok === false) {
4039
+ throw new Error(data !== null && typeof data.message === 'string' ? data.message : '恢复接口不可用');
3610
4040
  }
3611
- setParentArchived(false);
4041
+ };
4042
+ // 通道顺序:宿主 API 优先;只有一条通道时同通道重试(「失败重试 unarchive+open」)。
4043
+ return typeof unarchiveSession === 'function'
4044
+ ? [unarchiveViaHost, unarchiveViaHttp]
4045
+ : [unarchiveViaHttp, unarchiveViaHttp];
4046
+ },
4047
+ [unarchiveSession],
4048
+ );
4049
+
4050
+ const restoreAndOpen = useCallback(
4051
+ (target, options = {}) => {
4052
+ const markParent = options.markParent === true;
4053
+ const failureText = typeof options.failureText === 'string' ? options.failureText : '恢复旧会话失败';
4054
+ const restoredText =
4055
+ typeof options.restoredText === 'string'
4056
+ ? options.restoredText
4057
+ : '旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。';
4058
+ setNotice(null);
4059
+
4060
+ const channels = unarchiveChannels(target);
4061
+
4062
+ // 一轮 = unarchive 成功后再 open。open 失败也走下一轮(可能恢复没生效/宿主还没刷新)。
4063
+ const attempt = async (unarchive) => {
4064
+ await unarchive();
4065
+ if (markParent) setParentArchived(false);
3612
4066
  if (typeof openSession !== 'function') {
3613
- setNotice('旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。');
4067
+ setNotice(restoredText);
3614
4068
  setOpen(true);
3615
4069
  return;
3616
4070
  }
3617
- const result = openSession(target);
3618
- if (result !== null && result !== undefined && typeof result.then === 'function') {
3619
- result.catch((error) => {
3620
- setNotice('已恢复旧会话,但自动切换失败:' + (error && error.message ? error.message : '未知错误'));
3621
- setOpen(true);
3622
- });
3623
- }
4071
+ await openSession(target);
3624
4072
  setOpen(false);
3625
4073
  setDoc(null);
3626
- } catch (error) {
3627
- setNotice('恢复旧会话失败:' + (error && error.message ? error.message : '未知错误'));
4074
+ closeChainView();
4075
+ };
4076
+
4077
+ return (async () => {
4078
+ let failure = null;
4079
+ for (const channel of channels) {
4080
+ try {
4081
+ await attempt(channel);
4082
+ return;
4083
+ } catch (error) {
4084
+ failure = error;
4085
+ }
4086
+ }
4087
+ setNotice(failureText + ':' + (failure && failure.message ? failure.message : '未知错误'));
3628
4088
  setOpen(true);
3629
- }
3630
- })();
3631
- }, [chain, openSession, unarchiveSession]);
4089
+ })();
4090
+ },
4091
+ [closeChainView, openSession, unarchiveChannels],
4092
+ );
4093
+
4094
+ // 0.5.1 DCR-03:能不能恢复 = 客户端有宿主 API,或服务端说它有 registry.unarchiveSession。
4095
+ // serverUnarchive === null(老插件没有 capabilities 字段)按「可尝试」处理(recover 幂等)。
4096
+ const canRestore = typeof unarchiveSession === 'function' || serverUnarchive !== false;
3632
4097
 
3633
4098
  const goParent = useCallback(() => {
3634
4099
  setNotice(null);
3635
4100
  if (chain === null || chain.parentId === null) return;
3636
- // 已归档 → 先恢复再打开(否则宿主会拒绝打开归档会话)。
3637
- if (parentArchived === true) {
3638
- void restoreParent();
4101
+ // 已归档(true)或宿主没给归档状态(null):都不能假设「未归档」直接打开 ——
4102
+ // 打开被归档的会话会被宿主拒绝,这个入口就成了死路(DCR-03)。
4103
+ // 有恢复能力时先 unarchive 再 open(unarchive 幂等,未归档时是 no-op)。
4104
+ if (parentArchived !== false && canRestore) {
4105
+ void restoreAndOpen(chain.parentId, { markParent: true });
3639
4106
  return;
3640
4107
  }
3641
- if (openParentNow()) {
3642
- setOpen(false);
3643
- setDoc(null);
3644
- }
3645
- }, [chain, openParentNow, parentArchived, restoreParent]);
4108
+ openTarget(chain.parentId, '打不开原会话');
4109
+ }, [canRestore, chain, openTarget, parentArchived, restoreAndOpen]);
3646
4110
 
3647
- // 无链 → 什么都不渲染(不占 composerStack 的高度)。
3648
- if (chain === null) return null;
4111
+ /**
4112
+ * 0.5.1 批 3(TD-01):父会话侧「去新会话」—— 用出站记录的目标 childId 切过去。
4113
+ *
4114
+ * 下一代可能已经被用户归档(`nextArchived`),宿主同样拒绝打开归档会话,所以
4115
+ * 和「回到原会话」同口径:归档/不知道就先 unarchive 再 open(unarchive 幂等)。
4116
+ */
4117
+ const goNext = useCallback(() => {
4118
+ setNotice(null);
4119
+ if (next === null || next.childId === null) return;
4120
+ if (nextArchived !== false && canRestore) {
4121
+ void restoreAndOpen(next.childId, {
4122
+ failureText: '恢复新会话失败',
4123
+ restoredText: '新会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。',
4124
+ });
4125
+ return;
4126
+ }
4127
+ openTarget(next.childId, '打不开新会话');
4128
+ }, [canRestore, next, nextArchived, openTarget, restoreAndOpen]);
4129
+
4130
+ /** 完整链某一层:跳过去(旧会话默认已归档,同口径先恢复再切)。 */
4131
+ const openAncestor = useCallback(
4132
+ (target) => {
4133
+ if (typeof target !== 'string' || target.length === 0) return;
4134
+ setNotice(null);
4135
+ if (canRestore) {
4136
+ void restoreAndOpen(target, {
4137
+ failureText: '打不开旧会话',
4138
+ restoredText: '旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。',
4139
+ });
4140
+ return;
4141
+ }
4142
+ openTarget(target, '打不开旧会话');
4143
+ },
4144
+ [canRestore, openTarget, restoreAndOpen],
4145
+ );
3649
4146
 
3650
- const label = relayBarLabelOf(chain);
3651
- const docName = relayBarDocNameOf(chain.path);
3652
- const hasParent = chain.parentId !== null;
3653
- // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
3654
- // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
3655
- const showRestore = hasParent && parentArchived !== false;
3656
- const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
3657
- const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
3658
- const menuWidth = Math.min(doc === null ? 272 : 420, Math.max(240, vw - 16));
3659
- const estimatedHeight = doc === null ? 168 : 360;
3660
- const anchorTop = anchor === null ? vh - 96 : anchor.top;
3661
- const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
3662
- const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
3663
- const left = Math.max(8, Math.min(anchor === null ? 8 : anchor.left, Math.max(8, vw - menuWidth - 8)));
3664
- const position = openUp
3665
- ? { left, bottom: Math.max(8, vh - anchorTop + 6) }
3666
- : { left, top: Math.min(Math.max(8, anchorBottom + 6), Math.max(8, vh - Math.min(estimatedHeight, vh - 16) - 8)) };
4147
+ /**
4148
+ * 0.5.1 DCR-05:优先让服务端一次读出整条链(只读持久化日志,不加载祖先会话)。
4149
+ *
4150
+ * 返回 true = 端点可用且已把结果写进 chainView(成功或带 unreadable 的部分结果);
4151
+ * false = 老插件/老宿主没有这个端点、或响应形状不对 —— 调用方回落到旧的逐级读法。
4152
+ *
4153
+ * 为什么能解决「只显示 1 层」:宿主对未加载的会话返回 `sessionId:null`,旧逐级
4154
+ * GET 只能停住;新端点从持久化 header/log 里读,祖先没打开也能列全。
4155
+ *
4156
+ * @param {number} requestId 与 chainRequestRef 对齐的请求序号(晚到响应作废)
4157
+ * @returns {Promise<boolean>}
4158
+ */
4159
+ const loadChainViaServer = useCallback(
4160
+ async (requestId) => {
4161
+ let res = null;
4162
+ let data = null;
4163
+ try {
4164
+ res = await fetch(`${RELAY_CHAIN_ENDPOINT}?sessionId=${encodeURIComponent(sessionId)}`, {
4165
+ cache: 'no-store',
4166
+ });
4167
+ if (res.ok) data = await res.json().catch(() => null);
4168
+ } catch {
4169
+ res = null;
4170
+ data = null;
4171
+ }
4172
+ if (requestId !== chainRequestRef.current) return true;
4173
+ if (res === null || !res.ok || data === null || typeof data !== 'object' || data.ok !== true) return false;
4174
+ const rows = relayChainRowsOf(data);
4175
+ if (rows === null) return false;
4176
+ const unreadable = data.unreadable;
4177
+ const unreadableId =
4178
+ unreadable !== null && typeof unreadable === 'object' && typeof unreadable.sessionId === 'string'
4179
+ ? unreadable.sessionId
4180
+ : null;
4181
+ // not-found = 持久化里根本没有这个会话(已删除/从未落盘):打开也救不回来,
4182
+ // 只给「重试」,不造一个必然失败的入口(与 DCR-03 隐藏死入口同口径)。
4183
+ // error = 读日志临时失败:会话可能只是没加载/没落盘,允许「打开并继续」。
4184
+ const blocked =
4185
+ unreadableId !== null && unreadable.reason !== 'not-found'
4186
+ ? { sessionId: unreadableId, reason: 'error' }
4187
+ : null;
4188
+ // 服务端已经一次读完:这里没有逐级断点,重试就是重发一次整链请求。
4189
+ chainResumeRef.current = null;
4190
+ setChainView({
4191
+ rows,
4192
+ loading: false,
4193
+ error: unreadableId === null ? null : '无法读取更早的链路',
4194
+ done: unreadableId === null && data.done !== false,
4195
+ capped: data.capped === true,
4196
+ blocked,
4197
+ });
4198
+ return true;
4199
+ },
4200
+ [sessionId],
4201
+ );
3667
4202
 
3668
- const docPanel =
3669
- doc === null
3670
- ? null
3671
- : h(
3672
- 'div',
3673
- { role: 'dialog', 'aria-label': '交接单', 'data-cr-relay-doc': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
3674
- h(
3675
- 'div',
3676
- {
3677
- style: {
3678
- display: 'flex',
3679
- alignItems: 'center',
3680
- gap: 8,
3681
- padding: '7px 10px',
3682
- borderBottom: `1px solid ${theme.panelBorder}`,
3683
- fontSize: 12,
4203
+ /**
4204
+ * 0.5.1 批 3 / DCR-05:完整交接链 —— **按需**读祖先会话(不预加载、无常驻轮询)。
4205
+ *
4206
+ * 先走一次整链端点(见上);老宿主没有端点时回落逐级 state 读法:
4207
+ * 每一行来自「下一层(子)会话的入站 chain」,ordinal/baseTitle 正好是那一层会话
4208
+ * 改名后的「历史 N:原名」;交接单路径优先用它自己记录里的 path,缺了再向那一层
4209
+ * 会话的 state 要出站 next 的 path(和接力条补 path 同口径,childId/链号都要对得上)。
4210
+ *
4211
+ * 频率口径:点开「完整交接链」后才开始拉;回落路径每层一次 state GET(不并发、不轰炸);
4212
+ * 最多 `MAX_CHAIN_HOPS` 跳;`seen` 按 sessionId 去重(链路万一成环也不会转圈)。
4213
+ * 某层读不到就停在断点:已读到的行保留;未加载给「打开并继续」,其它失败给「重试」。
4214
+ *
4215
+ * @param {{cursor:object|null, childId:string, rows:Array, seen:Set<string>}|null} resume
4216
+ * 断点;null/undefined = 从本会话重新开始(先试整链端点)。
4217
+ */
4218
+ const loadChain = useCallback(
4219
+ async (resume) => {
4220
+ const requestId = ++chainRequestRef.current;
4221
+ // 0.5.1 DCR-05:先试一次整链端点;老宿主没有就回落逐级 state(下面的旧逻辑)。
4222
+ if (resume === undefined || resume === null) {
4223
+ const handled = await loadChainViaServer(requestId);
4224
+ if (handled) return;
4225
+ if (requestId !== chainRequestRef.current) return;
4226
+ }
4227
+ const start =
4228
+ resume === undefined || resume === null
4229
+ ? {
4230
+ cursor: chain === null ? null : { ...chain },
4231
+ childId: sessionId,
4232
+ rows: [],
4233
+ seen: new Set(typeof sessionId === 'string' && sessionId.length > 0 ? [sessionId] : []),
4234
+ }
4235
+ : resume;
4236
+ let cursor = start.cursor;
4237
+ let childId = start.childId;
4238
+ let seen = start.seen;
4239
+ let rows = start.rows;
4240
+ const publish = (patch) => {
4241
+ if (requestId !== chainRequestRef.current) return;
4242
+ setChainView({ rows, loading: true, error: null, done: false, capped: false, blocked: null, ...patch });
4243
+ };
4244
+ publish({});
4245
+ while (cursor !== null && cursor.parentId !== null) {
4246
+ if (rows.length >= MAX_CHAIN_HOPS) {
4247
+ publish({ loading: false, done: true, capped: true });
4248
+ return;
4249
+ }
4250
+ const parentId = cursor.parentId;
4251
+ if (seen.has(parentId)) {
4252
+ // 链路成环/自指:到此为止,不重复列同一条会话。
4253
+ publish({ loading: false, done: true, capped: false });
4254
+ return;
4255
+ }
4256
+ let data = null;
4257
+ try {
4258
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(parentId)}`, {
4259
+ cache: 'no-store',
4260
+ });
4261
+ if (requestId !== chainRequestRef.current) return;
4262
+ // 4xx(鉴权/端点不存在)与 5xx 一样当作「这一层读不到」:不再自动重试,
4263
+ // 交给用户的「重试」入口(自动重试只留给接力条自己那次 state)。
4264
+ if (res.ok) data = await res.json().catch(() => null);
4265
+ } catch {
4266
+ data = null;
4267
+ }
4268
+ if (requestId !== chainRequestRef.current) return;
4269
+ // 读不到 = HTTP/解析失败,或者**宿主还没把这一层加载进内存**(`sessionId` 对不上,
4270
+ // 重启后祖先会话通常就是这样 —— 不能把「没加载」当成「没有父会话」到头了)。
4271
+ // 两种都停在断点:已读到的行保留。宿主明确回 sessionId:null 时给出可操作入口
4272
+ // 「上一层会话未打开 → 打开并继续」(DCR-05 兜底);其它失败仍只给「重试」。
4273
+ const loaded =
4274
+ data !== null &&
4275
+ typeof data === 'object' &&
4276
+ data.ok !== false &&
4277
+ typeof data.sessionId === 'string' &&
4278
+ data.sessionId === parentId;
4279
+ if (!loaded) {
4280
+ chainResumeRef.current = { cursor, childId, rows, seen };
4281
+ const notLoaded =
4282
+ data !== null && typeof data === 'object' && data.ok !== false && data.sessionId === null;
4283
+ publish({
4284
+ loading: false,
4285
+ error: notLoaded ? '上一层会话未打开' : '无法读取更早的链路',
4286
+ done: false,
4287
+ blocked: notLoaded ? { sessionId: parentId, reason: 'unloaded' } : null,
4288
+ });
4289
+ return;
4290
+ }
4291
+ const outbound = relayBarNextOf(data, parentId);
4292
+ const path =
4293
+ cursor.path !== null
4294
+ ? cursor.path
4295
+ : outbound !== null && outbound.childId === childId && outbound.ordinal === cursor.ordinal
4296
+ ? outbound.path
4297
+ : null;
4298
+ rows = [
4299
+ ...rows,
4300
+ { sessionId: parentId, ordinal: cursor.ordinal, baseTitle: cursor.baseTitle, scheme: cursor.scheme, path },
4301
+ ];
4302
+ seen = new Set([...seen, parentId]);
4303
+ publish({});
4304
+ childId = parentId;
4305
+ // 这一层自己的入站 chain 才是「更早的链路」;没有就说明到头了。
4306
+ cursor = relayChainProjectionOf(data);
4307
+ }
4308
+ chainResumeRef.current = null;
4309
+ publish({ loading: false, done: true, capped: false });
4310
+ },
4311
+ [chain, loadChainViaServer, sessionId],
4312
+ );
4313
+
4314
+ const openChainList = useCallback(() => {
4315
+ setNotice(null);
4316
+ setDoc(null);
4317
+ chainResumeRef.current = null;
4318
+ void loadChain(null);
4319
+ }, [loadChain]);
4320
+
4321
+ /** 某层读不到之后的「重试」:从断点继续,已读到的层不重复拉。 */
4322
+ const retryChain = useCallback(() => {
4323
+ const resume = chainResumeRef.current;
4324
+ if (resume === null || resume === undefined) {
4325
+ void loadChain(null);
4326
+ return;
4327
+ }
4328
+ void loadChain(resume);
4329
+ }, [loadChain]);
4330
+
4331
+ /**
4332
+ * 0.5.1 DCR-05 兜底:等宿主把刚刚 openSession 的会话装进内存。
4333
+ *
4334
+ * 只读 `/api/compact-relay-state?sessionId=`,`sessionId` 对得上才算加载完成;
4335
+ * 最多 8 次(约 1 秒)。宿主打开会话需要一点时间,立刻续读会再次撞上「未加载」。
4336
+ * @param {string} target 刚打开的会话 id
4337
+ * @returns {Promise<boolean>}
4338
+ */
4339
+ const waitForSessionLoaded = useCallback(async (target) => {
4340
+ for (let attempt = 0; attempt < 8; attempt += 1) {
4341
+ try {
4342
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(target)}`, {
4343
+ cache: 'no-store',
4344
+ });
4345
+ if (res.ok) {
4346
+ const data = await res.json().catch(() => null);
4347
+ if (
4348
+ data !== null &&
4349
+ typeof data === 'object' &&
4350
+ data.ok !== false &&
4351
+ data.sessionId === target
4352
+ ) {
4353
+ return true;
4354
+ }
4355
+ }
4356
+ } catch {
4357
+ /* 宿主还没加载:继续等 */
4358
+ }
4359
+ if (attempt < 7) await new Promise((resolve) => setTimeout(resolve, 120));
4360
+ }
4361
+ return false;
4362
+ }, []);
4363
+
4364
+ /**
4365
+ * 0.5.1 DCR-05 兜底:断点处「打开并继续」。
4366
+ *
4367
+ * 1. 双通道 unarchive(复用现有;幂等,未归档时 no-op,失败也继续尝试 open);
4368
+ * 2. `openSession(parentId)` 打开上一层会话;
4369
+ * 3. 等宿主加载后自动 `loadChain(resume)`;加载不上就提示「已打开,再点完整交接链继续」。
4370
+ *
4371
+ * 注意:真实宿主打开会话会切路由,组件可能被卸载——此时后续 setState 是 no-op,
4372
+ * 不会报错;用户在新会话里点「完整交接链」也能接着看。
4373
+ */
4374
+ const openAndResumeChain = useCallback(async () => {
4375
+ const blocked = chainView === null ? null : chainView.blocked;
4376
+ if (blocked === null || blocked === undefined || typeof blocked.sessionId !== 'string') return;
4377
+ const target = blocked.sessionId;
4378
+ const resume = chainResumeRef.current;
4379
+ setNotice(null);
4380
+ setChainView((previous) =>
4381
+ previous === null ? previous : { ...previous, loading: true, error: null, blocked: null },
4382
+ );
4383
+
4384
+ // 先恢复(两条通道都试;都失败也不阻塞打开——也许本来就没归档)。
4385
+ for (const channel of unarchiveChannels(target)) {
4386
+ try {
4387
+ await channel();
4388
+ break;
4389
+ } catch {
4390
+ /* 换下一条通道;仍失败就直接尝试打开 */
4391
+ }
4392
+ }
4393
+
4394
+ if (typeof openSession !== 'function') {
4395
+ setNotice('这个宿主不支持切换会话,无法自动打开上一层会话。');
4396
+ setChainView((previous) =>
4397
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4398
+ );
4399
+ return;
4400
+ }
4401
+ try {
4402
+ await openSession(target);
4403
+ } catch (error) {
4404
+ setNotice('打开上一层会话失败:' + (error && error.message ? error.message : '未知错误'));
4405
+ setChainView((previous) =>
4406
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4407
+ );
4408
+ return;
4409
+ }
4410
+
4411
+ if (!(await waitForSessionLoaded(target))) {
4412
+ setNotice('已打开上一层会话,再点「完整交接链」继续。');
4413
+ setChainView((previous) =>
4414
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4415
+ );
4416
+ return;
4417
+ }
4418
+ await loadChain(resume);
4419
+ }, [chainView, loadChain, openSession, unarchiveChannels, waitForSessionLoaded]);
4420
+
4421
+ /** 从完整链返回主菜单(保留断点?不:回主菜单再从主菜单进来就是重新拉一遍)。 */
4422
+ const backFromChain = useCallback(() => {
4423
+ closeChainView();
4424
+ }, [closeChainView]);
4425
+
4426
+ // 两个方向都没有 → 什么都不渲染(不占 composerStack 的高度)。
4427
+ if (chain === null && next === null) return null;
4428
+
4429
+ // ------------------------------------------------------------------
4430
+ // 0.5.2 L 项:强制归档旧会话(显式二次确认;唯一会 stopActivity 的用户入口)
4431
+ // ------------------------------------------------------------------
4432
+
4433
+ /**
4434
+ * 打开强制归档的二次确认面板(只改本地视图,不发请求)。
4435
+ *
4436
+ * 默认行为不变:自动归档遇到活跃会话仍然「保留可见不归档」;只有用户在这个
4437
+ * 确认面板里再点一次「强制归档(停止任务)」才会调用服务端。
4438
+ * @param {string} target 旧会话 id(入站 parentId)
4439
+ */
4440
+ const openForceConfirm = useCallback(
4441
+ (target) => {
4442
+ if (typeof target !== 'string' || target.length === 0) return;
4443
+ abortDoc();
4444
+ setNotice(null);
4445
+ setDoc(null);
4446
+ closeChainView();
4447
+ setForceView({ phase: 'confirm', target });
4448
+ },
4449
+ [abortDoc, closeChainView],
4450
+ );
4451
+
4452
+ /**
4453
+ * 执行强制归档:POST `action:'force-archive'`(服务端 → registry.archiveSession(id,{stopActivity:true}))。
4454
+ *
4455
+ * 只有用户点过确认才会走到这里。成功时复用 `handoffArchiveNoteOf` 的现有三套文案
4456
+ * (archived / already / pinned,含「归档会取消置顶」的置顶提示);失败时可读错误
4457
+ * 写进面板,不静默。`pinned` 不是失败:它表示宿主里旧会话其实还置顶、需要用户
4458
+ * 再确认一次「归档并取消置顶」,面板会给出这个出口。
4459
+ *
4460
+ * @param {string} target 旧会话 id(入站 parentId)
4461
+ * @param {{confirmUnpin?:boolean}} [options] true = 用户已确认「归档会取消置顶」
4462
+ */
4463
+ const runForceArchive = useCallback((target, options = {}) => {
4464
+ const confirmUnpin = options.confirmUnpin === true;
4465
+ if (typeof target !== 'string' || target.length === 0) return Promise.resolve();
4466
+ setNotice(null);
4467
+ setForceView({ phase: 'busy', target, confirmUnpin });
4468
+ return (async () => {
4469
+ try {
4470
+ const res = await fetch('/api/compact-relay-action', {
4471
+ method: 'POST',
4472
+ headers: { 'content-type': 'application/json' },
4473
+ cache: 'no-store',
4474
+ body: JSON.stringify({
4475
+ action: 'force-archive',
4476
+ sessionId: target,
4477
+ // 二次确认的凭证:服务端没有 confirmStop:true 一律拒绝(不会静默停任务)。
4478
+ confirmStop: true,
4479
+ ...(confirmUnpin ? { confirmUnpin: true } : {}),
4480
+ }),
4481
+ });
4482
+ const data = await res.json().catch(() => null);
4483
+ if (!res.ok || data === null || typeof data !== 'object' || data.ok === false) {
4484
+ const message =
4485
+ data !== null && typeof data === 'object' && typeof data.message === 'string' && data.message.length > 0
4486
+ ? data.message
4487
+ : '强制归档接口不可用';
4488
+ throw new Error(message);
4489
+ }
4490
+ // archived / already 都意味着旧会话已在归档集合里(already 幂等,没有重复停任务)。
4491
+ if (data.status === 'archived' || data.status === 'already') setParentArchived(true);
4492
+ setForceView({
4493
+ phase: 'done',
4494
+ target,
4495
+ status: typeof data.status === 'string' ? data.status : 'archived',
4496
+ note:
4497
+ typeof data.note === 'string' && data.note.length > 0
4498
+ ? data.note
4499
+ : '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。',
4500
+ });
4501
+ } catch (error) {
4502
+ setForceView({
4503
+ phase: 'error',
4504
+ target,
4505
+ confirmUnpin,
4506
+ message: '强制归档失败:' + (error && error.message ? error.message : '未知错误'),
4507
+ });
4508
+ }
4509
+ })();
4510
+ }, []);
4511
+
4512
+ /** 从确认/错误/结果面板回到主菜单列表(不关整个菜单,用户可继续用其他入口)。 */
4513
+ const backFromForce = useCallback(() => {
4514
+ setNotice(null);
4515
+ setForceView(null);
4516
+ }, []);
4517
+
4518
+
4519
+ const hasParent = chain !== null && chain.parentId !== null;
4520
+ const hasNext = next !== null && next.childId !== null;
4521
+ // 父会话侧(只有出站记录)这一行的摘要文案:它没有「已接力自」的来路。
4522
+ const label = chain !== null ? relayBarLabelOf(chain) : '已交接给新会话';
4523
+ // 「查看交接单」:子会话侧看进站那一跳,父会话侧看出站那一跳(不混用)。
4524
+ const docPath = chain !== null ? chain.path : next === null ? null : next.path;
4525
+ const docName = relayBarDocNameOf(docPath);
4526
+ // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
4527
+ // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
4528
+ // 0.5.1 DCR-03:宿主客户端与服务端都没有恢复能力时**隐藏**入口(点了必然失败)。
4529
+ const showRestore = hasParent && parentArchived !== false && canRestore;
4530
+ // 0.5.2 L 项:强制归档入口 —— 旧会话**还没归档**时才需要它(已归档没什么可强制的);
4531
+ // 服务端明确说没有 archiveSession 能力(false)时隐藏死入口;null(老插件没字段)
4532
+ // 仍显示,失败时会给出可读错误。
4533
+ const canForceArchive = serverForceArchive !== false;
4534
+ const showForceArchive = hasParent && parentArchived !== true && canForceArchive;
4535
+ const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
4536
+ const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
4537
+ const chainOpen = chainView !== null && doc === null;
4538
+ const forceOpen = forceView !== null && doc === null && chainView === null;
4539
+ const menuWidth = Math.min(doc !== null ? 420 : chainOpen ? 336 : forceOpen ? 320 : 272, Math.max(240, vw - 16));
4540
+ const estimatedHeight = doc !== null ? 360 : chainOpen ? 380 : forceOpen ? 300 : 168;
4541
+ const anchorTop = anchor === null ? vh - 96 : anchor.top;
4542
+ const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
4543
+ const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
4544
+ const left = Math.max(8, Math.min(anchor === null ? 8 : anchor.left, Math.max(8, vw - menuWidth - 8)));
4545
+ const position = openUp
4546
+ ? { left, bottom: Math.max(8, vh - anchorTop + 6) }
4547
+ : { left, top: Math.min(Math.max(8, anchorBottom + 6), Math.max(8, vh - Math.min(estimatedHeight, vh - 16) - 8)) };
4548
+
4549
+ const docPanel =
4550
+ doc === null
4551
+ ? null
4552
+ : h(
4553
+ 'div',
4554
+ { role: 'dialog', 'aria-label': '交接单', 'data-cr-relay-doc': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
4555
+ h(
4556
+ 'div',
4557
+ {
4558
+ style: {
4559
+ display: 'flex',
4560
+ alignItems: 'center',
4561
+ gap: 8,
4562
+ padding: '7px 10px',
4563
+ borderBottom: `1px solid ${theme.panelBorder}`,
4564
+ fontSize: 12,
3684
4565
  fontWeight: 600,
3685
4566
  },
3686
4567
  },
@@ -3717,6 +4598,405 @@ window.__ModuleLoader__.load({
3717
4598
  ),
3718
4599
  );
3719
4600
 
4601
+ /**
4602
+ * 完整链的一行:整行点「跳到这一层会话」,行尾单独一个「交接单」入口。
4603
+ *
4604
+ * 交接单路径只认 handoff-*.md 的 basename(与接力条同一套校验;接口只收
4605
+ * basename),没有 path 就禁用——不给必然失败的入口。
4606
+ * @param {{sessionId:string, ordinal:number|null, baseTitle:string|null, scheme:string|null, path:string|null}} row
4607
+ */
4608
+ const chainRowItemOf = (row) => {
4609
+ const rowDoc = relayBarDocNameOf(row.path);
4610
+ return h(
4611
+ 'div',
4612
+ {
4613
+ key: row.sessionId,
4614
+ 'data-cr-relay-chain-row': row.sessionId,
4615
+ style: { display: 'flex', alignItems: 'stretch', gap: 6, borderRadius: 8 },
4616
+ },
4617
+ h(
4618
+ 'button',
4619
+ {
4620
+ type: 'button',
4621
+ 'data-cr-relay-chain-open': row.sessionId,
4622
+ title: `打开 ${relayBarLabelOf(row)}`,
4623
+ onClick: () => openAncestor(row.sessionId),
4624
+ style: {
4625
+ flex: 1,
4626
+ minWidth: 0,
4627
+ display: 'flex',
4628
+ flexDirection: 'column',
4629
+ gap: 1,
4630
+ padding: '5px 8px',
4631
+ border: 'none',
4632
+ borderRadius: 8,
4633
+ background: 'transparent',
4634
+ textAlign: 'left',
4635
+ color: 'var(--dsw-alias-label-primary, inherit)',
4636
+ font: 'inherit',
4637
+ cursor: 'pointer',
4638
+ },
4639
+ onMouseEnter: (event) => {
4640
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
4641
+ },
4642
+ onMouseLeave: (event) => {
4643
+ event.currentTarget.style.background = 'transparent';
4644
+ },
4645
+ },
4646
+ h('span', { style: { fontSize: 12, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, relayBarLabelOf(row)),
4647
+ h(
4648
+ 'span',
4649
+ { style: { fontSize: 10, lineHeight: '14px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
4650
+ '打开这个会话(已归档会自动恢复)',
4651
+ ),
4652
+ ),
4653
+ h(
4654
+ 'button',
4655
+ {
4656
+ type: 'button',
4657
+ 'data-cr-relay-chain-doc': row.sessionId,
4658
+ disabled: rowDoc === null,
4659
+ title: rowDoc === null ? '暂无交接单:这一层没有记录交接单路径' : '打开这一跳的交接单',
4660
+ onClick: () => {
4661
+ if (rowDoc !== null) void loadDoc(rowDoc);
4662
+ },
4663
+ style: {
4664
+ flex: 'none',
4665
+ alignSelf: 'center',
4666
+ border: `1px solid ${theme.panelBorder}`,
4667
+ borderRadius: 6,
4668
+ padding: '3px 8px',
4669
+ fontSize: 11,
4670
+ background: 'transparent',
4671
+ color: 'inherit',
4672
+ cursor: rowDoc === null ? 'default' : 'pointer',
4673
+ opacity: rowDoc === null ? 0.55 : 1,
4674
+ },
4675
+ },
4676
+ '交接单',
4677
+ ),
4678
+ );
4679
+ };
4680
+
4681
+ /**
4682
+ * 完整交接链面板(点「完整交接链」后才出现;doc === null 时替换主列表)。
4683
+ *
4684
+ * 数据是按需逐级读来的(见 loadChain):读取中显示进度行,读不到显示错误 +
4685
+ * 重试,到 8 跳上限显示「只显示最近 8 层」。「← 返回」回主菜单 —— 出错也只
4686
+ * 影响这一块,不阻断其他入口。
4687
+ */
4688
+ const chainPanel =
4689
+ chainView === null
4690
+ ? null
4691
+ : h(
4692
+ 'div',
4693
+ { 'data-cr-relay-chain': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
4694
+ h(
4695
+ 'div',
4696
+ {
4697
+ style: {
4698
+ display: 'flex',
4699
+ alignItems: 'center',
4700
+ gap: 8,
4701
+ padding: '7px 10px',
4702
+ borderBottom: `1px solid ${theme.panelBorder}`,
4703
+ fontSize: 12,
4704
+ fontWeight: 600,
4705
+ },
4706
+ },
4707
+ h('span', { style: { flex: 1, minWidth: 0 } }, '完整交接链'),
4708
+ h(
4709
+ 'button',
4710
+ {
4711
+ type: 'button',
4712
+ 'data-cr-relay-chain-back': '1',
4713
+ onClick: backFromChain,
4714
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4715
+ },
4716
+ '← 返回',
4717
+ ),
4718
+ ),
4719
+ h(
4720
+ 'div',
4721
+ { style: { display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 5px', overflowY: 'auto', overflowX: 'hidden', maxHeight: 'min(46vh, 300px)' } },
4722
+ chainView.rows.map((row) => chainRowItemOf(row)),
4723
+ chainView.loading === true
4724
+ ? h(
4725
+ 'div',
4726
+ { 'data-cr-relay-chain-loading': '1', style: { padding: '5px 10px', fontSize: 11, color: theme.subText } },
4727
+ '读取更早的链路…',
4728
+ )
4729
+ : null,
4730
+ chainView.capped === true
4731
+ ? h(
4732
+ 'div',
4733
+ { 'data-cr-relay-chain-capped': '1', style: { padding: '5px 10px', fontSize: 10, color: theme.subText } },
4734
+ `只显示最近 ${MAX_CHAIN_HOPS} 层`,
4735
+ )
4736
+ : null,
4737
+ chainView.error !== null
4738
+ ? h(
4739
+ 'div',
4740
+ {
4741
+ 'data-cr-relay-chain-error': '1',
4742
+ // 0.5.1 DCR-05:断点处是哪一层没读到(空串 = 普通读取失败,没有可打开的会话)。
4743
+ 'data-cr-relay-chain-blocked':
4744
+ chainView.blocked === null || chainView.blocked === undefined
4745
+ ? ''
4746
+ : chainView.blocked.sessionId,
4747
+ style: {
4748
+ display: 'flex',
4749
+ alignItems: 'center',
4750
+ gap: 8,
4751
+ padding: '6px 10px',
4752
+ fontSize: 11,
4753
+ color: 'var(--dsw-alias-state-error-primary, #D92D20)',
4754
+ },
4755
+ },
4756
+ h('span', { style: { flex: 1, minWidth: 0 } }, String(chainView.error)),
4757
+ chainView.blocked !== null && chainView.blocked !== undefined
4758
+ ? h(
4759
+ 'button',
4760
+ {
4761
+ type: 'button',
4762
+ 'data-cr-relay-chain-open-parent': chainView.blocked.sessionId,
4763
+ onClick: () => {
4764
+ void openAndResumeChain();
4765
+ },
4766
+ title: `打开上一层会话 ${chainView.blocked.sessionId},加载后自动续读`,
4767
+ style: { flex: 'none', border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4768
+ },
4769
+ '打开并继续',
4770
+ )
4771
+ : null,
4772
+ h(
4773
+ 'button',
4774
+ {
4775
+ type: 'button',
4776
+ 'data-cr-relay-chain-retry': '1',
4777
+ onClick: retryChain,
4778
+ style: { flex: 'none', border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4779
+ },
4780
+ '重试',
4781
+ ),
4782
+ )
4783
+ : null,
4784
+ ),
4785
+ );
4786
+
4787
+ /**
4788
+ * 0.5.2 L 项:强制归档的二次确认 / 执行中 / 失败 / 结果面板。
4789
+ *
4790
+ * 破坏性后果写在标题下的第一段(「会停止旧会话上正在跑的任务」),而不是只靠
4791
+ * 按钮文案;置顶时再补一行「归档会同时取消置顶(宿主行为)」,与交接前的置顶
4792
+ * 确认同一口径。只有「强制归档(停止任务)」这个按钮会真的发请求。
4793
+ */
4794
+ const forcePanel =
4795
+ forceView === null
4796
+ ? null
4797
+ : h(
4798
+ 'div',
4799
+ {
4800
+ role: 'dialog',
4801
+ 'aria-label': '强制归档旧会话',
4802
+ 'data-cr-force-archive': '1',
4803
+ 'data-cr-force-phase': forceView.phase,
4804
+ 'data-cr-force-target': typeof forceView.target === 'string' ? forceView.target : '',
4805
+ style: { display: 'flex', flexDirection: 'column', minHeight: 0 },
4806
+ },
4807
+ h(
4808
+ 'div',
4809
+ {
4810
+ style: {
4811
+ display: 'flex',
4812
+ alignItems: 'center',
4813
+ gap: 8,
4814
+ padding: '7px 10px',
4815
+ borderBottom: `1px solid ${theme.panelBorder}`,
4816
+ fontSize: 12,
4817
+ fontWeight: 600,
4818
+ },
4819
+ },
4820
+ h('span', { style: { flex: 1, minWidth: 0 } }, '强制归档旧会话'),
4821
+ forceView.phase === 'busy'
4822
+ ? null
4823
+ : h(
4824
+ 'button',
4825
+ {
4826
+ type: 'button',
4827
+ 'data-cr-force-back': '1',
4828
+ onClick: backFromForce,
4829
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4830
+ },
4831
+ '← 返回',
4832
+ ),
4833
+ ),
4834
+ forceView.phase === 'confirm'
4835
+ ? h(
4836
+ 'div',
4837
+ {
4838
+ 'data-cr-force-confirm': '1',
4839
+ style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' },
4840
+ },
4841
+ h('div', { style: { fontWeight: 600, color: theme.panelFg } }, `将强制归档旧会话「${label}」。`),
4842
+ h(
4843
+ 'div',
4844
+ {
4845
+ 'data-cr-force-warning': '1',
4846
+ style: {
4847
+ padding: '6px 8px',
4848
+ borderRadius: 8,
4849
+ border: `1px solid ${COLORS.error}`,
4850
+ color: COLORS.error,
4851
+ background: theme.hover,
4852
+ },
4853
+ },
4854
+ '会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),中断后无法恢复。',
4855
+ ),
4856
+ parentPinned === true
4857
+ ? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '旧会话已在侧栏置顶:归档会同时取消置顶(宿主行为)。')
4858
+ : parentPinned === null
4859
+ ? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '宿主没提供置顶状态:若旧会话在侧栏置顶,归档会同时取消置顶(宿主行为)。')
4860
+ : null,
4861
+ h(
4862
+ 'div',
4863
+ { style: { color: theme.subText } },
4864
+ '这是显式操作:默认自动归档遇到活跃会话会「保留可见、不停止任务」,只有你在这里确认后才会停止旧会话上的任务。',
4865
+ ),
4866
+ h(
4867
+ 'div',
4868
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
4869
+ h(
4870
+ 'button',
4871
+ {
4872
+ type: 'button',
4873
+ 'data-cr-force-cancel': '1',
4874
+ onClick: backFromForce,
4875
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4876
+ },
4877
+ '取消',
4878
+ ),
4879
+ h(
4880
+ 'button',
4881
+ {
4882
+ type: 'button',
4883
+ 'data-cr-force-run': '1',
4884
+ onClick: () => {
4885
+ void runForceArchive(forceView.target, { confirmUnpin: parentPinned === true });
4886
+ },
4887
+ style: {
4888
+ border: 'none',
4889
+ borderRadius: 7,
4890
+ padding: '4px 10px',
4891
+ fontSize: 11,
4892
+ fontWeight: 600,
4893
+ cursor: 'pointer',
4894
+ background: COLORS.error,
4895
+ color: '#ffffff',
4896
+ },
4897
+ },
4898
+ parentPinned === true ? '强制归档并取消置顶(停止任务)' : '强制归档(停止任务)',
4899
+ ),
4900
+ ),
4901
+ )
4902
+ : forceView.phase === 'busy'
4903
+ ? h(
4904
+ 'div',
4905
+ { 'data-cr-force-busy': '1', style: { padding: '12px', fontSize: 11, lineHeight: '16px', color: theme.subText } },
4906
+ '正在强制归档并停止旧会话上的任务…',
4907
+ )
4908
+ : forceView.phase === 'error'
4909
+ ? h(
4910
+ 'div',
4911
+ { 'data-cr-force-error': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
4912
+ h('div', { style: { color: COLORS.error } }, String(forceView.message)),
4913
+ h(
4914
+ 'div',
4915
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
4916
+ h(
4917
+ 'button',
4918
+ {
4919
+ type: 'button',
4920
+ 'data-cr-force-error-back': '1',
4921
+ onClick: backFromForce,
4922
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4923
+ },
4924
+ '返回菜单',
4925
+ ),
4926
+ h(
4927
+ 'button',
4928
+ {
4929
+ type: 'button',
4930
+ 'data-cr-force-retry': '1',
4931
+ onClick: () => {
4932
+ void runForceArchive(forceView.target, { confirmUnpin: forceView.confirmUnpin === true });
4933
+ },
4934
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4935
+ },
4936
+ '重试',
4937
+ ),
4938
+ ),
4939
+ )
4940
+ : h(
4941
+ 'div',
4942
+ { 'data-cr-force-done': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
4943
+ h('div', { 'data-cr-force-note': '1', style: { color: theme.panelFg } }, String(forceView.note)),
4944
+ // pinned = 宿主里旧会话其实还置顶(状态过期):不静默取消置顶,
4945
+ // 给一个明确的二次出口,用户点了才带 confirmUnpin 重发。
4946
+ forceView.status === 'pinned'
4947
+ ? h(
4948
+ 'div',
4949
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
4950
+ h(
4951
+ 'button',
4952
+ {
4953
+ type: 'button',
4954
+ 'data-cr-force-close': '1',
4955
+ onClick: closeMenu,
4956
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4957
+ },
4958
+ '关闭',
4959
+ ),
4960
+ h(
4961
+ 'button',
4962
+ {
4963
+ type: 'button',
4964
+ 'data-cr-force-unpin': '1',
4965
+ onClick: () => {
4966
+ void runForceArchive(forceView.target, { confirmUnpin: true });
4967
+ },
4968
+ style: { border: 'none', borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: COLORS.error, color: '#ffffff' },
4969
+ },
4970
+ '仍然归档(取消置顶)',
4971
+ ),
4972
+ )
4973
+ : h(
4974
+ 'div',
4975
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
4976
+ h(
4977
+ 'button',
4978
+ {
4979
+ type: 'button',
4980
+ 'data-cr-force-close': '1',
4981
+ onClick: closeMenu,
4982
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4983
+ },
4984
+ '关闭',
4985
+ ),
4986
+ h(
4987
+ 'button',
4988
+ {
4989
+ type: 'button',
4990
+ 'data-cr-force-done-back': '1',
4991
+ onClick: backFromForce,
4992
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4993
+ },
4994
+ '返回菜单',
4995
+ ),
4996
+ ),
4997
+ ),
4998
+ );
4999
+
3720
5000
  const menu =
3721
5001
  open === true
3722
5002
  ? h(
@@ -3735,7 +5015,7 @@ window.__ModuleLoader__.load({
3735
5015
  display: 'flex',
3736
5016
  flexDirection: 'column',
3737
5017
  overflow: 'hidden',
3738
- padding: doc === null ? 5 : 0,
5018
+ padding: doc === null && chainView === null && forceView === null ? 5 : 0,
3739
5019
  borderRadius: 12,
3740
5020
  ...panelChrome(theme),
3741
5021
  color: theme.panelFg,
@@ -3750,7 +5030,10 @@ window.__ModuleLoader__.load({
3750
5030
  notice,
3751
5031
  )
3752
5032
  : null,
5033
+ // 交接单面板 / 完整链面板 / 强制归档确认面板都替换主列表;「← 返回」按来源回主菜单。
3753
5034
  docPanel ??
5035
+ chainPanel ??
5036
+ forcePanel ??
3754
5037
  h(
3755
5038
  'div',
3756
5039
  { style: { display: 'flex', flexDirection: 'column' } },
@@ -3762,11 +5045,20 @@ window.__ModuleLoader__.load({
3762
5045
  right: docName === null ? '暂无交接单' : '›',
3763
5046
  title: docName === null ? '暂无交接单:这条接力记录里没有交接单路径(旧记录,或父会话已不在)' : '打开交接单',
3764
5047
  }),
3765
- relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
3766
- disabled: !hasParent,
3767
- right: hasParent ? '›' : '不可用',
3768
- title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
3769
- }),
5048
+ // 0.5.1 批 3(TD-01):父会话侧的去向 —— 出站记录里的 childId。
5049
+ hasNext
5050
+ ? relayBarItem('去新会话', '切到这次交接出来的新会话', 'next', goNext, {
5051
+ right: '›',
5052
+ title: `打开新会话 ${next.childId}${nextArchived === true ? '(已归档:打开前先恢复)' : ''}`,
5053
+ })
5054
+ : null,
5055
+ chain !== null
5056
+ ? relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
5057
+ disabled: !hasParent,
5058
+ right: hasParent ? '›' : '不可用',
5059
+ title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
5060
+ })
5061
+ : null,
3770
5062
  showRestore
3771
5063
  ? relayBarItem(
3772
5064
  '恢复旧会话',
@@ -3775,7 +5067,7 @@ window.__ModuleLoader__.load({
3775
5067
  : '宿主没提供归档状态;恢复调用是幂等的,仍可直接尝试',
3776
5068
  'unarchive',
3777
5069
  () => {
3778
- void restoreParent();
5070
+ void restoreAndOpen(chain.parentId, { markParent: true });
3779
5071
  },
3780
5072
  {
3781
5073
  right: parentArchived === true ? '已归档' : '恢复',
@@ -3783,6 +5075,28 @@ window.__ModuleLoader__.load({
3783
5075
  },
3784
5076
  )
3785
5077
  : null,
5078
+ hasParent && showForceArchive
5079
+ ? relayBarItem(
5080
+ '强制归档旧会话(会停止后台任务)',
5081
+ parentPinned === true
5082
+ ? '旧会话已置顶:归档会同时取消置顶;会先二次确认'
5083
+ : '旧会话还有任务在跑也能收起;会先二次确认',
5084
+ 'force-archive',
5085
+ () => {
5086
+ openForceConfirm(chain.parentId);
5087
+ },
5088
+ {
5089
+ right: '›',
5090
+ title: `强制归档旧会话 ${chain.parentId}:二次确认后会停止它上面正在跑的任务再归档`,
5091
+ },
5092
+ )
5093
+ : null,
5094
+ hasParent
5095
+ ? relayBarItem('完整交接链', '一次读取整条链,回看前几次会话与交接单', 'chain', openChainList, {
5096
+ right: '›',
5097
+ title: `完整交接链:一次读取整条链(最多 ${MAX_CHAIN_HOPS} 层;祖先未加载也能读)`,
5098
+ })
5099
+ : null,
3786
5100
  relayBarItem('路线图(即将上线)', '跨会话时间线,0.6 之后', 'roadmap', null, {
3787
5101
  disabled: true,
3788
5102
  right: '即将上线',
@@ -3830,15 +5144,18 @@ window.__ModuleLoader__.load({
3830
5144
  type: 'button',
3831
5145
  ref: triggerRef,
3832
5146
  'data-cr-relay-bar': '1',
3833
- 'data-cr-relay-ordinal': String(chain.ordinal),
3834
- 'data-cr-relay-scheme': chain.scheme ?? '',
3835
- 'data-cr-relay-parent': chain.parentId ?? '',
3836
- 'data-cr-relay-path': chain.path ?? '',
5147
+ 'data-cr-relay-ordinal': String(chain !== null ? chain.ordinal : next.ordinal),
5148
+ 'data-cr-relay-scheme': (chain !== null ? chain.scheme : next.scheme) ?? '',
5149
+ 'data-cr-relay-parent': chain === null || chain.parentId === null ? '' : chain.parentId,
5150
+ 'data-cr-relay-path': docPath ?? '',
3837
5151
  // 空串 = 宿主没给归档状态(老宿主),'true'/'false' = readState.parentArchived。
3838
5152
  'data-cr-relay-parent-archived': parentArchived === null ? '' : String(parentArchived),
5153
+ // 0.5.1 批 3:出站目标(父会话侧「去新会话」)与它的归档状态。
5154
+ 'data-cr-relay-next': hasNext ? next.childId : '',
5155
+ 'data-cr-relay-next-archived': nextArchived === null ? '' : String(nextArchived),
3839
5156
  'aria-haspopup': 'menu',
3840
5157
  'aria-expanded': open,
3841
- 'aria-label': `已接力自 ${label},打开接力菜单`,
5158
+ 'aria-label': chain !== null ? `已接力自 ${label},打开接力菜单` : `${label},打开接力菜单`,
3842
5159
  onClick: toggleMenu,
3843
5160
  style: {
3844
5161
  display: 'flex',
@@ -3866,7 +5183,8 @@ window.__ModuleLoader__.load({
3866
5183
  'span',
3867
5184
  { style: { flex: 'none', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3868
5185
  // 末尾留一个空格:flex gap 负责视觉间距,文本内容(读屏/可访问性树)也要读得通。
3869
- '已接力自 ',
5186
+ // 父会话侧没有「来路」,只显示「已交接给新会话」(见 label)。
5187
+ chain !== null ? '已接力自 ' : '',
3870
5188
  ),
3871
5189
  h(
3872
5190
  'span',