dsh-compact-relay 0.5.0 → 0.5.1

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
@@ -2735,10 +2867,53 @@ window.__ModuleLoader__.load({
2735
2867
  h('div', { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } }, handoffData.editable.note ?? '程序生成的段不可改。'),
2736
2868
  )
2737
2869
  : null,
2738
- h('div', { style: { fontSize: 11, color: theme.subText } }, '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。'),
2870
+ // 0.5.1 批 2:归档开关(默认开 = 0.5.0 行为;取消后只交接、不归档)。
2871
+ h(
2872
+ 'label',
2873
+ {
2874
+ 'data-cr-handoff-archive-row': '1',
2875
+ style: { display: 'flex', gap: 6, alignItems: 'flex-start', fontSize: 11, lineHeight: '16px', color: theme.subText },
2876
+ },
2877
+ h('input', {
2878
+ type: 'checkbox',
2879
+ 'data-cr-handoff-archive': '1',
2880
+ checked: archiveWanted,
2881
+ onChange: (event) => setArchiveWanted(event.target.checked === true),
2882
+ style: { marginTop: 2 },
2883
+ }),
2884
+ h(
2885
+ 'span',
2886
+ null,
2887
+ '交接后自动归档旧会话(默认开;新会话建好后旧会话从侧栏收起,可在新会话「恢复旧会话」找回)。取消勾选 = 只交接、旧会话留在列表里。',
2888
+ ),
2889
+ ),
2890
+ // 0.5.1 批 2:置顶保护 —— 宿主归档会在同一次写盘里取消置顶,必须用户确认。
2891
+ sessionPinned && archiveWanted
2892
+ ? h(
2893
+ 'div',
2894
+ {
2895
+ 'data-cr-handoff-pinned-warning': '1',
2896
+ style: {
2897
+ fontSize: 11,
2898
+ lineHeight: '16px',
2899
+ padding: '6px 8px',
2900
+ borderRadius: 8,
2901
+ border: `1px solid ${palette.handoff}`,
2902
+ color: palette.handoff,
2903
+ background: theme.hover,
2904
+ },
2905
+ },
2906
+ '旧会话已在侧栏置顶:归档会取消置顶(宿主行为)。要归档请点「交接并归档(取消置顶)」;想保留置顶就点「只交接,保留置顶」。',
2907
+ )
2908
+ : null,
2739
2909
  h(
2740
2910
  'div',
2741
- { style: { display: 'flex', justifyContent: 'flex-end', gap: 6 } },
2911
+ { style: { fontSize: 11, color: theme.subText } },
2912
+ '确认后会先压缩旧会话,再按压缩后的历史生成最终交接单;预览不落盘。归档开关只影响旧会话是否从侧栏收起,不影响交接单本身。',
2913
+ ),
2914
+ h(
2915
+ 'div',
2916
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' } },
2742
2917
  h(
2743
2918
  'button',
2744
2919
  {
@@ -2749,25 +2924,25 @@ window.__ModuleLoader__.load({
2749
2924
  },
2750
2925
  '取消',
2751
2926
  ),
2927
+ // 置顶 + 勾选归档:给两个明确出口(归档并取消置顶 / 只交接保留置顶)。
2928
+ sessionPinned && archiveWanted
2929
+ ? h(
2930
+ 'button',
2931
+ {
2932
+ type: 'button',
2933
+ 'data-cr-handoff-keep': '1',
2934
+ onClick: () => beginHandoff({ archive: false }),
2935
+ style: { ...smallBtnStyle, borderRadius: 7, padding: '4px 10px' },
2936
+ },
2937
+ '只交接,保留置顶',
2938
+ )
2939
+ : null,
2752
2940
  h(
2753
2941
  'button',
2754
2942
  {
2755
2943
  type: 'button',
2756
2944
  '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
- },
2945
+ onClick: () => beginHandoff(sessionPinned && archiveWanted ? { archive: true, confirmUnpin: true } : { archive: archiveWanted }),
2771
2946
  style: {
2772
2947
  border: 'none',
2773
2948
  borderRadius: 7,
@@ -2779,7 +2954,11 @@ window.__ModuleLoader__.load({
2779
2954
  color: '#ffffff',
2780
2955
  },
2781
2956
  },
2782
- '确认交接',
2957
+ sessionPinned && archiveWanted
2958
+ ? '交接并归档(取消置顶)'
2959
+ : archiveWanted
2960
+ ? '确认交接(归档旧会话)'
2961
+ : '确认交接(不归档)',
2783
2962
  ),
2784
2963
  ),
2785
2964
  ),
@@ -3152,7 +3331,9 @@ window.__ModuleLoader__.load({
3152
3331
  * 显示:`readState.chain` 非 null,且这条记录是**本会话的入站记录**
3153
3332
  * (childId === sessionId)。出站记录(本会话交接给了下一代)不显示
3154
3333
  * 「已接力自」;老宿主/没有 chain 字段时组件返回 null,不占高度。
3155
- * 数据:GET `/api/compact-relay-state?sessionId=…`。会话刚打开、宿主还没装日志
3334
+ * 数据:GET `/api/compact-relay-state?sessionId=…`;
3335
+ * 完整链优先 GET `/api/compact-relay-chain?sessionId=…`(一次读全,祖先未加载也行),
3336
+ * 老宿主回落逐级 state。会话刚打开、宿主还没装日志
3156
3337
  * 时短重试几次(拿到本会话的 sessionId 后就停,不做常驻轮询/SSE)。
3157
3338
  * 子会话的 relay record 不带 path(F3 只写链号),缺 path 时再只读一次父
3158
3339
  * 会话状态,用父记录里的 path 打开交接单(childId/ordinal 校验)。
@@ -3205,6 +3386,42 @@ window.__ModuleLoader__.load({
3205
3386
  };
3206
3387
  }
3207
3388
 
3389
+ /**
3390
+ * 服务端状态里「这一跳」的**出站**投影(0.5.1 DCR-01)。
3391
+ *
3392
+ * 父会话请求拿到的是它自己的状态:`chain` 是**父会话的入站**记录(它的上一代),
3393
+ * `next` 才是它交接给子会话的那条出站记录 —— 子会话要补的交接单 path 在 next 里。
3394
+ * 老插件(0.5.0)没有 next 字段,那时 chain 就是「最近一条记录」,直接回落到它,
3395
+ * 保证旧服务端仍可用。校验仍在调用方(childId/链号必须对得上)。
3396
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3397
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3398
+ */
3399
+ function relayOutboundProjectionOf(data) {
3400
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3401
+ const next = data.next;
3402
+ if (next !== null && next !== undefined) {
3403
+ const projection = relayChainProjectionOf({ ok: true, chain: next });
3404
+ if (projection !== null) return projection;
3405
+ }
3406
+ // 老插件:没有 next,chain 就是最近一条记录(出站)。
3407
+ return relayChainProjectionOf(data);
3408
+ }
3409
+
3410
+ /**
3411
+ * 0.5.1 DCR-03:宿主有没有恢复归档会话的接缝(readState.capabilities.unarchive)。
3412
+ *
3413
+ * 三重取值:true / false / null(老插件没有 capabilities 字段 → 不知道,按「可尝试」
3414
+ * 处理,恢复调用本身幂等)。
3415
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3416
+ * @returns {boolean|null}
3417
+ */
3418
+ function relayBarUnarchiveCapabilityOf(data) {
3419
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3420
+ const capabilities = data.capabilities;
3421
+ if (capabilities === null || typeof capabilities !== 'object') return null;
3422
+ return typeof capabilities.unarchive === 'boolean' ? capabilities.unarchive : null;
3423
+ }
3424
+
3208
3425
  /**
3209
3426
  * 0.5.0:旧会话是否已被交接流程自动归档。
3210
3427
  *
@@ -3228,6 +3445,39 @@ window.__ModuleLoader__.load({
3228
3445
  return chain.scheme === 'history-n' ? `历史 ${chain.ordinal}:${baseTitle}` : `交接第 ${chain.ordinal} 次:${baseTitle}`;
3229
3446
  }
3230
3447
 
3448
+ /**
3449
+ * 0.5.1 批 3:父会话侧的**出站目标**(`readState.next` 的投影,父→子入口用)。
3450
+ *
3451
+ * 与 relayOutboundProjectionOf 的区别:这里**不回落到 chain**。老插件没有 next 字段
3452
+ * (那里 chain 是「最近一条记录」,可能是本会话的入站记录),拿它当出站目标会生出
3453
+ * 「去新会话」却跳回自己的死入口。另外 childId 指回本会话的脏记录也一律不认。
3454
+ *
3455
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3456
+ * @param {string|undefined} sessionId 当前会话 id
3457
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3458
+ */
3459
+ function relayBarNextOf(data, sessionId) {
3460
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3461
+ if (data.next === null || data.next === undefined) return null;
3462
+ const projection = relayChainProjectionOf({ ok: true, chain: data.next });
3463
+ if (projection === null || projection.childId === null) return null;
3464
+ if (typeof sessionId === 'string' && sessionId.length > 0 && projection.childId === sessionId) return null;
3465
+ return projection;
3466
+ }
3467
+
3468
+ /**
3469
+ * 0.5.1 批 3:出站目标(下一代会话)是否已在归档集合里(readState.nextArchived)。
3470
+ *
3471
+ * 三重取值:true / false / null(老插件没有这个字段 → 不知道,按「可尝试恢复」处理,
3472
+ * unarchive 幂等)。父会话侧「去新会话」据此决定要不要先 unarchive 再 open。
3473
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3474
+ * @returns {boolean|null}
3475
+ */
3476
+ function relayBarNextArchivedOf(data) {
3477
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3478
+ return typeof data.nextArchived === 'boolean' ? data.nextArchived : null;
3479
+ }
3480
+
3231
3481
  /**
3232
3482
  * 交接单绝对路径 → `/api/compact-relay-handoffs?file=` 只接受的 handoff-*.md 文件名。
3233
3483
  * 服务端只认文件名(不是全路径),这里取 basename 并做同一形态校验;不合法 → null。
@@ -3240,6 +3490,48 @@ window.__ModuleLoader__.load({
3240
3490
  return typeof name === 'string' && /^handoff-[0-9A-Za-z][0-9A-Za-z.\-_]*\.md$/.test(name) ? name : null;
3241
3491
  }
3242
3492
 
3493
+ /**
3494
+ * 完整交接链最多回看多少跳(0.5.1 批 3)。
3495
+ *
3496
+ * 硬上限:链路异常成环、或从某个 0.4 老链一路接力下来时,不能让界面无限逐级
3497
+ * 打接口。正常用户几跳内就到根,8 跳足够长;到顶时界面明说「只显示最近 8 层」。
3498
+ */
3499
+ const MAX_CHAIN_HOPS = 8;
3500
+
3501
+ /**
3502
+ * 0.5.1 DCR-05:完整交接链一次读完的只读端点。
3503
+ *
3504
+ * 服务端直接读持久化日志/header(不加载祖先会话),因此重启后祖先都没加载也能
3505
+ * 一次拿到整条链;老插件/老宿主没有这个端点时客户端回落到逐级 state 读法。
3506
+ */
3507
+ const RELAY_CHAIN_ENDPOINT = '/api/compact-relay-chain';
3508
+
3509
+ /**
3510
+ * 服务端整链响应 → 行数组;形状不合法返回 null(调用方回落旧逐级读法)。
3511
+ *
3512
+ * 行只保留画一行需要的字段;`sessionId` 是这一层会话自己的 id(不是 childId)。
3513
+ * @param {object|null|undefined} data `/api/compact-relay-chain` 的响应
3514
+ * @returns {Array<{sessionId:string, ordinal:number, baseTitle:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}>|null}
3515
+ */
3516
+ function relayChainRowsOf(data) {
3517
+ if (data === null || typeof data !== 'object' || data.ok !== true || !Array.isArray(data.rows)) return null;
3518
+ const rows = [];
3519
+ for (const row of data.rows) {
3520
+ if (row === null || typeof row !== 'object') continue;
3521
+ if (typeof row.sessionId !== 'string' || row.sessionId.length === 0) continue;
3522
+ const projection = relayChainProjectionOf({ ok: true, chain: row });
3523
+ if (projection === null) continue;
3524
+ rows.push({
3525
+ sessionId: row.sessionId,
3526
+ ordinal: projection.ordinal,
3527
+ baseTitle: projection.baseTitle,
3528
+ scheme: projection.scheme,
3529
+ path: projection.path,
3530
+ });
3531
+ }
3532
+ return rows;
3533
+ }
3534
+
3243
3535
  /** 接力条菜单里的一行(和指示灯菜单同一套视觉,但更轻)。 */
3244
3536
  function relayBarItem(label, sub, key, action, meta = {}) {
3245
3537
  const disabled = meta.disabled === true;
@@ -3302,6 +3594,11 @@ window.__ModuleLoader__.load({
3302
3594
  /**
3303
3595
  * 接力条本体(`conversation.input.dock`,输入卡上方一行)。
3304
3596
  *
3597
+ * 0.5.1 批 3 起这一行有两个方向:
3598
+ * - 入站(`chain`,子会话侧):「已接力自 历史 N:原名」+ 回到原会话 / 完整交接链;
3599
+ * - 出站(`next`,父会话侧):「已交接给新会话」+ 去新会话;
3600
+ * - 两个都有(中段会话):同时显示两个方向的入口。
3601
+ *
3305
3602
  * @param {object} props ctx.slots.register 注入的 props
3306
3603
  * @param {string} [props.sessionId] 当前会话 id(session scope)
3307
3604
  * @param {(sessionId:string)=>unknown} [props.openSession] 切到某个会话(ctx.uiWorkspace.openSession)
@@ -3314,8 +3611,21 @@ window.__ModuleLoader__.load({
3314
3611
  const unarchiveSession = props === null || props === undefined ? undefined : props.unarchiveSession;
3315
3612
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
3316
3613
  const [chain, setChain] = useState(null);
3614
+ // 0.5.1 批 3:出站投影(本会话把接力棒交给了谁)。父会话侧据此显示「去新会话」。
3615
+ const [next, setNext] = useState(null);
3317
3616
  // null = 宿主没给归档状态(老宿主);true/false = readState.parentArchived。
3318
3617
  const [parentArchived, setParentArchived] = useState(null);
3618
+ // 0.5.1 批 3:出站目标(next.childId)的归档状态(readState.nextArchived)。
3619
+ const [nextArchived, setNextArchived] = useState(null);
3620
+ // 0.5.1 DCR-03:服务端能不能恢复归档会话(readState.capabilities.unarchive)。
3621
+ // null = 老插件没有这个字段(不知道 → 仍显示入口,恢复调用幂等)。
3622
+ const [serverUnarchive, setServerUnarchive] = useState(null);
3623
+ // 0.5.1 DCR-03:打开菜单时 +1,触发一次静默刷新(别处归档/取消归档能立刻反映)。
3624
+ const [refreshKey, setRefreshKey] = useState(0);
3625
+ // 0.5.1 批 3:完整交接链(null = 没展开;点「完整交接链」后才逐级去读)。
3626
+ const [chainView, setChainView] = useState(null);
3627
+ // 上一次渲染时的 sessionId:只有真的换会话才清链(刷新不清,避免闪一下)。
3628
+ const lastSessionRef = useRef(null);
3319
3629
  const [open, setOpen] = useState(false);
3320
3630
  const [doc, setDoc] = useState(null);
3321
3631
  const [notice, setNotice] = useState(null);
@@ -3326,6 +3636,9 @@ window.__ModuleLoader__.load({
3326
3636
  const menuRef = useRef(null);
3327
3637
  const docRequestRef = useRef(0);
3328
3638
  const docAbortRef = useRef(null);
3639
+ // 完整链接力式的请求序号(关菜单/换会话/重试时让晚到的响应失效)与断点。
3640
+ const chainRequestRef = useRef(0);
3641
+ const chainResumeRef = useRef(null);
3329
3642
 
3330
3643
  /** 取消未完成的交接单读取;同时让晚到的响应失效(requestId 对不上)。 */
3331
3644
  const abortDoc = useCallback(() => {
@@ -3340,12 +3653,20 @@ window.__ModuleLoader__.load({
3340
3653
  }
3341
3654
  }, []);
3342
3655
 
3656
+ // 0.5.1 批 3:完整链的「收摊」——让晚到的逐级响应失效,并丢掉列表与断点。
3657
+ const closeChainView = useCallback(() => {
3658
+ chainRequestRef.current += 1;
3659
+ chainResumeRef.current = null;
3660
+ setChainView(null);
3661
+ }, []);
3662
+
3343
3663
  const closeMenu = useCallback(() => {
3344
3664
  abortDoc();
3665
+ closeChainView();
3345
3666
  setOpen(false);
3346
3667
  setDoc(null);
3347
3668
  setNotice(null);
3348
- }, [abortDoc]);
3669
+ }, [abortDoc, closeChainView]);
3349
3670
 
3350
3671
  // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
3351
3672
  const backToMenu = useCallback(() => {
@@ -3357,19 +3678,34 @@ window.__ModuleLoader__.load({
3357
3678
  // null),这时要重试几次;一旦宿主确认「就是这个会话且没有 chain」就停,
3358
3679
  // 普通会话不会无限打接口。fetch 失败/老宿主没有 chain → setChain(null) → 不渲染。
3359
3680
  useEffect(() => {
3360
- // 换会话/没有会话时先清掉上一条链(dock 组件可能被复用而不是重挂)。
3361
- setChain(null);
3362
- setParentArchived(null);
3363
- setOpen(false);
3364
- setDoc(null);
3365
- setNotice(null);
3681
+ // 换会话/没有会话时才清掉上一条链(dock 组件可能被复用而不是重挂)。
3682
+ // 0.5.1 DCR-03:`refreshKey`(打开菜单后刷新)**不清链**——否则菜单一打开、
3683
+ // 接力条会先消失再出现。
3684
+ const sessionChanged = lastSessionRef.current !== sessionId;
3685
+ lastSessionRef.current = sessionId;
3686
+ if (sessionChanged) {
3687
+ setChain(null);
3688
+ setNext(null);
3689
+ setParentArchived(null);
3690
+ setNextArchived(null);
3691
+ setServerUnarchive(null);
3692
+ setOpen(false);
3693
+ setDoc(null);
3694
+ setNotice(null);
3695
+ // 换会话:完整链的断点与结果都不再属于当前会话。
3696
+ chainRequestRef.current += 1;
3697
+ chainResumeRef.current = null;
3698
+ setChainView(null);
3699
+ }
3366
3700
  if (!hasSession) return undefined;
3367
3701
  let dead = false;
3368
3702
  let timer = 0;
3369
3703
  let attempts = 0;
3704
+ // 静默刷新(菜单打开时):链已经在显示,失败/未加载就不再排队重试,也不清链。
3705
+ const silent = sessionChanged === false;
3370
3706
  const url = `/api/compact-relay-state?sessionId=${encodeURIComponent(sessionId)}`;
3371
3707
  const schedule = () => {
3372
- if (dead) return;
3708
+ if (dead || silent) return;
3373
3709
  attempts += 1;
3374
3710
  if (attempts > 12) return;
3375
3711
  timer = setTimeout(() => {
@@ -3384,7 +3720,8 @@ window.__ModuleLoader__.load({
3384
3720
  if (dead || !res.ok) return;
3385
3721
  const parentData = await res.json().catch(() => null);
3386
3722
  if (dead) return;
3387
- const parentChain = relayChainProjectionOf(parentData);
3723
+ // 0.5.1 DCR-01:父会话的 chain 是它自己的入站记录,这一跳的出站在 next 里。
3724
+ const parentChain = relayOutboundProjectionOf(parentData);
3388
3725
  if (
3389
3726
  parentChain === null ||
3390
3727
  parentChain.childId !== sessionId ||
@@ -3412,21 +3749,33 @@ window.__ModuleLoader__.load({
3412
3749
  if (dead) return;
3413
3750
  if (data !== null && typeof data === 'object' && data.ok === false) return;
3414
3751
  const parsed = relayBarChainOf(data, sessionId);
3415
- if (parsed !== null) {
3752
+ // 0.5.1 批 3:父会话侧的出站目标。它和 chain 是两条独立的投影:子会话只有
3753
+ // chain,父会话只有 next,中段会话两个都有 —— 任一存在就要渲染接力条。
3754
+ const outbound = relayBarNextOf(data, sessionId);
3755
+ if (parsed !== null || outbound !== null) {
3416
3756
  setChain(parsed);
3757
+ setNext(outbound);
3417
3758
  setParentArchived(relayBarParentArchivedOf(data));
3759
+ setNextArchived(relayBarNextArchivedOf(data));
3760
+ setServerUnarchive(relayBarUnarchiveCapabilityOf(data));
3418
3761
  // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3419
3762
  // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3420
3763
  // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
3421
- if (parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3764
+ if (parsed !== null && parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3422
3765
  return;
3423
3766
  }
3424
3767
  // sessionId 对不上/为 null → 宿主还没加载本会话,继续等;对得上 → 确定没有链。
3425
3768
  const loaded =
3426
3769
  data !== null && typeof data === 'object' && typeof data.sessionId === 'string' && data.sessionId === sessionId;
3427
3770
  if (loaded) {
3428
- setChain(null);
3429
- setParentArchived(null);
3771
+ // 静默刷新时宿主确认「没有链」也不清(保持在显示的接力条,避免误伤)。
3772
+ if (!silent) {
3773
+ setChain(null);
3774
+ setNext(null);
3775
+ setParentArchived(null);
3776
+ setNextArchived(null);
3777
+ setServerUnarchive(null);
3778
+ }
3430
3779
  } else schedule();
3431
3780
  } catch {
3432
3781
  /* 宿主不在/网络失败:保持「没有链」—— 不显示,不占高度;稍后再试 */
@@ -3438,7 +3787,7 @@ window.__ModuleLoader__.load({
3438
3787
  dead = true;
3439
3788
  if (timer !== 0) clearTimeout(timer);
3440
3789
  };
3441
- }, [hasSession, sessionId]);
3790
+ }, [hasSession, sessionId, refreshKey]);
3442
3791
 
3443
3792
  // 菜单打开时按这一行的真实位置重算锚点(滚动/缩放不漂)。
3444
3793
  useEffect(() => {
@@ -3515,8 +3864,13 @@ window.__ModuleLoader__.load({
3515
3864
  abortDoc();
3516
3865
  setNotice(null);
3517
3866
  setDoc(null);
3867
+ // 每次开/关都把完整链收掉:重开菜单从主列表开始(不预加载,也不留旧会话的链)。
3868
+ closeChainView();
3869
+ // 0.5.1 DCR-03:打开菜单前刷新一次 state —— 归档集合可能在别处被改(侧栏
3870
+ // 取消归档 / 另一个窗口归档),沿用打开前的旧状态会让「恢复旧会话」变死入口。
3871
+ if (!open) setRefreshKey((key) => key + 1);
3518
3872
  setOpen((value) => !value);
3519
- }, [abortDoc]);
3873
+ }, [abortDoc, closeChainView, open]);
3520
3874
 
3521
3875
  const loadDoc = useCallback(async (name) => {
3522
3876
  if (typeof name !== 'string' || name.length === 0) return;
@@ -3558,105 +3912,483 @@ window.__ModuleLoader__.load({
3558
3912
  }
3559
3913
  }, []);
3560
3914
 
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
- });
3915
+ /** 切到某个会话(不碰归档状态);成功发起返回 true。 */
3916
+ const openTarget = useCallback(
3917
+ (target, failureText) => {
3918
+ if (typeof openSession !== 'function') {
3919
+ setNotice('这个宿主不支持切换会话,无法自动打开会话。');
3920
+ setOpen(true);
3921
+ return false;
3575
3922
  }
3576
- return true;
3577
- } catch (error) {
3578
- setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3579
- return false;
3580
- }
3581
- }, [chain, openSession]);
3923
+ const failed = (error) => {
3924
+ setNotice(failureText + ':' + (error && error.message ? error.message : '未知错误'));
3925
+ setOpen(true);
3926
+ };
3927
+ try {
3928
+ const result = openSession(target);
3929
+ if (result !== null && result !== undefined && typeof result.then === 'function') {
3930
+ result.catch(failed);
3931
+ }
3932
+ setOpen(false);
3933
+ setDoc(null);
3934
+ closeChainView();
3935
+ return true;
3936
+ } catch (error) {
3937
+ failed(error);
3938
+ return false;
3939
+ }
3940
+ },
3941
+ [closeChainView, openSession],
3942
+ );
3582
3943
 
3583
3944
  /**
3584
- * 0.5.0:恢复归档的父会话并切过去。
3945
+ * 0.5.0 / 0.5.1 DCR-03:恢复归档会话并切过去(父会话、完整链上任意一层、下一代都走这里)。
3946
+ *
3947
+ * 两条通道:
3948
+ * 1. 宿主 Client 侧 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」同一条
3949
+ * 接缝,会同步更新本地归档集合);
3950
+ * 2. 本插件 HTTP `action:'recover'`(服务端 workspaceRegistry.unarchiveSession)——
3951
+ * 老宿主没有客户端 API 时的兜底。
3952
+ * 失败重试:一轮 unarchive+open 失败后换另一条通道再试一次(只有一条通道时同通道
3953
+ * 重试),仍失败才提示错误。unarchive 幂等:重试不会把会话再归档,未归档时是 no-op。
3585
3954
  *
3586
- * 优先用宿主 Client 侧的 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」
3587
- * 同一条接缝,会同步更新本地归档集合);老宿主没有时走本插件 HTTP
3588
- * `action:'recover'`(服务端 workspaceRegistry.unarchiveSession),成功后同样尝试切换。
3589
- * 恢复本身幂等:父会话没归档时宿主 no-op,仍然可以直接打开。
3955
+ * @param {string} target 目标会话 id
3956
+ * @param {{markParent?:boolean, failureText?:string, restoredText?:string}} [options]
3957
+ * markParent:成功后把本地「父会话归档状态」改成未归档(只有父会话需要)。
3590
3958
  */
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
- }
3959
+ /**
3960
+ * 恢复归档会话的两条通道(0.5.1 DCR-03 抽出,DCR-05 的「打开并继续」复用)。
3961
+ *
3962
+ * 1. 宿主 Client 侧 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」同一条
3963
+ * 接缝,会同步更新本地归档集合);
3964
+ * 2. 本插件 HTTP `action:'recover'`(服务端 workspaceRegistry.unarchiveSession)——
3965
+ * 老宿主没有客户端 API 时的兜底。
3966
+ *
3967
+ * 返回按优先级排好的尝试序列;调用方逐个 await,失败换下一条。
3968
+ * @param {string} target 目标会话 id
3969
+ * @returns {Array<() => Promise<unknown>>}
3970
+ */
3971
+ const unarchiveChannels = useCallback(
3972
+ (target) => {
3973
+ const unarchiveViaHost = async () => {
3974
+ if (typeof unarchiveSession !== 'function') throw new Error('这个宿主没有 unarchiveSession');
3975
+ await unarchiveSession(target);
3976
+ };
3977
+ const unarchiveViaHttp = async () => {
3978
+ const res = await fetch('/api/compact-relay-action', {
3979
+ method: 'POST',
3980
+ headers: { 'content-type': 'application/json' },
3981
+ cache: 'no-store',
3982
+ body: JSON.stringify({ action: 'recover', sessionId: target }),
3983
+ });
3984
+ const data = await res.json().catch(() => null);
3985
+ if (!res.ok || data === null || data.ok === false) {
3986
+ throw new Error(data !== null && typeof data.message === 'string' ? data.message : '恢复接口不可用');
3610
3987
  }
3611
- setParentArchived(false);
3988
+ };
3989
+ // 通道顺序:宿主 API 优先;只有一条通道时同通道重试(「失败重试 unarchive+open」)。
3990
+ return typeof unarchiveSession === 'function'
3991
+ ? [unarchiveViaHost, unarchiveViaHttp]
3992
+ : [unarchiveViaHttp, unarchiveViaHttp];
3993
+ },
3994
+ [unarchiveSession],
3995
+ );
3996
+
3997
+ const restoreAndOpen = useCallback(
3998
+ (target, options = {}) => {
3999
+ const markParent = options.markParent === true;
4000
+ const failureText = typeof options.failureText === 'string' ? options.failureText : '恢复旧会话失败';
4001
+ const restoredText =
4002
+ typeof options.restoredText === 'string'
4003
+ ? options.restoredText
4004
+ : '旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。';
4005
+ setNotice(null);
4006
+
4007
+ const channels = unarchiveChannels(target);
4008
+
4009
+ // 一轮 = unarchive 成功后再 open。open 失败也走下一轮(可能恢复没生效/宿主还没刷新)。
4010
+ const attempt = async (unarchive) => {
4011
+ await unarchive();
4012
+ if (markParent) setParentArchived(false);
3612
4013
  if (typeof openSession !== 'function') {
3613
- setNotice('旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。');
4014
+ setNotice(restoredText);
3614
4015
  setOpen(true);
3615
4016
  return;
3616
4017
  }
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
- }
4018
+ await openSession(target);
3624
4019
  setOpen(false);
3625
4020
  setDoc(null);
3626
- } catch (error) {
3627
- setNotice('恢复旧会话失败:' + (error && error.message ? error.message : '未知错误'));
4021
+ closeChainView();
4022
+ };
4023
+
4024
+ return (async () => {
4025
+ let failure = null;
4026
+ for (const channel of channels) {
4027
+ try {
4028
+ await attempt(channel);
4029
+ return;
4030
+ } catch (error) {
4031
+ failure = error;
4032
+ }
4033
+ }
4034
+ setNotice(failureText + ':' + (failure && failure.message ? failure.message : '未知错误'));
3628
4035
  setOpen(true);
3629
- }
3630
- })();
3631
- }, [chain, openSession, unarchiveSession]);
4036
+ })();
4037
+ },
4038
+ [closeChainView, openSession, unarchiveChannels],
4039
+ );
4040
+
4041
+ // 0.5.1 DCR-03:能不能恢复 = 客户端有宿主 API,或服务端说它有 registry.unarchiveSession。
4042
+ // serverUnarchive === null(老插件没有 capabilities 字段)按「可尝试」处理(recover 幂等)。
4043
+ const canRestore = typeof unarchiveSession === 'function' || serverUnarchive !== false;
3632
4044
 
3633
4045
  const goParent = useCallback(() => {
3634
4046
  setNotice(null);
3635
4047
  if (chain === null || chain.parentId === null) return;
3636
- // 已归档 → 先恢复再打开(否则宿主会拒绝打开归档会话)。
3637
- if (parentArchived === true) {
3638
- void restoreParent();
4048
+ // 已归档(true)或宿主没给归档状态(null):都不能假设「未归档」直接打开 ——
4049
+ // 打开被归档的会话会被宿主拒绝,这个入口就成了死路(DCR-03)。
4050
+ // 有恢复能力时先 unarchive 再 open(unarchive 幂等,未归档时是 no-op)。
4051
+ if (parentArchived !== false && canRestore) {
4052
+ void restoreAndOpen(chain.parentId, { markParent: true });
3639
4053
  return;
3640
4054
  }
3641
- if (openParentNow()) {
3642
- setOpen(false);
3643
- setDoc(null);
4055
+ openTarget(chain.parentId, '打不开原会话');
4056
+ }, [canRestore, chain, openTarget, parentArchived, restoreAndOpen]);
4057
+
4058
+ /**
4059
+ * 0.5.1 批 3(TD-01):父会话侧「去新会话」—— 用出站记录的目标 childId 切过去。
4060
+ *
4061
+ * 下一代可能已经被用户归档(`nextArchived`),宿主同样拒绝打开归档会话,所以
4062
+ * 和「回到原会话」同口径:归档/不知道就先 unarchive 再 open(unarchive 幂等)。
4063
+ */
4064
+ const goNext = useCallback(() => {
4065
+ setNotice(null);
4066
+ if (next === null || next.childId === null) return;
4067
+ if (nextArchived !== false && canRestore) {
4068
+ void restoreAndOpen(next.childId, {
4069
+ failureText: '恢复新会话失败',
4070
+ restoredText: '新会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。',
4071
+ });
4072
+ return;
3644
4073
  }
3645
- }, [chain, openParentNow, parentArchived, restoreParent]);
4074
+ openTarget(next.childId, '打不开新会话');
4075
+ }, [canRestore, next, nextArchived, openTarget, restoreAndOpen]);
4076
+
4077
+ /** 完整链某一层:跳过去(旧会话默认已归档,同口径先恢复再切)。 */
4078
+ const openAncestor = useCallback(
4079
+ (target) => {
4080
+ if (typeof target !== 'string' || target.length === 0) return;
4081
+ setNotice(null);
4082
+ if (canRestore) {
4083
+ void restoreAndOpen(target, {
4084
+ failureText: '打不开旧会话',
4085
+ restoredText: '旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。',
4086
+ });
4087
+ return;
4088
+ }
4089
+ openTarget(target, '打不开旧会话');
4090
+ },
4091
+ [canRestore, openTarget, restoreAndOpen],
4092
+ );
3646
4093
 
3647
- // 无链 → 什么都不渲染(不占 composerStack 的高度)。
3648
- if (chain === null) return null;
4094
+ /**
4095
+ * 0.5.1 DCR-05:优先让服务端一次读出整条链(只读持久化日志,不加载祖先会话)。
4096
+ *
4097
+ * 返回 true = 端点可用且已把结果写进 chainView(成功或带 unreadable 的部分结果);
4098
+ * false = 老插件/老宿主没有这个端点、或响应形状不对 —— 调用方回落到旧的逐级读法。
4099
+ *
4100
+ * 为什么能解决「只显示 1 层」:宿主对未加载的会话返回 `sessionId:null`,旧逐级
4101
+ * GET 只能停住;新端点从持久化 header/log 里读,祖先没打开也能列全。
4102
+ *
4103
+ * @param {number} requestId 与 chainRequestRef 对齐的请求序号(晚到响应作废)
4104
+ * @returns {Promise<boolean>}
4105
+ */
4106
+ const loadChainViaServer = useCallback(
4107
+ async (requestId) => {
4108
+ let res = null;
4109
+ let data = null;
4110
+ try {
4111
+ res = await fetch(`${RELAY_CHAIN_ENDPOINT}?sessionId=${encodeURIComponent(sessionId)}`, {
4112
+ cache: 'no-store',
4113
+ });
4114
+ if (res.ok) data = await res.json().catch(() => null);
4115
+ } catch {
4116
+ res = null;
4117
+ data = null;
4118
+ }
4119
+ if (requestId !== chainRequestRef.current) return true;
4120
+ if (res === null || !res.ok || data === null || typeof data !== 'object' || data.ok !== true) return false;
4121
+ const rows = relayChainRowsOf(data);
4122
+ if (rows === null) return false;
4123
+ const unreadable = data.unreadable;
4124
+ const unreadableId =
4125
+ unreadable !== null && typeof unreadable === 'object' && typeof unreadable.sessionId === 'string'
4126
+ ? unreadable.sessionId
4127
+ : null;
4128
+ // not-found = 持久化里根本没有这个会话(已删除/从未落盘):打开也救不回来,
4129
+ // 只给「重试」,不造一个必然失败的入口(与 DCR-03 隐藏死入口同口径)。
4130
+ // error = 读日志临时失败:会话可能只是没加载/没落盘,允许「打开并继续」。
4131
+ const blocked =
4132
+ unreadableId !== null && unreadable.reason !== 'not-found'
4133
+ ? { sessionId: unreadableId, reason: 'error' }
4134
+ : null;
4135
+ // 服务端已经一次读完:这里没有逐级断点,重试就是重发一次整链请求。
4136
+ chainResumeRef.current = null;
4137
+ setChainView({
4138
+ rows,
4139
+ loading: false,
4140
+ error: unreadableId === null ? null : '无法读取更早的链路',
4141
+ done: unreadableId === null && data.done !== false,
4142
+ capped: data.capped === true,
4143
+ blocked,
4144
+ });
4145
+ return true;
4146
+ },
4147
+ [sessionId],
4148
+ );
3649
4149
 
3650
- const label = relayBarLabelOf(chain);
3651
- const docName = relayBarDocNameOf(chain.path);
3652
- const hasParent = chain.parentId !== null;
4150
+ /**
4151
+ * 0.5.1 批 3 / DCR-05:完整交接链 —— **按需**读祖先会话(不预加载、无常驻轮询)。
4152
+ *
4153
+ * 先走一次整链端点(见上);老宿主没有端点时回落逐级 state 读法:
4154
+ * 每一行来自「下一层(子)会话的入站 chain」,ordinal/baseTitle 正好是那一层会话
4155
+ * 改名后的「历史 N:原名」;交接单路径优先用它自己记录里的 path,缺了再向那一层
4156
+ * 会话的 state 要出站 next 的 path(和接力条补 path 同口径,childId/链号都要对得上)。
4157
+ *
4158
+ * 频率口径:点开「完整交接链」后才开始拉;回落路径每层一次 state GET(不并发、不轰炸);
4159
+ * 最多 `MAX_CHAIN_HOPS` 跳;`seen` 按 sessionId 去重(链路万一成环也不会转圈)。
4160
+ * 某层读不到就停在断点:已读到的行保留;未加载给「打开并继续」,其它失败给「重试」。
4161
+ *
4162
+ * @param {{cursor:object|null, childId:string, rows:Array, seen:Set<string>}|null} resume
4163
+ * 断点;null/undefined = 从本会话重新开始(先试整链端点)。
4164
+ */
4165
+ const loadChain = useCallback(
4166
+ async (resume) => {
4167
+ const requestId = ++chainRequestRef.current;
4168
+ // 0.5.1 DCR-05:先试一次整链端点;老宿主没有就回落逐级 state(下面的旧逻辑)。
4169
+ if (resume === undefined || resume === null) {
4170
+ const handled = await loadChainViaServer(requestId);
4171
+ if (handled) return;
4172
+ if (requestId !== chainRequestRef.current) return;
4173
+ }
4174
+ const start =
4175
+ resume === undefined || resume === null
4176
+ ? {
4177
+ cursor: chain === null ? null : { ...chain },
4178
+ childId: sessionId,
4179
+ rows: [],
4180
+ seen: new Set(typeof sessionId === 'string' && sessionId.length > 0 ? [sessionId] : []),
4181
+ }
4182
+ : resume;
4183
+ let cursor = start.cursor;
4184
+ let childId = start.childId;
4185
+ let seen = start.seen;
4186
+ let rows = start.rows;
4187
+ const publish = (patch) => {
4188
+ if (requestId !== chainRequestRef.current) return;
4189
+ setChainView({ rows, loading: true, error: null, done: false, capped: false, blocked: null, ...patch });
4190
+ };
4191
+ publish({});
4192
+ while (cursor !== null && cursor.parentId !== null) {
4193
+ if (rows.length >= MAX_CHAIN_HOPS) {
4194
+ publish({ loading: false, done: true, capped: true });
4195
+ return;
4196
+ }
4197
+ const parentId = cursor.parentId;
4198
+ if (seen.has(parentId)) {
4199
+ // 链路成环/自指:到此为止,不重复列同一条会话。
4200
+ publish({ loading: false, done: true, capped: false });
4201
+ return;
4202
+ }
4203
+ let data = null;
4204
+ try {
4205
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(parentId)}`, {
4206
+ cache: 'no-store',
4207
+ });
4208
+ if (requestId !== chainRequestRef.current) return;
4209
+ // 4xx(鉴权/端点不存在)与 5xx 一样当作「这一层读不到」:不再自动重试,
4210
+ // 交给用户的「重试」入口(自动重试只留给接力条自己那次 state)。
4211
+ if (res.ok) data = await res.json().catch(() => null);
4212
+ } catch {
4213
+ data = null;
4214
+ }
4215
+ if (requestId !== chainRequestRef.current) return;
4216
+ // 读不到 = HTTP/解析失败,或者**宿主还没把这一层加载进内存**(`sessionId` 对不上,
4217
+ // 重启后祖先会话通常就是这样 —— 不能把「没加载」当成「没有父会话」到头了)。
4218
+ // 两种都停在断点:已读到的行保留。宿主明确回 sessionId:null 时给出可操作入口
4219
+ // 「上一层会话未打开 → 打开并继续」(DCR-05 兜底);其它失败仍只给「重试」。
4220
+ const loaded =
4221
+ data !== null &&
4222
+ typeof data === 'object' &&
4223
+ data.ok !== false &&
4224
+ typeof data.sessionId === 'string' &&
4225
+ data.sessionId === parentId;
4226
+ if (!loaded) {
4227
+ chainResumeRef.current = { cursor, childId, rows, seen };
4228
+ const notLoaded =
4229
+ data !== null && typeof data === 'object' && data.ok !== false && data.sessionId === null;
4230
+ publish({
4231
+ loading: false,
4232
+ error: notLoaded ? '上一层会话未打开' : '无法读取更早的链路',
4233
+ done: false,
4234
+ blocked: notLoaded ? { sessionId: parentId, reason: 'unloaded' } : null,
4235
+ });
4236
+ return;
4237
+ }
4238
+ const outbound = relayBarNextOf(data, parentId);
4239
+ const path =
4240
+ cursor.path !== null
4241
+ ? cursor.path
4242
+ : outbound !== null && outbound.childId === childId && outbound.ordinal === cursor.ordinal
4243
+ ? outbound.path
4244
+ : null;
4245
+ rows = [
4246
+ ...rows,
4247
+ { sessionId: parentId, ordinal: cursor.ordinal, baseTitle: cursor.baseTitle, scheme: cursor.scheme, path },
4248
+ ];
4249
+ seen = new Set([...seen, parentId]);
4250
+ publish({});
4251
+ childId = parentId;
4252
+ // 这一层自己的入站 chain 才是「更早的链路」;没有就说明到头了。
4253
+ cursor = relayChainProjectionOf(data);
4254
+ }
4255
+ chainResumeRef.current = null;
4256
+ publish({ loading: false, done: true, capped: false });
4257
+ },
4258
+ [chain, loadChainViaServer, sessionId],
4259
+ );
4260
+
4261
+ const openChainList = useCallback(() => {
4262
+ setNotice(null);
4263
+ setDoc(null);
4264
+ chainResumeRef.current = null;
4265
+ void loadChain(null);
4266
+ }, [loadChain]);
4267
+
4268
+ /** 某层读不到之后的「重试」:从断点继续,已读到的层不重复拉。 */
4269
+ const retryChain = useCallback(() => {
4270
+ const resume = chainResumeRef.current;
4271
+ if (resume === null || resume === undefined) {
4272
+ void loadChain(null);
4273
+ return;
4274
+ }
4275
+ void loadChain(resume);
4276
+ }, [loadChain]);
4277
+
4278
+ /**
4279
+ * 0.5.1 DCR-05 兜底:等宿主把刚刚 openSession 的会话装进内存。
4280
+ *
4281
+ * 只读 `/api/compact-relay-state?sessionId=`,`sessionId` 对得上才算加载完成;
4282
+ * 最多 8 次(约 1 秒)。宿主打开会话需要一点时间,立刻续读会再次撞上「未加载」。
4283
+ * @param {string} target 刚打开的会话 id
4284
+ * @returns {Promise<boolean>}
4285
+ */
4286
+ const waitForSessionLoaded = useCallback(async (target) => {
4287
+ for (let attempt = 0; attempt < 8; attempt += 1) {
4288
+ try {
4289
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(target)}`, {
4290
+ cache: 'no-store',
4291
+ });
4292
+ if (res.ok) {
4293
+ const data = await res.json().catch(() => null);
4294
+ if (
4295
+ data !== null &&
4296
+ typeof data === 'object' &&
4297
+ data.ok !== false &&
4298
+ data.sessionId === target
4299
+ ) {
4300
+ return true;
4301
+ }
4302
+ }
4303
+ } catch {
4304
+ /* 宿主还没加载:继续等 */
4305
+ }
4306
+ if (attempt < 7) await new Promise((resolve) => setTimeout(resolve, 120));
4307
+ }
4308
+ return false;
4309
+ }, []);
4310
+
4311
+ /**
4312
+ * 0.5.1 DCR-05 兜底:断点处「打开并继续」。
4313
+ *
4314
+ * 1. 双通道 unarchive(复用现有;幂等,未归档时 no-op,失败也继续尝试 open);
4315
+ * 2. `openSession(parentId)` 打开上一层会话;
4316
+ * 3. 等宿主加载后自动 `loadChain(resume)`;加载不上就提示「已打开,再点完整交接链继续」。
4317
+ *
4318
+ * 注意:真实宿主打开会话会切路由,组件可能被卸载——此时后续 setState 是 no-op,
4319
+ * 不会报错;用户在新会话里点「完整交接链」也能接着看。
4320
+ */
4321
+ const openAndResumeChain = useCallback(async () => {
4322
+ const blocked = chainView === null ? null : chainView.blocked;
4323
+ if (blocked === null || blocked === undefined || typeof blocked.sessionId !== 'string') return;
4324
+ const target = blocked.sessionId;
4325
+ const resume = chainResumeRef.current;
4326
+ setNotice(null);
4327
+ setChainView((previous) =>
4328
+ previous === null ? previous : { ...previous, loading: true, error: null, blocked: null },
4329
+ );
4330
+
4331
+ // 先恢复(两条通道都试;都失败也不阻塞打开——也许本来就没归档)。
4332
+ for (const channel of unarchiveChannels(target)) {
4333
+ try {
4334
+ await channel();
4335
+ break;
4336
+ } catch {
4337
+ /* 换下一条通道;仍失败就直接尝试打开 */
4338
+ }
4339
+ }
4340
+
4341
+ if (typeof openSession !== 'function') {
4342
+ setNotice('这个宿主不支持切换会话,无法自动打开上一层会话。');
4343
+ setChainView((previous) =>
4344
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4345
+ );
4346
+ return;
4347
+ }
4348
+ try {
4349
+ await openSession(target);
4350
+ } catch (error) {
4351
+ setNotice('打开上一层会话失败:' + (error && error.message ? error.message : '未知错误'));
4352
+ setChainView((previous) =>
4353
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4354
+ );
4355
+ return;
4356
+ }
4357
+
4358
+ if (!(await waitForSessionLoaded(target))) {
4359
+ setNotice('已打开上一层会话,再点「完整交接链」继续。');
4360
+ setChainView((previous) =>
4361
+ previous === null ? previous : { ...previous, loading: false, error: '上一层会话未打开', blocked },
4362
+ );
4363
+ return;
4364
+ }
4365
+ await loadChain(resume);
4366
+ }, [chainView, loadChain, openSession, unarchiveChannels, waitForSessionLoaded]);
4367
+
4368
+ /** 从完整链返回主菜单(保留断点?不:回主菜单再从主菜单进来就是重新拉一遍)。 */
4369
+ const backFromChain = useCallback(() => {
4370
+ closeChainView();
4371
+ }, [closeChainView]);
4372
+
4373
+ // 两个方向都没有 → 什么都不渲染(不占 composerStack 的高度)。
4374
+ if (chain === null && next === null) return null;
4375
+
4376
+ const hasParent = chain !== null && chain.parentId !== null;
4377
+ const hasNext = next !== null && next.childId !== null;
4378
+ // 父会话侧(只有出站记录)这一行的摘要文案:它没有「已接力自」的来路。
4379
+ const label = chain !== null ? relayBarLabelOf(chain) : '已交接给新会话';
4380
+ // 「查看交接单」:子会话侧看进站那一跳,父会话侧看出站那一跳(不混用)。
4381
+ const docPath = chain !== null ? chain.path : next === null ? null : next.path;
4382
+ const docName = relayBarDocNameOf(docPath);
3653
4383
  // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
3654
4384
  // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
3655
- const showRestore = hasParent && parentArchived !== false;
4385
+ // 0.5.1 DCR-03:宿主客户端与服务端都没有恢复能力时**隐藏**入口(点了必然失败)。
4386
+ const showRestore = hasParent && parentArchived !== false && canRestore;
3656
4387
  const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
3657
4388
  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;
4389
+ const chainOpen = chainView !== null && doc === null;
4390
+ const menuWidth = Math.min(doc !== null ? 420 : chainOpen ? 336 : 272, Math.max(240, vw - 16));
4391
+ const estimatedHeight = doc !== null ? 360 : chainOpen ? 380 : 168;
3660
4392
  const anchorTop = anchor === null ? vh - 96 : anchor.top;
3661
4393
  const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
3662
4394
  const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
@@ -3717,6 +4449,192 @@ window.__ModuleLoader__.load({
3717
4449
  ),
3718
4450
  );
3719
4451
 
4452
+ /**
4453
+ * 完整链的一行:整行点「跳到这一层会话」,行尾单独一个「交接单」入口。
4454
+ *
4455
+ * 交接单路径只认 handoff-*.md 的 basename(与接力条同一套校验;接口只收
4456
+ * basename),没有 path 就禁用——不给必然失败的入口。
4457
+ * @param {{sessionId:string, ordinal:number|null, baseTitle:string|null, scheme:string|null, path:string|null}} row
4458
+ */
4459
+ const chainRowItemOf = (row) => {
4460
+ const rowDoc = relayBarDocNameOf(row.path);
4461
+ return h(
4462
+ 'div',
4463
+ {
4464
+ key: row.sessionId,
4465
+ 'data-cr-relay-chain-row': row.sessionId,
4466
+ style: { display: 'flex', alignItems: 'stretch', gap: 6, borderRadius: 8 },
4467
+ },
4468
+ h(
4469
+ 'button',
4470
+ {
4471
+ type: 'button',
4472
+ 'data-cr-relay-chain-open': row.sessionId,
4473
+ title: `打开 ${relayBarLabelOf(row)}`,
4474
+ onClick: () => openAncestor(row.sessionId),
4475
+ style: {
4476
+ flex: 1,
4477
+ minWidth: 0,
4478
+ display: 'flex',
4479
+ flexDirection: 'column',
4480
+ gap: 1,
4481
+ padding: '5px 8px',
4482
+ border: 'none',
4483
+ borderRadius: 8,
4484
+ background: 'transparent',
4485
+ textAlign: 'left',
4486
+ color: 'var(--dsw-alias-label-primary, inherit)',
4487
+ font: 'inherit',
4488
+ cursor: 'pointer',
4489
+ },
4490
+ onMouseEnter: (event) => {
4491
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
4492
+ },
4493
+ onMouseLeave: (event) => {
4494
+ event.currentTarget.style.background = 'transparent';
4495
+ },
4496
+ },
4497
+ h('span', { style: { fontSize: 12, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, relayBarLabelOf(row)),
4498
+ h(
4499
+ 'span',
4500
+ { style: { fontSize: 10, lineHeight: '14px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
4501
+ '打开这个会话(已归档会自动恢复)',
4502
+ ),
4503
+ ),
4504
+ h(
4505
+ 'button',
4506
+ {
4507
+ type: 'button',
4508
+ 'data-cr-relay-chain-doc': row.sessionId,
4509
+ disabled: rowDoc === null,
4510
+ title: rowDoc === null ? '暂无交接单:这一层没有记录交接单路径' : '打开这一跳的交接单',
4511
+ onClick: () => {
4512
+ if (rowDoc !== null) void loadDoc(rowDoc);
4513
+ },
4514
+ style: {
4515
+ flex: 'none',
4516
+ alignSelf: 'center',
4517
+ border: `1px solid ${theme.panelBorder}`,
4518
+ borderRadius: 6,
4519
+ padding: '3px 8px',
4520
+ fontSize: 11,
4521
+ background: 'transparent',
4522
+ color: 'inherit',
4523
+ cursor: rowDoc === null ? 'default' : 'pointer',
4524
+ opacity: rowDoc === null ? 0.55 : 1,
4525
+ },
4526
+ },
4527
+ '交接单',
4528
+ ),
4529
+ );
4530
+ };
4531
+
4532
+ /**
4533
+ * 完整交接链面板(点「完整交接链」后才出现;doc === null 时替换主列表)。
4534
+ *
4535
+ * 数据是按需逐级读来的(见 loadChain):读取中显示进度行,读不到显示错误 +
4536
+ * 重试,到 8 跳上限显示「只显示最近 8 层」。「← 返回」回主菜单 —— 出错也只
4537
+ * 影响这一块,不阻断其他入口。
4538
+ */
4539
+ const chainPanel =
4540
+ chainView === null
4541
+ ? null
4542
+ : h(
4543
+ 'div',
4544
+ { 'data-cr-relay-chain': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
4545
+ h(
4546
+ 'div',
4547
+ {
4548
+ style: {
4549
+ display: 'flex',
4550
+ alignItems: 'center',
4551
+ gap: 8,
4552
+ padding: '7px 10px',
4553
+ borderBottom: `1px solid ${theme.panelBorder}`,
4554
+ fontSize: 12,
4555
+ fontWeight: 600,
4556
+ },
4557
+ },
4558
+ h('span', { style: { flex: 1, minWidth: 0 } }, '完整交接链'),
4559
+ h(
4560
+ 'button',
4561
+ {
4562
+ type: 'button',
4563
+ 'data-cr-relay-chain-back': '1',
4564
+ onClick: backFromChain,
4565
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4566
+ },
4567
+ '← 返回',
4568
+ ),
4569
+ ),
4570
+ h(
4571
+ 'div',
4572
+ { style: { display: 'flex', flexDirection: 'column', gap: 2, padding: '4px 5px', overflowY: 'auto', overflowX: 'hidden', maxHeight: 'min(46vh, 300px)' } },
4573
+ chainView.rows.map((row) => chainRowItemOf(row)),
4574
+ chainView.loading === true
4575
+ ? h(
4576
+ 'div',
4577
+ { 'data-cr-relay-chain-loading': '1', style: { padding: '5px 10px', fontSize: 11, color: theme.subText } },
4578
+ '读取更早的链路…',
4579
+ )
4580
+ : null,
4581
+ chainView.capped === true
4582
+ ? h(
4583
+ 'div',
4584
+ { 'data-cr-relay-chain-capped': '1', style: { padding: '5px 10px', fontSize: 10, color: theme.subText } },
4585
+ `只显示最近 ${MAX_CHAIN_HOPS} 层`,
4586
+ )
4587
+ : null,
4588
+ chainView.error !== null
4589
+ ? h(
4590
+ 'div',
4591
+ {
4592
+ 'data-cr-relay-chain-error': '1',
4593
+ // 0.5.1 DCR-05:断点处是哪一层没读到(空串 = 普通读取失败,没有可打开的会话)。
4594
+ 'data-cr-relay-chain-blocked':
4595
+ chainView.blocked === null || chainView.blocked === undefined
4596
+ ? ''
4597
+ : chainView.blocked.sessionId,
4598
+ style: {
4599
+ display: 'flex',
4600
+ alignItems: 'center',
4601
+ gap: 8,
4602
+ padding: '6px 10px',
4603
+ fontSize: 11,
4604
+ color: 'var(--dsw-alias-state-error-primary, #D92D20)',
4605
+ },
4606
+ },
4607
+ h('span', { style: { flex: 1, minWidth: 0 } }, String(chainView.error)),
4608
+ chainView.blocked !== null && chainView.blocked !== undefined
4609
+ ? h(
4610
+ 'button',
4611
+ {
4612
+ type: 'button',
4613
+ 'data-cr-relay-chain-open-parent': chainView.blocked.sessionId,
4614
+ onClick: () => {
4615
+ void openAndResumeChain();
4616
+ },
4617
+ title: `打开上一层会话 ${chainView.blocked.sessionId},加载后自动续读`,
4618
+ style: { flex: 'none', border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4619
+ },
4620
+ '打开并继续',
4621
+ )
4622
+ : null,
4623
+ h(
4624
+ 'button',
4625
+ {
4626
+ type: 'button',
4627
+ 'data-cr-relay-chain-retry': '1',
4628
+ onClick: retryChain,
4629
+ style: { flex: 'none', border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4630
+ },
4631
+ '重试',
4632
+ ),
4633
+ )
4634
+ : null,
4635
+ ),
4636
+ );
4637
+
3720
4638
  const menu =
3721
4639
  open === true
3722
4640
  ? h(
@@ -3735,7 +4653,7 @@ window.__ModuleLoader__.load({
3735
4653
  display: 'flex',
3736
4654
  flexDirection: 'column',
3737
4655
  overflow: 'hidden',
3738
- padding: doc === null ? 5 : 0,
4656
+ padding: doc === null && chainView === null ? 5 : 0,
3739
4657
  borderRadius: 12,
3740
4658
  ...panelChrome(theme),
3741
4659
  color: theme.panelFg,
@@ -3750,7 +4668,9 @@ window.__ModuleLoader__.load({
3750
4668
  notice,
3751
4669
  )
3752
4670
  : null,
4671
+ // 交接单面板 / 完整链面板都替换主列表;「← 返回」按来源回到链列表或主菜单。
3753
4672
  docPanel ??
4673
+ chainPanel ??
3754
4674
  h(
3755
4675
  'div',
3756
4676
  { style: { display: 'flex', flexDirection: 'column' } },
@@ -3762,11 +4682,20 @@ window.__ModuleLoader__.load({
3762
4682
  right: docName === null ? '暂无交接单' : '›',
3763
4683
  title: docName === null ? '暂无交接单:这条接力记录里没有交接单路径(旧记录,或父会话已不在)' : '打开交接单',
3764
4684
  }),
3765
- relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
3766
- disabled: !hasParent,
3767
- right: hasParent ? '›' : '不可用',
3768
- title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
3769
- }),
4685
+ // 0.5.1 批 3(TD-01):父会话侧的去向 —— 出站记录里的 childId。
4686
+ hasNext
4687
+ ? relayBarItem('去新会话', '切到这次交接出来的新会话', 'next', goNext, {
4688
+ right: '›',
4689
+ title: `打开新会话 ${next.childId}${nextArchived === true ? '(已归档:打开前先恢复)' : ''}`,
4690
+ })
4691
+ : null,
4692
+ chain !== null
4693
+ ? relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
4694
+ disabled: !hasParent,
4695
+ right: hasParent ? '›' : '不可用',
4696
+ title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
4697
+ })
4698
+ : null,
3770
4699
  showRestore
3771
4700
  ? relayBarItem(
3772
4701
  '恢复旧会话',
@@ -3775,7 +4704,7 @@ window.__ModuleLoader__.load({
3775
4704
  : '宿主没提供归档状态;恢复调用是幂等的,仍可直接尝试',
3776
4705
  'unarchive',
3777
4706
  () => {
3778
- void restoreParent();
4707
+ void restoreAndOpen(chain.parentId, { markParent: true });
3779
4708
  },
3780
4709
  {
3781
4710
  right: parentArchived === true ? '已归档' : '恢复',
@@ -3783,6 +4712,12 @@ window.__ModuleLoader__.load({
3783
4712
  },
3784
4713
  )
3785
4714
  : null,
4715
+ hasParent
4716
+ ? relayBarItem('完整交接链', '一次读取整条链,回看前几次会话与交接单', 'chain', openChainList, {
4717
+ right: '›',
4718
+ title: `完整交接链:一次读取整条链(最多 ${MAX_CHAIN_HOPS} 层;祖先未加载也能读)`,
4719
+ })
4720
+ : null,
3786
4721
  relayBarItem('路线图(即将上线)', '跨会话时间线,0.6 之后', 'roadmap', null, {
3787
4722
  disabled: true,
3788
4723
  right: '即将上线',
@@ -3830,15 +4765,18 @@ window.__ModuleLoader__.load({
3830
4765
  type: 'button',
3831
4766
  ref: triggerRef,
3832
4767
  '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 ?? '',
4768
+ 'data-cr-relay-ordinal': String(chain !== null ? chain.ordinal : next.ordinal),
4769
+ 'data-cr-relay-scheme': (chain !== null ? chain.scheme : next.scheme) ?? '',
4770
+ 'data-cr-relay-parent': chain === null || chain.parentId === null ? '' : chain.parentId,
4771
+ 'data-cr-relay-path': docPath ?? '',
3837
4772
  // 空串 = 宿主没给归档状态(老宿主),'true'/'false' = readState.parentArchived。
3838
4773
  'data-cr-relay-parent-archived': parentArchived === null ? '' : String(parentArchived),
4774
+ // 0.5.1 批 3:出站目标(父会话侧「去新会话」)与它的归档状态。
4775
+ 'data-cr-relay-next': hasNext ? next.childId : '',
4776
+ 'data-cr-relay-next-archived': nextArchived === null ? '' : String(nextArchived),
3839
4777
  'aria-haspopup': 'menu',
3840
4778
  'aria-expanded': open,
3841
- 'aria-label': `已接力自 ${label},打开接力菜单`,
4779
+ 'aria-label': chain !== null ? `已接力自 ${label},打开接力菜单` : `${label},打开接力菜单`,
3842
4780
  onClick: toggleMenu,
3843
4781
  style: {
3844
4782
  display: 'flex',
@@ -3866,7 +4804,8 @@ window.__ModuleLoader__.load({
3866
4804
  'span',
3867
4805
  { style: { flex: 'none', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3868
4806
  // 末尾留一个空格:flex gap 负责视觉间距,文本内容(读屏/可访问性树)也要读得通。
3869
- '已接力自 ',
4807
+ // 父会话侧没有「来路」,只显示「已交接给新会话」(见 label)。
4808
+ chain !== null ? '已接力自 ' : '',
3870
4809
  ),
3871
4810
  h(
3872
4811
  'span',