dsh-compact-relay 0.4.2 → 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
@@ -2,8 +2,10 @@
2
2
  * dsh-compact-relay —— 浏览器半(DSH client bundle)。
3
3
  *
4
4
  * 职责:输入框左侧指示灯 / 悬停卡 / 菜单 / 压缩确认卡 / 交接单面板 / toast,
5
+ * 输入框上方接力条(0.5.0:已接力自 … + 下拉菜单),
5
6
  * 以及 /handoff 命令卡(命令成功后自动切到新会话)。
6
- * 数据: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 整条接力链,一次读取);
7
9
  * 动作:POST /api/compact-relay-action;交接单:GET /api/compact-relay-handoffs。
8
10
  *
9
11
  * 为什么是单文件:DSH 运行时没有打包器,package.json exports["./client"]
@@ -1089,7 +1091,20 @@ window.__ModuleLoader__.load({
1089
1091
  function CompactRelayIndicator(props) {
1090
1092
  const { sessionId, waitAndOpen } = props;
1091
1093
  const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
1092
- 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
+ });
1093
1108
  // 0.4 P2-3:原生自动压缩的「有效线」——优先真实触发线(thresholdTokens/window),
1094
1109
  // 老宿主/算不出时回落 thresholdRatio(窗口比例)。
1095
1110
  const effectiveNativeRatio = (native) => {
@@ -1159,6 +1174,8 @@ window.__ModuleLoader__.load({
1159
1174
  const [handoffPreview, setHandoffPreview] = useState(null);
1160
1175
  /** 0.4 P2-1:交接预览里「约束 / 下一步」两段的编辑值(键=段标题;空值回落原文)。 */
1161
1176
  const [handoffEditValues, setHandoffEditValues] = useState({});
1177
+ // 0.5.1 批 2:交接后是否归档旧会话(默认开 = 0.5.0 行为;可取消)。
1178
+ const [archiveWanted, setArchiveWanted] = useState(true);
1162
1179
  const handoffPreviewRef = useRef(null);
1163
1180
  const handoffPreviewAbortRef = useRef(null);
1164
1181
  const closeHandoffPreview = useCallback(() => {
@@ -1265,15 +1282,35 @@ window.__ModuleLoader__.load({
1265
1282
  ? {
1266
1283
  count: Number(data.pins.count) || 0,
1267
1284
  seqs: Array.isArray(data.pins.seqs) ? data.pins.seqs.filter((seq) => Number.isInteger(seq)) : [],
1268
- // 0.3.1:锁定消息在历史里的相对位置(0–1)——环上按位置画块。
1285
+ // 0.3.1 起下发、0.5.1 起环上不再画:锁定消息在历史里的相对位置(0–1)。
1286
+ // 位置标记归 0.6 时间线(数据面保留,0.6 的 timeline 直接消费)。
1269
1287
  marks: Array.isArray(data.pins.marks)
1270
1288
  ? data.pins.marks
1271
1289
  .filter((item) => item !== null && typeof item === 'object' && typeof item.ratio === 'number' && Number.isFinite(item.ratio))
1272
1290
  .map((item) => ({ seq: Number.isInteger(item.seq) ? item.seq : -1, ratio: Math.max(0, Math.min(1, item.ratio)) }))
1273
1291
  .slice(0, 20)
1274
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,
1275
1312
  }
1276
- : { count: 0, seqs: [], marks: [] },
1313
+ : { count: 0, seqs: [], marks: [], stale: { count: 0, seqs: [], native: 0, other: 0, unknown: 0 }, atRisk: null },
1277
1314
  // 0.3.1:模型判断的「这里适合压缩」位置(0–1);没有或已失效为 null。
1278
1315
  compressHint:
1279
1316
  data.compressHint && typeof data.compressHint === 'object' && typeof data.compressHint.ratio === 'number' && Number.isFinite(data.compressHint.ratio)
@@ -1333,6 +1370,8 @@ window.__ModuleLoader__.load({
1333
1370
  // 0.3.5:范围存在但会话没有活 agent —— 菜单据此显示「会话未激活…」而不是
1334
1371
  // 「暂无可压缩内容」(applyState 只保留白名单字段,新字段必须显式透传)。
1335
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,
1336
1375
  });
1337
1376
  }, []);
1338
1377
 
@@ -1503,6 +1542,29 @@ window.__ModuleLoader__.load({
1503
1542
 
1504
1543
  // M6:锁定集合(宿主下发的 count/seqs;默认空)。
1505
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;
1506
1568
  // 0.3.1:阶段标记功能已删(作者拍板),ring level 只看建议。
1507
1569
  const level = typeof state.level === 'string' ? state.level : adviseHandoff ? 'handoff' : adviseCompact ? 'compact' : 'idle';
1508
1570
 
@@ -1644,24 +1706,9 @@ window.__ModuleLoader__.load({
1644
1706
  }
1645
1707
  // 平时给中心一点可见的灰点;交接时红点(外环已分段,红点强调该交接)。
1646
1708
  const dotColor = adviseHandoff ? palette.handoff : palette.idle;
1647
- // 锁定消息在环上的块(方案 A:按消息在历史里的相对位置)。这是「保护内容」的信号,
1648
- // 权重最高:用最显眼的反差色、比外环略粗;多个锁定全部显示。
1649
- const pinMarks = state.pins && Array.isArray(state.pins.marks) ? state.pins.marks : [];
1650
- const pinBlocks = pinMarks.map((mark, index) =>
1651
- h('circle', {
1652
- key: `pin-${mark.seq ?? index}-${index}`,
1653
- cx: 10,
1654
- cy: 10,
1655
- r: RADIUS,
1656
- fill: 'none',
1657
- stroke: palette.pin,
1658
- strokeWidth: 2.9,
1659
- strokeLinecap: 'round',
1660
- strokeDasharray: `2.8 ${CIRC}`,
1661
- transform: `rotate(${-90 + mark.ratio * 360} 10 10)`,
1662
- 'data-cr-pin-mark': String(index),
1663
- }),
1664
- );
1709
+ // 0.5.1 设计定稿(用户拍板):环只表达容量,不再画 pin 位置块。
1710
+ // 位置标记(state.pins.marks)归 0.6 时间线:数据面仍读取(见上方状态归一化),
1711
+ // 环上不画;锁定状态只用悬停卡/菜单的「已锁定 N 条 / 已失效 M 条」表达。
1665
1712
 
1666
1713
  // 忙碌 = 本组件正在跑动作,或宿主侧锁已占用(另一个窗口在压/接)。
1667
1714
  const hostBusy = state.busy === true;
@@ -1705,7 +1752,7 @@ window.__ModuleLoader__.load({
1705
1752
  nativeRatio === null
1706
1753
  ? 'DSH 原生自动压缩已关闭'
1707
1754
  : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(1)}%,到那之前先压缩或交接更稳`;
1708
- return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉。`;
1755
+ return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉(原生压缩不认锁定,真被压走会标「已失效」)。交接后会默认自动归档旧会话(侧栏收起,可取消勾选「交接后归档」;旧会话在侧栏置顶时会先问你,因为归档会取消置顶)。`;
1709
1756
  }, [state.native]);
1710
1757
 
1711
1758
  /** 播放一次引导:呼吸光晕 + 提示。手动重播用更长时间(用户要关掉设置页才看得到)。 */
@@ -1756,6 +1803,19 @@ window.__ModuleLoader__.load({
1756
1803
  return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
1757
1804
  }, [playOnboarding]);
1758
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
+
1759
1819
  // 0.4.2(P1):换模型后首次发现「交接线已晚」弹一次提示(同路由不重复打扰)。
1760
1820
  // 只在「观察到路由变化」(localStorage 记着上一次的 provider/model)时弹;
1761
1821
  // 首次见到某路由不弹(不是换模型导致的),设置页有常驻提示兜底。
@@ -1851,6 +1911,7 @@ window.__ModuleLoader__.load({
1851
1911
  closeDocs();
1852
1912
  closeDiff();
1853
1913
  setHandoffEditValues({});
1914
+ setArchiveWanted(true);
1854
1915
  setHandoffPreview({ loading: true });
1855
1916
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1856
1917
  handoffPreviewAbortRef.current = controller;
@@ -2080,7 +2141,19 @@ window.__ModuleLoader__.load({
2080
2141
  showToast('交接单已生成,正在打开新会话…', 0);
2081
2142
  try {
2082
2143
  await waitAndOpen(data.childId);
2083
- 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
+ );
2084
2157
  } catch (error) {
2085
2158
  showToast(`交接单已生成,但自动切换新会话失败:${error && error.message ? error.message : '未知原因'}(新会话 ${data.childId})`, 8000);
2086
2159
  }
@@ -2130,6 +2203,8 @@ window.__ModuleLoader__.load({
2130
2203
  (nativeLineText !== '' ? `|${nativeLineText}` : '') +
2131
2204
  (handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
2132
2205
  (pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
2206
+ (pinStaleCount > 0 ? `|${staleLabel}` : '') +
2207
+ (pinRisk !== null ? `|⚠ ${pinRiskLabel}(建议先交接或解锁)` : '') +
2133
2208
  (effectiveBusy ? '|处理中…' : '');
2134
2209
 
2135
2210
  // 浮层定位:fixed(不受 composer 裁剪)+ 视口左右收边 + 上方空间不够时翻到下方。
@@ -2188,6 +2263,18 @@ window.__ModuleLoader__.load({
2188
2263
  { style: { color: theme.subText } },
2189
2264
  `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
2190
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,
2191
2278
  // 0.4.2:交接线晚于原生压缩时,悬停卡里给一行显式警示(只告警,不改设置)。
2192
2279
  handoffWarnText !== ''
2193
2280
  ? h('div', { 'data-cr-handoff-warn-text': '1', style: { marginTop: 2, color: palette.handoff } }, handoffWarnText)
@@ -2216,8 +2303,7 @@ window.__ModuleLoader__.load({
2216
2303
  ...nativeNodes,
2217
2304
  // 0.4.2:交接线已晚的警示标记(交接刻度旁的小短线 + 小点,不改分段逻辑)。
2218
2305
  ...handoffWarnNodes,
2219
- // 0.3.1:锁定消息在环上的块(品红,按历史位置)。
2220
- ...pinBlocks,
2306
+ // 0.5.1 定稿:pin 位置块不画(位置标记归 0.6 时间线;环只表达容量)。
2221
2307
  );
2222
2308
 
2223
2309
  const trigger = h(
@@ -2254,6 +2340,9 @@ window.__ModuleLoader__.load({
2254
2340
  'data-cr-state-session': typeof state.sessionId === 'string' ? state.sessionId : '',
2255
2341
  'data-cr-summaries': String(state.summaries),
2256
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),
2257
2346
  style: {
2258
2347
  display: 'inline-flex',
2259
2348
  alignItems: 'center',
@@ -2345,6 +2434,16 @@ window.__ModuleLoader__.load({
2345
2434
  );
2346
2435
  };
2347
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;
2348
2447
  // undefined=宿主没上报(老版本/未知);null=明确没有可压范围;object=可压多少。
2349
2448
  const rangeInfo = state.compactRange;
2350
2449
  // 0.3.5:有可压范围、但会话还没有活 agent(passivized live session)——preview 需要
@@ -2405,16 +2504,28 @@ window.__ModuleLoader__.load({
2405
2504
  },
2406
2505
  ),
2407
2506
  item(
2408
- pins.count > 0 ? `解锁消息(${pins.count} 条)` : '锁定重要消息',
2507
+ pins.count > 0
2508
+ ? `解锁消息(${pins.count} 条)`
2509
+ : pinStaleCount > 0
2510
+ ? `解除失效锁定(${pinStaleCount} 条)`
2511
+ : '锁定重要消息',
2409
2512
  pins.count > 0
2410
2513
  ? '解除锁定,允许后续压缩合并这些消息'
2411
- : '锁定的消息压缩时不会碰(重要结论/路径先锁住,也可 /pin list 挑一条)',
2514
+ : pinStaleCount > 0
2515
+ ? `${staleLabel}:原文已不在当前上下文,点此清空锁定记录`
2516
+ : '锁定的消息压缩时不会碰(重要结论/路径先锁住,也可 /pin list 挑一条)',
2412
2517
  'pin',
2413
2518
  null,
2414
2519
  hasSession
2415
2520
  ? {
2416
2521
  oneLine: true,
2417
- 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 } : {}),
2418
2529
  onClick: () => {
2419
2530
  void togglePins();
2420
2531
  },
@@ -2432,7 +2543,8 @@ window.__ModuleLoader__.load({
2432
2543
  void startHandoffPreview();
2433
2544
  },
2434
2545
  // 0.4.2(P1):交接线晚于原生压缩时,菜单项旁直接标出来(只告警,仍可点)。
2435
- ...(handoffLate ? { note: handoffWarnText, title: handoffGuardInfo.message } : {}),
2546
+ // 0.5.1 批 2:原生压缩预计覆盖 pin 时,这里优先提示「先交接保留」。
2547
+ ...(handoffItemNote !== '' ? { note: handoffItemNote, ...(handoffItemTitle === undefined ? {} : { title: handoffItemTitle }) } : {}),
2436
2548
  }
2437
2549
  : { disabled: true, note: '会话刚打开,请稍候' },
2438
2550
  ),
@@ -2620,6 +2732,27 @@ window.__ModuleLoader__.load({
2620
2732
  const handoffData = handoffPreview !== null && handoffPreview.loading !== true ? handoffPreview.data : null;
2621
2733
  const handoffReadiness = handoffData !== null && typeof handoffData === 'object' ? handoffData.readiness : null;
2622
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
+ };
2623
2756
  const handoffPanel =
2624
2757
  handoffPreview === null
2625
2758
  ? null
@@ -2734,10 +2867,53 @@ window.__ModuleLoader__.load({
2734
2867
  h('div', { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } }, handoffData.editable.note ?? '程序生成的段不可改。'),
2735
2868
  )
2736
2869
  : null,
2737
- 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,
2738
2909
  h(
2739
2910
  'div',
2740
- { 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' } },
2741
2917
  h(
2742
2918
  'button',
2743
2919
  {
@@ -2748,25 +2924,25 @@ window.__ModuleLoader__.load({
2748
2924
  },
2749
2925
  '取消',
2750
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,
2751
2940
  h(
2752
2941
  'button',
2753
2942
  {
2754
2943
  type: 'button',
2755
2944
  'data-cr-handoff-run': '1',
2756
- onClick: () => {
2757
- // P2-1:只把「用户真的改过、且非空」的两段发给宿主;空值/未改 = 用重新生成的原文。
2758
- const editable = handoffData !== null && typeof handoffData === 'object' ? handoffData.editable : null;
2759
- const edits = {};
2760
- if (editable !== null && typeof editable === 'object' && Array.isArray(editable.titles)) {
2761
- for (const title of editable.titles) {
2762
- const original = typeof editable.sections?.[title] === 'string' ? editable.sections[title].trim() : '';
2763
- const current = typeof handoffEditValues[title] === 'string' ? handoffEditValues[title].trim() : '';
2764
- if (current.length > 0 && current !== original) edits[title] = current;
2765
- }
2766
- }
2767
- closeHandoffPreview();
2768
- void run('handoff', Object.keys(edits).length > 0 ? { edits } : undefined);
2769
- },
2945
+ onClick: () => beginHandoff(sessionPinned && archiveWanted ? { archive: true, confirmUnpin: true } : { archive: archiveWanted }),
2770
2946
  style: {
2771
2947
  border: 'none',
2772
2948
  borderRadius: 7,
@@ -2778,7 +2954,11 @@ window.__ModuleLoader__.load({
2778
2954
  color: '#ffffff',
2779
2955
  },
2780
2956
  },
2781
- '确认交接',
2957
+ sessionPinned && archiveWanted
2958
+ ? '交接并归档(取消置顶)'
2959
+ : archiveWanted
2960
+ ? '确认交接(归档旧会话)'
2961
+ : '确认交接(不归档)',
2782
2962
  ),
2783
2963
  ),
2784
2964
  ),
@@ -3141,194 +3321,1718 @@ window.__ModuleLoader__.load({
3141
3321
  );
3142
3322
  }
3143
3323
 
3144
- /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
3145
- function handoffRow(text, tone, action) {
3146
- return h(
3147
- 'div',
3148
- {
3149
- style: {
3150
- display: 'flex',
3151
- alignItems: 'center',
3152
- gap: 8,
3153
- padding: '4px 2px',
3154
- fontSize: 12,
3155
- color: tone === 'error' ? COLORS.error : COLORS.subText,
3156
- },
3157
- },
3158
- h('span', { style: { flex: 1, minWidth: 0 } }, text),
3159
- action
3160
- ? h(
3161
- 'button',
3162
- {
3163
- type: 'button',
3164
- onClick: action,
3165
- style: {
3166
- flex: 'none',
3167
- border: 'none',
3168
- borderRadius: 6,
3169
- padding: '2px 8px',
3170
- background: COLORS.hover,
3171
- color: 'inherit',
3172
- fontSize: 11,
3173
- cursor: 'pointer',
3174
- },
3175
- },
3176
- '重试打开',
3177
- )
3178
- : null,
3179
- );
3180
- }
3324
+ /* ---------------- 0.5.0 接力条(v1):输入框上方一行「已接力自 …」 ---------------- */
3181
3325
 
3182
3326
  /**
3183
- * /handoff 命令卡:命令成功后自动切到新会话。
3184
- * 这个槽把命令的通用卡片替换成我们的卡片(只有 /handoff 这一个 key)。
3327
+ * 0.5.0 接力条 v1 —— 只做「一行摘要 + 向上弹出的下拉菜单」。
3328
+ *
3329
+ * 位置:`conversation.input.dock`(list / session scope;宿主文档原文
3330
+ * “Full-width entries above the composer card”,即输入卡上方通栏)。
3331
+ * 显示:`readState.chain` 非 null,且这条记录是**本会话的入站记录**
3332
+ * (childId === sessionId)。出站记录(本会话交接给了下一代)不显示
3333
+ * 「已接力自」;老宿主/没有 chain 字段时组件返回 null,不占高度。
3334
+ * 数据:GET `/api/compact-relay-state?sessionId=…`;
3335
+ * 完整链优先 GET `/api/compact-relay-chain?sessionId=…`(一次读全,祖先未加载也行),
3336
+ * 老宿主回落逐级 state。会话刚打开、宿主还没装日志
3337
+ * 时短重试几次(拿到本会话的 sessionId 后就停,不做常驻轮询/SSE)。
3338
+ * 子会话的 relay record 不带 path(F3 只写链号),缺 path 时再只读一次父
3339
+ * 会话状态,用父记录里的 path 打开交接单(childId/ordinal 校验)。
3340
+ * 交互:点击这一行 → fixed 定位、向上弹出的菜单(不被输入区裁切):
3341
+ * 查看交接单 / 回到原会话 / 路线图(即将上线,占位禁用)。
3185
3342
  */
3186
- function HandoffCard({ node, waitAndOpen }) {
3187
- const outcome = node !== null && typeof node === 'object' ? node.outcome : undefined;
3188
- const outcomeText = outcome !== null && typeof outcome === 'object' && typeof outcome.text === 'string' ? outcome.text : '';
3189
- // A5:优先用命令结果里的结构化 childId;旧宿主没带字段时再从文本 #child= 兜底。
3190
- const childId =
3191
- outcome !== null && typeof outcome === 'object' && typeof outcome.childId === 'string' && outcome.childId.length > 0
3192
- ? outcome.childId
3193
- : extractChildId(outcomeText);
3194
- // P2-C1:区分 opening / opened / failed;刷新后只有 localStorage='1' 才算已打开,
3195
- // 'opening'(上次没走完)要重新尝试,不能直接渲染成功态。
3196
- const [status, setStatus] = useState(() => {
3197
- if (outcome === undefined) return 'running';
3198
- if (outcome !== null && outcome.kind === 'error') return 'error';
3199
- if (childId === null) return 'done';
3200
- return wasOpened(childId) ? 'done' : 'opening';
3201
- });
3202
- const [detail, setDetail] = useState(null);
3203
3343
 
3204
- const openChild = useCallback(() => {
3205
- if (childId === null) return;
3206
- setStatus('opening');
3207
- setDetail(null);
3208
- waitAndOpen(childId).then(
3209
- () => {
3210
- markOpened(childId, '1');
3211
- setStatus('done');
3212
- },
3213
- (error) => {
3214
- clearOpened(childId);
3215
- setStatus('failed');
3216
- setDetail(String(error && error.message ? error.message : error));
3217
- },
3218
- );
3219
- }, [childId, waitAndOpen]);
3344
+ /**
3345
+ * readState.chain → 接力条可用的最小结构;无效时返回 null(不猜、不补默认值)。
3346
+ *
3347
+ * 只接受 ordinal 为正整数的链;baseTitle/parentId/path 缺了就保持 null,
3348
+ * 由 UI 决定禁用或回落文案。老宿主的响应里没有 chain 字段 → null → 不渲染。
3349
+ *
3350
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3351
+ * @param {string|undefined} sessionId 当前会话 id(dock 的 session scope 参数)
3352
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3353
+ */
3354
+ function relayBarChainOf(data, sessionId) {
3355
+ const chain = relayChainProjectionOf(data);
3356
+ if (chain === null) return null;
3357
+ // 出站记录(本会话把接力棒交给下一代):它不是「已接力自」,v1 不显示。
3358
+ // childId 缺失时(旧链路记录形态)不据此隐藏,遵守「chain 非 null 就显示」。
3359
+ if (chain.childId !== null && typeof sessionId === 'string' && sessionId.length > 0 && chain.childId !== sessionId) return null;
3360
+ return chain;
3361
+ }
3220
3362
 
3221
- useEffect(() => {
3222
- if (outcome === undefined || outcome === null || outcome.kind !== 'success' || childId === null) return;
3223
- if (wasOpened(childId)) {
3224
- setStatus('done');
3225
- return;
3226
- }
3227
- markOpened(childId, 'opening');
3228
- let alive = true;
3229
- waitAndOpen(childId).then(
3230
- () => {
3231
- markOpened(childId, '1');
3232
- if (alive) setStatus('done');
3233
- },
3234
- (error) => {
3235
- clearOpened(childId);
3236
- if (!alive) return;
3237
- setStatus('failed');
3238
- setDetail(String(error && error.message ? error.message : error));
3239
- },
3240
- );
3241
- return () => {
3242
- alive = false;
3243
- };
3244
- }, [childId, outcome, waitAndOpen]);
3363
+ /**
3364
+ * 只做字段投影,不做「是不是本会话入站记录」的判断。
3365
+ *
3366
+ * 单独拆出来是给「父记录补 path」用:父会话里的同一条记录是出站的
3367
+ * (childId=本会话),用 relayBarChainOf 会被入站判断过滤掉,但它的 path
3368
+ * 恰恰是子会话要打开的那份交接单。
3369
+ *
3370
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3371
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3372
+ */
3373
+ function relayChainProjectionOf(data) {
3374
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3375
+ const chain = data.chain;
3376
+ if (chain === null || typeof chain !== 'object') return null;
3377
+ if (!Number.isInteger(chain.ordinal) || chain.ordinal <= 0) return null;
3378
+ const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
3379
+ return {
3380
+ ordinal: chain.ordinal,
3381
+ baseTitle: text(chain.baseTitle),
3382
+ parentId: text(chain.parentId),
3383
+ childId: text(chain.childId),
3384
+ scheme: chain.scheme === 'handoff-n' || chain.scheme === 'history-n' ? chain.scheme : null,
3385
+ path: text(chain.path),
3386
+ };
3387
+ }
3245
3388
 
3246
- if (status === 'running') return handoffRow('正在交接:压缩旧会话 → 生成交接单 → 打开新会话…');
3247
- if (status === 'error') return handoffRow(outcomeText || '交接失败(详见日志)', 'error');
3248
- if (childId === null) return handoffRow(outcomeText || '交接完成');
3249
- if (status === 'failed') {
3250
- return handoffRow(`新会话已建好(${childId}),自动切换失败:${detail || '未知原因'}`, 'error', openChild);
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;
3251
3405
  }
3252
- if (status === 'opening') return handoffRow(`交接完成,正在打开新会话(${childId})…`);
3253
- return handoffRow('交接完成:已切到新会话(点指示灯 → 查看交接单 可回看或复制)。');
3406
+ // 老插件:没有 next,chain 就是最近一条记录(出站)。
3407
+ return relayChainProjectionOf(data);
3254
3408
  }
3255
3409
 
3256
- /* ---------------- 设置栏:压缩接力 ---------------- */
3257
-
3258
- /** 颜色预设:点一下同时改 4 个基准色。 */
3259
- const COLOR_PRESETS = [
3260
- { id: 'classic', label: '经典', base: { idle: '#6F7680', compact: '#C26A00', handoff: '#D92D20', stage: '#2563EB', pin: '#C026D3' } },
3261
- { id: 'contrast', label: '高对比', base: { idle: '#4B5563', compact: '#92400E', handoff: '#B91C1C', stage: '#1D4ED8', pin: '#A21CAF' } },
3262
- { id: 'soft', label: '柔和', base: { idle: '#7D8794', compact: '#B45309', handoff: '#D05A55', stage: '#3B82F6', pin: '#D946EF' } },
3263
- ];
3264
-
3265
- const settingsHintStyle = { margin: 0, fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' };
3266
- const settingsLabelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' };
3267
- const settingsInputStyle = {
3268
- height: 28,
3269
- borderRadius: 6,
3270
- border: '1px solid var(--dsw-alias-border-l3, rgba(127,127,127,0.3))',
3271
- background: 'var(--dsw-alias-bg-layer-1, transparent)',
3272
- color: 'inherit',
3273
- fontSize: 12,
3274
- padding: '0 8px',
3275
- maxWidth: 320,
3276
- };
3277
- const settingsButtonStyle = {
3278
- height: 30,
3279
- borderRadius: 7,
3280
- border: '1px solid var(--dsw-alias-border-l3, rgba(127,127,127,0.3))',
3281
- background: 'transparent',
3282
- color: 'inherit',
3283
- fontSize: 12,
3284
- padding: '0 12px',
3285
- cursor: 'pointer',
3286
- };
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
+ }
3287
3424
 
3288
- /** 百分比上限徽标:「最高 62%」——让上限一眼可见,但不用它夹紧输入。 */
3289
- const percentCapChipStyle = {
3290
- display: 'inline-flex',
3291
- alignItems: 'center',
3292
- height: 20,
3293
- padding: '0 8px',
3294
- borderRadius: 999,
3295
- border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.28))',
3296
- background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
3297
- color: 'var(--dsw-alias-label-secondary, rgba(127,127,127,0.95))',
3298
- fontSize: 11,
3299
- lineHeight: '20px',
3300
- whiteSpace: 'nowrap',
3301
- };
3425
+ /**
3426
+ * 0.5.0:旧会话是否已被交接流程自动归档。
3427
+ *
3428
+ * 宿主 `readState` 在链有父会话时下发 `parentArchived`(boolean;服务不可用为 null)。
3429
+ * 老宿主没有这个字段 → null(客户端按「可尝试恢复」处理,恢复调用本身幂等)。
3430
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3431
+ * @returns {boolean|null}
3432
+ */
3433
+ function relayBarParentArchivedOf(data) {
3434
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3435
+ return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
3436
+ }
3302
3437
 
3303
3438
  /**
3304
- * 行内提示块(告警 / 失败):淡色底 + 同色描边,深浅主题都靠 DSH 状态 token 自适应。
3305
- * 红色只用在真正的失败上,且用 7% 透明底而不是整块红,避免刺眼。
3306
- * @param {'warning'|'error'} kind
3439
+ * 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
3440
+ * @param {object} chain relayBarChainOf 的结果
3441
+ * @returns {string}
3307
3442
  */
3308
- function alertBoxStyle(kind) {
3309
- const warning = kind === 'warning';
3310
- return {
3311
- display: 'flex',
3312
- flexDirection: 'column',
3313
- gap: 2,
3314
- padding: '8px 10px',
3315
- borderRadius: 8,
3316
- border: `1px solid ${warning ? 'var(--dsw-alias-state-warning-primary, #d97706)' : 'var(--dsw-alias-state-error-primary, #D92D20)'}`,
3317
- background: warning ? 'rgba(217, 119, 6, 0.08)' : 'rgba(217, 45, 32, 0.07)',
3318
- color: warning ? 'var(--dsw-alias-state-warning-primary, #b45309)' : 'var(--dsw-alias-state-error-primary, #D92D20)',
3319
- fontSize: 12,
3320
- lineHeight: '17px',
3321
- fontWeight: 600,
3322
- };
3443
+ function relayBarLabelOf(chain) {
3444
+ const baseTitle = chain.baseTitle === null ? '会话' : chain.baseTitle;
3445
+ return chain.scheme === 'history-n' ? `历史 ${chain.ordinal}:${baseTitle}` : `交接第 ${chain.ordinal} 次:${baseTitle}`;
3323
3446
  }
3324
3447
 
3325
- /** 不可变地设置对象里的某个路径。 */
3326
- function setIn(object, path, value) {
3327
- if (path.length === 1) return { ...object, [path[0]]: value };
3328
- return { ...object, [path[0]]: setIn(object[path[0]] ?? {}, path.slice(1), value) };
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;
3329
3466
  }
3330
3467
 
3331
- /** 一行设置:标签 + 作用小字 + 调整后的变化小字 + 控件。 */
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
+
3481
+ /**
3482
+ * 交接单绝对路径 → `/api/compact-relay-handoffs?file=` 只接受的 handoff-*.md 文件名。
3483
+ * 服务端只认文件名(不是全路径),这里取 basename 并做同一形态校验;不合法 → null。
3484
+ * @param {string|null} path
3485
+ * @returns {string|null}
3486
+ */
3487
+ function relayBarDocNameOf(path) {
3488
+ if (typeof path !== 'string' || path.length === 0) return null;
3489
+ const name = path.split(/[\\/]/).pop();
3490
+ return typeof name === 'string' && /^handoff-[0-9A-Za-z][0-9A-Za-z.\-_]*\.md$/.test(name) ? name : null;
3491
+ }
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
+
3535
+ /** 接力条菜单里的一行(和指示灯菜单同一套视觉,但更轻)。 */
3536
+ function relayBarItem(label, sub, key, action, meta = {}) {
3537
+ const disabled = meta.disabled === true;
3538
+ return h(
3539
+ 'button',
3540
+ {
3541
+ type: 'button',
3542
+ key,
3543
+ role: 'menuitem',
3544
+ 'data-cr-relay-action': key,
3545
+ disabled,
3546
+ title: meta.title ?? (disabled ? sub : undefined),
3547
+ onClick: () => {
3548
+ if (disabled || typeof action !== 'function') return;
3549
+ action();
3550
+ },
3551
+ style: {
3552
+ display: 'flex',
3553
+ alignItems: 'center',
3554
+ gap: 10,
3555
+ width: '100%',
3556
+ minHeight: 32,
3557
+ padding: '5px 10px',
3558
+ border: 'none',
3559
+ borderRadius: 8,
3560
+ background: 'transparent',
3561
+ textAlign: 'left',
3562
+ cursor: disabled ? 'default' : 'pointer',
3563
+ color: 'var(--dsw-alias-label-primary, inherit)',
3564
+ opacity: disabled ? 0.55 : 1,
3565
+ font: 'inherit',
3566
+ },
3567
+ onMouseEnter: (event) => {
3568
+ if (!disabled) event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
3569
+ },
3570
+ onMouseLeave: (event) => {
3571
+ event.currentTarget.style.background = 'transparent';
3572
+ },
3573
+ },
3574
+ h(
3575
+ 'span',
3576
+ { style: { display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 } },
3577
+ h('span', { style: { fontSize: 12, fontWeight: 500 } }, label),
3578
+ sub !== null
3579
+ ? h(
3580
+ 'span',
3581
+ { style: { fontSize: 10, lineHeight: '14px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3582
+ sub,
3583
+ )
3584
+ : null,
3585
+ ),
3586
+ h(
3587
+ 'span',
3588
+ { style: { flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))', whiteSpace: 'nowrap' } },
3589
+ meta.right ?? (disabled ? '' : '›'),
3590
+ ),
3591
+ );
3592
+ }
3593
+
3594
+ /**
3595
+ * 接力条本体(`conversation.input.dock`,输入卡上方一行)。
3596
+ *
3597
+ * 0.5.1 批 3 起这一行有两个方向:
3598
+ * - 入站(`chain`,子会话侧):「已接力自 历史 N:原名」+ 回到原会话 / 完整交接链;
3599
+ * - 出站(`next`,父会话侧):「已交接给新会话」+ 去新会话;
3600
+ * - 两个都有(中段会话):同时显示两个方向的入口。
3601
+ *
3602
+ * @param {object} props ctx.slots.register 注入的 props
3603
+ * @param {string} [props.sessionId] 当前会话 id(session scope)
3604
+ * @param {(sessionId:string)=>unknown} [props.openSession] 切到某个会话(ctx.uiWorkspace.openSession)
3605
+ * @param {(sessionId:string)=>Promise<unknown>|undefined} [props.unarchiveSession] 恢复归档会话
3606
+ * (ctx.uiWorkspace.unarchiveSession;老宿主没有 → 走本插件 HTTP recover 兜底)
3607
+ */
3608
+ function CompactRelayBar(props) {
3609
+ const sessionId = props === null || props === undefined ? undefined : props.sessionId;
3610
+ const openSession = props === null || props === undefined ? undefined : props.openSession;
3611
+ const unarchiveSession = props === null || props === undefined ? undefined : props.unarchiveSession;
3612
+ const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
3613
+ const [chain, setChain] = useState(null);
3614
+ // 0.5.1 批 3:出站投影(本会话把接力棒交给了谁)。父会话侧据此显示「去新会话」。
3615
+ const [next, setNext] = useState(null);
3616
+ // null = 宿主没给归档状态(老宿主);true/false = readState.parentArchived。
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);
3629
+ const [open, setOpen] = useState(false);
3630
+ const [doc, setDoc] = useState(null);
3631
+ const [notice, setNotice] = useState(null);
3632
+ const [anchor, setAnchor] = useState(null);
3633
+ const theme = useAutoTheme();
3634
+ const rootRef = useRef(null);
3635
+ const triggerRef = useRef(null);
3636
+ const menuRef = useRef(null);
3637
+ const docRequestRef = useRef(0);
3638
+ const docAbortRef = useRef(null);
3639
+ // 完整链接力式的请求序号(关菜单/换会话/重试时让晚到的响应失效)与断点。
3640
+ const chainRequestRef = useRef(0);
3641
+ const chainResumeRef = useRef(null);
3642
+
3643
+ /** 取消未完成的交接单读取;同时让晚到的响应失效(requestId 对不上)。 */
3644
+ const abortDoc = useCallback(() => {
3645
+ docRequestRef.current += 1;
3646
+ if (docAbortRef.current !== null) {
3647
+ try {
3648
+ docAbortRef.current.abort();
3649
+ } catch {
3650
+ /* ignore */
3651
+ }
3652
+ docAbortRef.current = null;
3653
+ }
3654
+ }, []);
3655
+
3656
+ // 0.5.1 批 3:完整链的「收摊」——让晚到的逐级响应失效,并丢掉列表与断点。
3657
+ const closeChainView = useCallback(() => {
3658
+ chainRequestRef.current += 1;
3659
+ chainResumeRef.current = null;
3660
+ setChainView(null);
3661
+ }, []);
3662
+
3663
+ const closeMenu = useCallback(() => {
3664
+ abortDoc();
3665
+ closeChainView();
3666
+ setOpen(false);
3667
+ setDoc(null);
3668
+ setNotice(null);
3669
+ }, [abortDoc, closeChainView]);
3670
+
3671
+ // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
3672
+ const backToMenu = useCallback(() => {
3673
+ abortDoc();
3674
+ setDoc(null);
3675
+ }, [abortDoc]);
3676
+
3677
+ // 读链状态。会话刚打开时宿主可能还没把日志装进内存(readState.sessionId 为
3678
+ // null),这时要重试几次;一旦宿主确认「就是这个会话且没有 chain」就停,
3679
+ // 普通会话不会无限打接口。fetch 失败/老宿主没有 chain → setChain(null) → 不渲染。
3680
+ useEffect(() => {
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
+ }
3700
+ if (!hasSession) return undefined;
3701
+ let dead = false;
3702
+ let timer = 0;
3703
+ let attempts = 0;
3704
+ // 静默刷新(菜单打开时):链已经在显示,失败/未加载就不再排队重试,也不清链。
3705
+ const silent = sessionChanged === false;
3706
+ const url = `/api/compact-relay-state?sessionId=${encodeURIComponent(sessionId)}`;
3707
+ const schedule = () => {
3708
+ if (dead || silent) return;
3709
+ attempts += 1;
3710
+ if (attempts > 12) return;
3711
+ timer = setTimeout(() => {
3712
+ void load();
3713
+ }, Math.min(500 * attempts, 3000));
3714
+ };
3715
+ async function loadParentPath(localChain) {
3716
+ try {
3717
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(localChain.parentId)}`, {
3718
+ cache: 'no-store',
3719
+ });
3720
+ if (dead || !res.ok) return;
3721
+ const parentData = await res.json().catch(() => null);
3722
+ if (dead) return;
3723
+ // 0.5.1 DCR-01:父会话的 chain 是它自己的入站记录,这一跳的出站在 next 里。
3724
+ const parentChain = relayOutboundProjectionOf(parentData);
3725
+ if (
3726
+ parentChain === null ||
3727
+ parentChain.childId !== sessionId ||
3728
+ parentChain.ordinal !== localChain.ordinal ||
3729
+ parentChain.path === null
3730
+ ) {
3731
+ return;
3732
+ }
3733
+ setChain((previous) => (previous === null ? previous : { ...previous, path: parentChain.path }));
3734
+ } catch {
3735
+ /* 父会话读不到(未加载/已归档):保持「暂无交接单」,不影响接力条本身 */
3736
+ }
3737
+ }
3738
+ async function load() {
3739
+ try {
3740
+ const res = await fetch(url, { cache: 'no-store' });
3741
+ if (dead) return;
3742
+ if (!res.ok) {
3743
+ // 4xx(鉴权/端点不存在)等下去也不会变;5xx/网络抖动才重试。
3744
+ if (res.status >= 400 && res.status < 500) return;
3745
+ schedule();
3746
+ return;
3747
+ }
3748
+ const data = await res.json().catch(() => null);
3749
+ if (dead) return;
3750
+ if (data !== null && typeof data === 'object' && data.ok === false) return;
3751
+ const parsed = relayBarChainOf(data, sessionId);
3752
+ // 0.5.1 批 3:父会话侧的出站目标。它和 chain 是两条独立的投影:子会话只有
3753
+ // chain,父会话只有 next,中段会话两个都有 —— 任一存在就要渲染接力条。
3754
+ const outbound = relayBarNextOf(data, sessionId);
3755
+ if (parsed !== null || outbound !== null) {
3756
+ setChain(parsed);
3757
+ setNext(outbound);
3758
+ setParentArchived(relayBarParentArchivedOf(data));
3759
+ setNextArchived(relayBarNextArchivedOf(data));
3760
+ setServerUnarchive(relayBarUnarchiveCapabilityOf(data));
3761
+ // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3762
+ // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3763
+ // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
3764
+ if (parsed !== null && parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3765
+ return;
3766
+ }
3767
+ // sessionId 对不上/为 null → 宿主还没加载本会话,继续等;对得上 → 确定没有链。
3768
+ const loaded =
3769
+ data !== null && typeof data === 'object' && typeof data.sessionId === 'string' && data.sessionId === sessionId;
3770
+ if (loaded) {
3771
+ // 静默刷新时宿主确认「没有链」也不清(保持在显示的接力条,避免误伤)。
3772
+ if (!silent) {
3773
+ setChain(null);
3774
+ setNext(null);
3775
+ setParentArchived(null);
3776
+ setNextArchived(null);
3777
+ setServerUnarchive(null);
3778
+ }
3779
+ } else schedule();
3780
+ } catch {
3781
+ /* 宿主不在/网络失败:保持「没有链」—— 不显示,不占高度;稍后再试 */
3782
+ schedule();
3783
+ }
3784
+ }
3785
+ void load();
3786
+ return () => {
3787
+ dead = true;
3788
+ if (timer !== 0) clearTimeout(timer);
3789
+ };
3790
+ }, [hasSession, sessionId, refreshKey]);
3791
+
3792
+ // 菜单打开时按这一行的真实位置重算锚点(滚动/缩放不漂)。
3793
+ useEffect(() => {
3794
+ if (!open) return undefined;
3795
+ const update = () => {
3796
+ const root = rootRef.current;
3797
+ const el = root === null || root === undefined ? null : root.firstElementChild;
3798
+ if (el === null || el === undefined || typeof el.getBoundingClientRect !== 'function') return;
3799
+ const rect = el.getBoundingClientRect();
3800
+ setAnchor({
3801
+ left: Math.round(rect.left),
3802
+ right: Math.round(rect.right),
3803
+ top: Math.round(rect.top),
3804
+ bottom: Math.round(rect.bottom),
3805
+ });
3806
+ };
3807
+ update();
3808
+ window.addEventListener('scroll', update, true);
3809
+ window.addEventListener('resize', update);
3810
+ return () => {
3811
+ window.removeEventListener('scroll', update, true);
3812
+ window.removeEventListener('resize', update);
3813
+ };
3814
+ }, [open]);
3815
+
3816
+ // 点外面 / Esc 关闭菜单与交接单查看。
3817
+ useEffect(() => {
3818
+ if (!open) return undefined;
3819
+ const inside = (target) => {
3820
+ const root = rootRef.current;
3821
+ const menu = menuRef.current;
3822
+ return (root !== null && root !== undefined && root.contains(target)) || (menu !== null && menu !== undefined && menu.contains(target));
3823
+ };
3824
+ const onDown = (event) => {
3825
+ if (inside(event.target)) return;
3826
+ closeMenu();
3827
+ };
3828
+ const onKey = (event) => {
3829
+ if (event.key !== 'Escape') return;
3830
+ closeMenu();
3831
+ const trigger = triggerRef.current;
3832
+ if (trigger !== null && trigger !== undefined && typeof trigger.focus === 'function') {
3833
+ window.setTimeout(() => trigger.focus(), 0);
3834
+ }
3835
+ };
3836
+ document.addEventListener('mousedown', onDown);
3837
+ document.addEventListener('keydown', onKey);
3838
+ return () => {
3839
+ document.removeEventListener('mousedown', onDown);
3840
+ document.removeEventListener('keydown', onKey);
3841
+ };
3842
+ }, [open, closeMenu]);
3843
+
3844
+ // 打开后把焦点送进第一个可操作项(键盘用户不用再 Tab 找)。
3845
+ useEffect(() => {
3846
+ if (!open || doc !== null) return;
3847
+ const menu = menuRef.current;
3848
+ if (menu === null || menu === undefined || typeof menu.querySelector !== 'function') return;
3849
+ const first = menu.querySelector('button:not(:disabled)');
3850
+ if (first !== null && first !== undefined && typeof first.focus === 'function') first.focus();
3851
+ }, [open, doc]);
3852
+
3853
+ const toggleMenu = useCallback((event) => {
3854
+ const current = event === null || event === undefined ? null : event.currentTarget;
3855
+ if (current !== null && current !== undefined && typeof current.getBoundingClientRect === 'function') {
3856
+ const rect = current.getBoundingClientRect();
3857
+ setAnchor({
3858
+ left: Math.round(rect.left),
3859
+ right: Math.round(rect.right),
3860
+ top: Math.round(rect.top),
3861
+ bottom: Math.round(rect.bottom),
3862
+ });
3863
+ }
3864
+ abortDoc();
3865
+ setNotice(null);
3866
+ setDoc(null);
3867
+ // 每次开/关都把完整链收掉:重开菜单从主列表开始(不预加载,也不留旧会话的链)。
3868
+ closeChainView();
3869
+ // 0.5.1 DCR-03:打开菜单前刷新一次 state —— 归档集合可能在别处被改(侧栏
3870
+ // 取消归档 / 另一个窗口归档),沿用打开前的旧状态会让「恢复旧会话」变死入口。
3871
+ if (!open) setRefreshKey((key) => key + 1);
3872
+ setOpen((value) => !value);
3873
+ }, [abortDoc, closeChainView, open]);
3874
+
3875
+ const loadDoc = useCallback(async (name) => {
3876
+ if (typeof name !== 'string' || name.length === 0) return;
3877
+ if (docAbortRef.current !== null) {
3878
+ try {
3879
+ docAbortRef.current.abort();
3880
+ } catch {
3881
+ /* ignore */
3882
+ }
3883
+ }
3884
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
3885
+ docAbortRef.current = controller;
3886
+ const requestId = ++docRequestRef.current;
3887
+ setDoc({ loading: true, name });
3888
+ setNotice(null);
3889
+ try {
3890
+ const res = await fetch(`/api/compact-relay-handoffs?file=${encodeURIComponent(name)}`, {
3891
+ cache: 'no-store',
3892
+ ...(controller === null ? {} : { signal: controller.signal }),
3893
+ });
3894
+ const data = await res.json().catch(() => ({}));
3895
+ if (requestId !== docRequestRef.current) return;
3896
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取失败');
3897
+ setDoc({
3898
+ loading: false,
3899
+ name,
3900
+ content: typeof data.content === 'string' ? data.content : '',
3901
+ path: typeof data.path === 'string' ? data.path : null,
3902
+ });
3903
+ } catch (error) {
3904
+ if (requestId !== docRequestRef.current || (error && error.name === 'AbortError')) return;
3905
+ setDoc({
3906
+ loading: false,
3907
+ name,
3908
+ error: '读取失败:' + (error && error.message ? error.message : '未知错误'),
3909
+ });
3910
+ } finally {
3911
+ if (requestId === docRequestRef.current) docAbortRef.current = null;
3912
+ }
3913
+ }, []);
3914
+
3915
+ /** 切到某个会话(不碰归档状态);成功发起返回 true。 */
3916
+ const openTarget = useCallback(
3917
+ (target, failureText) => {
3918
+ if (typeof openSession !== 'function') {
3919
+ setNotice('这个宿主不支持切换会话,无法自动打开会话。');
3920
+ setOpen(true);
3921
+ return false;
3922
+ }
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
+ );
3943
+
3944
+ /**
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。
3954
+ *
3955
+ * @param {string} target 目标会话 id
3956
+ * @param {{markParent?:boolean, failureText?:string, restoredText?:string}} [options]
3957
+ * markParent:成功后把本地「父会话归档状态」改成未归档(只有父会话需要)。
3958
+ */
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 : '恢复接口不可用');
3987
+ }
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);
4013
+ if (typeof openSession !== 'function') {
4014
+ setNotice(restoredText);
4015
+ setOpen(true);
4016
+ return;
4017
+ }
4018
+ await openSession(target);
4019
+ setOpen(false);
4020
+ setDoc(null);
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 : '未知错误'));
4035
+ setOpen(true);
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;
4044
+
4045
+ const goParent = useCallback(() => {
4046
+ setNotice(null);
4047
+ if (chain === null || chain.parentId === null) return;
4048
+ // 已归档(true)或宿主没给归档状态(null):都不能假设「未归档」直接打开 ——
4049
+ // 打开被归档的会话会被宿主拒绝,这个入口就成了死路(DCR-03)。
4050
+ // 有恢复能力时先 unarchive 再 open(unarchive 幂等,未归档时是 no-op)。
4051
+ if (parentArchived !== false && canRestore) {
4052
+ void restoreAndOpen(chain.parentId, { markParent: true });
4053
+ return;
4054
+ }
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;
4073
+ }
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
+ );
4093
+
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
+ );
4149
+
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);
4383
+ // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
4384
+ // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
4385
+ // 0.5.1 DCR-03:宿主客户端与服务端都没有恢复能力时**隐藏**入口(点了必然失败)。
4386
+ const showRestore = hasParent && parentArchived !== false && canRestore;
4387
+ const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
4388
+ const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
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;
4392
+ const anchorTop = anchor === null ? vh - 96 : anchor.top;
4393
+ const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
4394
+ const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
4395
+ const left = Math.max(8, Math.min(anchor === null ? 8 : anchor.left, Math.max(8, vw - menuWidth - 8)));
4396
+ const position = openUp
4397
+ ? { left, bottom: Math.max(8, vh - anchorTop + 6) }
4398
+ : { left, top: Math.min(Math.max(8, anchorBottom + 6), Math.max(8, vh - Math.min(estimatedHeight, vh - 16) - 8)) };
4399
+
4400
+ const docPanel =
4401
+ doc === null
4402
+ ? null
4403
+ : h(
4404
+ 'div',
4405
+ { role: 'dialog', 'aria-label': '交接单', 'data-cr-relay-doc': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
4406
+ h(
4407
+ 'div',
4408
+ {
4409
+ style: {
4410
+ display: 'flex',
4411
+ alignItems: 'center',
4412
+ gap: 8,
4413
+ padding: '7px 10px',
4414
+ borderBottom: `1px solid ${theme.panelBorder}`,
4415
+ fontSize: 12,
4416
+ fontWeight: 600,
4417
+ },
4418
+ },
4419
+ h('span', { style: { flex: 1, minWidth: 0 } }, '交接单'),
4420
+ h(
4421
+ 'button',
4422
+ {
4423
+ type: 'button',
4424
+ 'data-cr-relay-doc-back': '1',
4425
+ onClick: backToMenu,
4426
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4427
+ },
4428
+ '← 返回',
4429
+ ),
4430
+ h(
4431
+ 'button',
4432
+ {
4433
+ type: 'button',
4434
+ 'data-cr-relay-doc-close': '1',
4435
+ onClick: closeMenu,
4436
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4437
+ },
4438
+ '关闭',
4439
+ ),
4440
+ ),
4441
+ doc.loading === true
4442
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: theme.subText } }, '读取中…')
4443
+ : doc.error !== undefined && doc.error !== null
4444
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: COLORS.error } }, String(doc.error))
4445
+ : h(
4446
+ 'div',
4447
+ { style: { overflowY: 'auto', overflowX: 'hidden', padding: '8px 12px', fontSize: 11, lineHeight: '16px', maxHeight: 'min(52vh, 320px)', wordBreak: 'break-word' } },
4448
+ renderMarkdownLite(doc.content ?? ''),
4449
+ ),
4450
+ );
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
+
4638
+ const menu =
4639
+ open === true
4640
+ ? h(
4641
+ 'div',
4642
+ {
4643
+ ref: menuRef,
4644
+ role: 'menu',
4645
+ 'aria-label': '接力菜单',
4646
+ 'data-cr-relay-menu': '1',
4647
+ style: {
4648
+ position: 'fixed',
4649
+ ...position,
4650
+ width: menuWidth,
4651
+ maxWidth: 'calc(100vw - 16px)',
4652
+ maxHeight: 'min(65vh, 460px)',
4653
+ display: 'flex',
4654
+ flexDirection: 'column',
4655
+ overflow: 'hidden',
4656
+ padding: doc === null && chainView === null ? 5 : 0,
4657
+ borderRadius: 12,
4658
+ ...panelChrome(theme),
4659
+ color: theme.panelFg,
4660
+ // 顶层:高于指示灯菜单/确认卡(39–44),避免被输入区或其它浮层盖住。
4661
+ zIndex: 60,
4662
+ },
4663
+ },
4664
+ notice !== null
4665
+ ? h(
4666
+ 'div',
4667
+ { 'data-cr-relay-notice': '1', style: { padding: '7px 10px', fontSize: 11, lineHeight: '15px', color: 'var(--dsw-alias-state-error-primary, #D92D20)' } },
4668
+ notice,
4669
+ )
4670
+ : null,
4671
+ // 交接单面板 / 完整链面板都替换主列表;「← 返回」按来源回到链列表或主菜单。
4672
+ docPanel ??
4673
+ chainPanel ??
4674
+ h(
4675
+ 'div',
4676
+ { style: { display: 'flex', flexDirection: 'column' } },
4677
+ relayBarItem('查看交接单', docName === null ? '这一链没有记录交接单路径' : '打开本次交接的九段交接单', 'doc', () => {
4678
+ void loadDoc(docName);
4679
+ }, {
4680
+ disabled: docName === null,
4681
+ // 需求文案:无 path 时明确提示「暂无交接单」。
4682
+ right: docName === null ? '暂无交接单' : '›',
4683
+ title: docName === null ? '暂无交接单:这条接力记录里没有交接单路径(旧记录,或父会话已不在)' : '打开交接单',
4684
+ }),
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,
4699
+ showRestore
4700
+ ? relayBarItem(
4701
+ '恢复旧会话',
4702
+ parentArchived === true
4703
+ ? '旧会话已归档:恢复后切回上一代'
4704
+ : '宿主没提供归档状态;恢复调用是幂等的,仍可直接尝试',
4705
+ 'unarchive',
4706
+ () => {
4707
+ void restoreAndOpen(chain.parentId, { markParent: true });
4708
+ },
4709
+ {
4710
+ right: parentArchived === true ? '已归档' : '恢复',
4711
+ title: `从归档中恢复旧会话 ${chain.parentId}`,
4712
+ },
4713
+ )
4714
+ : null,
4715
+ hasParent
4716
+ ? relayBarItem('完整交接链', '一次读取整条链,回看前几次会话与交接单', 'chain', openChainList, {
4717
+ right: '›',
4718
+ title: `完整交接链:一次读取整条链(最多 ${MAX_CHAIN_HOPS} 层;祖先未加载也能读)`,
4719
+ })
4720
+ : null,
4721
+ relayBarItem('路线图(即将上线)', '跨会话时间线,0.6 之后', 'roadmap', null, {
4722
+ disabled: true,
4723
+ right: '即将上线',
4724
+ }),
4725
+ ),
4726
+ )
4727
+ : null;
4728
+
4729
+ return h(
4730
+ 'div',
4731
+ {
4732
+ ref: rootRef,
4733
+ 'data-cr-relay-root': '1',
4734
+ style: {
4735
+ boxSizing: 'border-box',
4736
+ flex: 'none',
4737
+ // 与宿主 TodoPanel(同一 dock 槽)一致:两侧留出 composer 的边距,
4738
+ // 宽度跟着输入卡走,避免通栏压到侧栏。
4739
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4)',
4740
+ maxWidth: 'calc(var(--dsh-composer-card-max-width, 920px) - var(--dsh-composer-dock-inset, 8px) * 4)',
4741
+ margin: '0 auto',
4742
+ },
4743
+ },
4744
+ // 卡片外观放在内层:backdrop-filter 会给 fixed 子元素造 containing block,
4745
+ // 菜单必须留在这一层之外,才能相对视口定位(不被输入区裁切)。
4746
+ h(
4747
+ 'div',
4748
+ {
4749
+ 'data-cr-relay-card': '1',
4750
+ style: {
4751
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
4752
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.24))',
4753
+ // 底色/字体全部走 DSH 主题 token:切深浅色不需要重挂组件;老宿主用 theme 兜底。
4754
+ background: `var(--dsw-specific-menu, ${theme.panelBg})`,
4755
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
4756
+ WebkitBackdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
4757
+ boxShadow: 'var(--dsw-elevation-panel, 0 1px 2px rgba(0,0,0,0.06))',
4758
+ color: 'var(--dsw-alias-label-primary, inherit)',
4759
+ overflow: 'hidden',
4760
+ },
4761
+ },
4762
+ h(
4763
+ 'button',
4764
+ {
4765
+ type: 'button',
4766
+ ref: triggerRef,
4767
+ 'data-cr-relay-bar': '1',
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 ?? '',
4772
+ // 空串 = 宿主没给归档状态(老宿主),'true'/'false' = readState.parentArchived。
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),
4777
+ 'aria-haspopup': 'menu',
4778
+ 'aria-expanded': open,
4779
+ 'aria-label': chain !== null ? `已接力自 ${label},打开接力菜单` : `${label},打开接力菜单`,
4780
+ onClick: toggleMenu,
4781
+ style: {
4782
+ display: 'flex',
4783
+ alignItems: 'center',
4784
+ gap: 8,
4785
+ width: '100%',
4786
+ minHeight: 30,
4787
+ padding: '3px 10px',
4788
+ border: 'none',
4789
+ borderRadius: 'inherit',
4790
+ background: 'transparent',
4791
+ color: 'inherit',
4792
+ font: 'inherit',
4793
+ textAlign: 'left',
4794
+ cursor: 'pointer',
4795
+ },
4796
+ onMouseEnter: (event) => {
4797
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
4798
+ },
4799
+ onMouseLeave: (event) => {
4800
+ event.currentTarget.style.background = 'transparent';
4801
+ },
4802
+ },
4803
+ h(
4804
+ 'span',
4805
+ { style: { flex: 'none', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
4806
+ // 末尾留一个空格:flex gap 负责视觉间距,文本内容(读屏/可访问性树)也要读得通。
4807
+ // 父会话侧没有「来路」,只显示「已交接给新会话」(见 label)。
4808
+ chain !== null ? '已接力自 ' : '',
4809
+ ),
4810
+ h(
4811
+ 'span',
4812
+ {
4813
+ style: {
4814
+ flex: 1,
4815
+ minWidth: 0,
4816
+ fontSize: 13,
4817
+ fontWeight: 500,
4818
+ lineHeight: '20px',
4819
+ color: 'var(--dsw-alias-label-primary, inherit)',
4820
+ overflow: 'hidden',
4821
+ textOverflow: 'ellipsis',
4822
+ whiteSpace: 'nowrap',
4823
+ },
4824
+ },
4825
+ label,
4826
+ ),
4827
+ h(
4828
+ 'span',
4829
+ {
4830
+ 'aria-hidden': true,
4831
+ style: {
4832
+ flex: 'none',
4833
+ fontSize: 10,
4834
+ lineHeight: 1,
4835
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))',
4836
+ transform: open ? 'rotate(180deg)' : 'none',
4837
+ transition: 'transform 120ms ease',
4838
+ },
4839
+ },
4840
+ '▾',
4841
+ ),
4842
+ ),
4843
+ ),
4844
+ menu,
4845
+ );
4846
+ }
4847
+
4848
+ /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
4849
+ function handoffRow(text, tone, action) {
4850
+ return h(
4851
+ 'div',
4852
+ {
4853
+ style: {
4854
+ display: 'flex',
4855
+ alignItems: 'center',
4856
+ gap: 8,
4857
+ padding: '4px 2px',
4858
+ fontSize: 12,
4859
+ color: tone === 'error' ? COLORS.error : COLORS.subText,
4860
+ },
4861
+ },
4862
+ h('span', { style: { flex: 1, minWidth: 0 } }, text),
4863
+ action
4864
+ ? h(
4865
+ 'button',
4866
+ {
4867
+ type: 'button',
4868
+ onClick: action,
4869
+ style: {
4870
+ flex: 'none',
4871
+ border: 'none',
4872
+ borderRadius: 6,
4873
+ padding: '2px 8px',
4874
+ background: COLORS.hover,
4875
+ color: 'inherit',
4876
+ fontSize: 11,
4877
+ cursor: 'pointer',
4878
+ },
4879
+ },
4880
+ '重试打开',
4881
+ )
4882
+ : null,
4883
+ );
4884
+ }
4885
+
4886
+ /**
4887
+ * /handoff 命令卡:命令成功后自动切到新会话。
4888
+ * 这个槽把命令的通用卡片替换成我们的卡片(只有 /handoff 这一个 key)。
4889
+ */
4890
+ function HandoffCard({ node, waitAndOpen }) {
4891
+ const outcome = node !== null && typeof node === 'object' ? node.outcome : undefined;
4892
+ const outcomeText = outcome !== null && typeof outcome === 'object' && typeof outcome.text === 'string' ? outcome.text : '';
4893
+ // A5:优先用命令结果里的结构化 childId;旧宿主没带字段时再从文本 #child= 兜底。
4894
+ const childId =
4895
+ outcome !== null && typeof outcome === 'object' && typeof outcome.childId === 'string' && outcome.childId.length > 0
4896
+ ? outcome.childId
4897
+ : extractChildId(outcomeText);
4898
+ // P2-C1:区分 opening / opened / failed;刷新后只有 localStorage='1' 才算已打开,
4899
+ // 'opening'(上次没走完)要重新尝试,不能直接渲染成功态。
4900
+ const [status, setStatus] = useState(() => {
4901
+ if (outcome === undefined) return 'running';
4902
+ if (outcome !== null && outcome.kind === 'error') return 'error';
4903
+ if (childId === null) return 'done';
4904
+ return wasOpened(childId) ? 'done' : 'opening';
4905
+ });
4906
+ const [detail, setDetail] = useState(null);
4907
+
4908
+ const openChild = useCallback(() => {
4909
+ if (childId === null) return;
4910
+ setStatus('opening');
4911
+ setDetail(null);
4912
+ waitAndOpen(childId).then(
4913
+ () => {
4914
+ markOpened(childId, '1');
4915
+ setStatus('done');
4916
+ },
4917
+ (error) => {
4918
+ clearOpened(childId);
4919
+ setStatus('failed');
4920
+ setDetail(String(error && error.message ? error.message : error));
4921
+ },
4922
+ );
4923
+ }, [childId, waitAndOpen]);
4924
+
4925
+ useEffect(() => {
4926
+ if (outcome === undefined || outcome === null || outcome.kind !== 'success' || childId === null) return;
4927
+ if (wasOpened(childId)) {
4928
+ setStatus('done');
4929
+ return;
4930
+ }
4931
+ markOpened(childId, 'opening');
4932
+ let alive = true;
4933
+ waitAndOpen(childId).then(
4934
+ () => {
4935
+ markOpened(childId, '1');
4936
+ if (alive) setStatus('done');
4937
+ },
4938
+ (error) => {
4939
+ clearOpened(childId);
4940
+ if (!alive) return;
4941
+ setStatus('failed');
4942
+ setDetail(String(error && error.message ? error.message : error));
4943
+ },
4944
+ );
4945
+ return () => {
4946
+ alive = false;
4947
+ };
4948
+ }, [childId, outcome, waitAndOpen]);
4949
+
4950
+ if (status === 'running') return handoffRow('正在交接:压缩旧会话 → 生成交接单 → 打开新会话…');
4951
+ if (status === 'error') return handoffRow(outcomeText || '交接失败(详见日志)', 'error');
4952
+ if (childId === null) return handoffRow(outcomeText || '交接完成');
4953
+ if (status === 'failed') {
4954
+ return handoffRow(`新会话已建好(${childId}),自动切换失败:${detail || '未知原因'}`, 'error', openChild);
4955
+ }
4956
+ if (status === 'opening') return handoffRow(`交接完成,正在打开新会话(${childId})…`);
4957
+ return handoffRow('交接完成:已切到新会话(点指示灯 → 查看交接单 可回看或复制)。');
4958
+ }
4959
+
4960
+ /* ---------------- 设置栏:压缩接力 ---------------- */
4961
+
4962
+ /** 颜色预设:点一下同时改 4 个基准色。 */
4963
+ const COLOR_PRESETS = [
4964
+ { id: 'classic', label: '经典', base: { idle: '#6F7680', compact: '#C26A00', handoff: '#D92D20', stage: '#2563EB', pin: '#C026D3' } },
4965
+ { id: 'contrast', label: '高对比', base: { idle: '#4B5563', compact: '#92400E', handoff: '#B91C1C', stage: '#1D4ED8', pin: '#A21CAF' } },
4966
+ { id: 'soft', label: '柔和', base: { idle: '#7D8794', compact: '#B45309', handoff: '#D05A55', stage: '#3B82F6', pin: '#D946EF' } },
4967
+ ];
4968
+
4969
+ const settingsHintStyle = { margin: 0, fontSize: 11, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' };
4970
+ const settingsLabelStyle = { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, inherit)' };
4971
+ const settingsInputStyle = {
4972
+ height: 28,
4973
+ borderRadius: 6,
4974
+ border: '1px solid var(--dsw-alias-border-l3, rgba(127,127,127,0.3))',
4975
+ background: 'var(--dsw-alias-bg-layer-1, transparent)',
4976
+ color: 'inherit',
4977
+ fontSize: 12,
4978
+ padding: '0 8px',
4979
+ maxWidth: 320,
4980
+ };
4981
+ const settingsButtonStyle = {
4982
+ height: 30,
4983
+ borderRadius: 7,
4984
+ border: '1px solid var(--dsw-alias-border-l3, rgba(127,127,127,0.3))',
4985
+ background: 'transparent',
4986
+ color: 'inherit',
4987
+ fontSize: 12,
4988
+ padding: '0 12px',
4989
+ cursor: 'pointer',
4990
+ };
4991
+
4992
+ /** 百分比上限徽标:「最高 62%」——让上限一眼可见,但不用它夹紧输入。 */
4993
+ const percentCapChipStyle = {
4994
+ display: 'inline-flex',
4995
+ alignItems: 'center',
4996
+ height: 20,
4997
+ padding: '0 8px',
4998
+ borderRadius: 999,
4999
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.28))',
5000
+ background: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.08))',
5001
+ color: 'var(--dsw-alias-label-secondary, rgba(127,127,127,0.95))',
5002
+ fontSize: 11,
5003
+ lineHeight: '20px',
5004
+ whiteSpace: 'nowrap',
5005
+ };
5006
+
5007
+ /**
5008
+ * 行内提示块(告警 / 失败):淡色底 + 同色描边,深浅主题都靠 DSH 状态 token 自适应。
5009
+ * 红色只用在真正的失败上,且用 7% 透明底而不是整块红,避免刺眼。
5010
+ * @param {'warning'|'error'} kind
5011
+ */
5012
+ function alertBoxStyle(kind) {
5013
+ const warning = kind === 'warning';
5014
+ return {
5015
+ display: 'flex',
5016
+ flexDirection: 'column',
5017
+ gap: 2,
5018
+ padding: '8px 10px',
5019
+ borderRadius: 8,
5020
+ border: `1px solid ${warning ? 'var(--dsw-alias-state-warning-primary, #d97706)' : 'var(--dsw-alias-state-error-primary, #D92D20)'}`,
5021
+ background: warning ? 'rgba(217, 119, 6, 0.08)' : 'rgba(217, 45, 32, 0.07)',
5022
+ color: warning ? 'var(--dsw-alias-state-warning-primary, #b45309)' : 'var(--dsw-alias-state-error-primary, #D92D20)',
5023
+ fontSize: 12,
5024
+ lineHeight: '17px',
5025
+ fontWeight: 600,
5026
+ };
5027
+ }
5028
+
5029
+ /** 不可变地设置对象里的某个路径。 */
5030
+ function setIn(object, path, value) {
5031
+ if (path.length === 1) return { ...object, [path[0]]: value };
5032
+ return { ...object, [path[0]]: setIn(object[path[0]] ?? {}, path.slice(1), value) };
5033
+ }
5034
+
5035
+ /** 一行设置:标签 + 作用小字 + 调整后的变化小字 + 控件。 */
3332
5036
  function settingsRow(label, purpose, effect, control) {
3333
5037
  return h(
3334
5038
  'div',
@@ -4827,6 +6531,30 @@ window.__ModuleLoader__.load({
4827
6531
  ),
4828
6532
  );
4829
6533
 
6534
+ // 0.5.0 接力条 v1:输入卡上方通栏一行「已接力自 …」+ 向上弹出的下拉菜单。
6535
+ // 组件内部再判断 chain:无链/老宿主没有 chain 字段 → 返回 null,不占高度。
6536
+ ctx.slots.inject('conversation.input.dock', () =>
6537
+ ctx.slots.register(
6538
+ {
6539
+ name: 'conversation.input.dock',
6540
+ id: 'compact-relay',
6541
+ // 宿主 TodoPanel 是 0、queue 是 20;接力条夹在中间(输入卡正上方)。
6542
+ order: 10,
6543
+ inject: (sessionId) => ({
6544
+ sessionId,
6545
+ openSession: (targetSessionId) => ctx.uiWorkspace.openSession(targetSessionId),
6546
+ // 0.5.0:恢复归档旧会话(与侧栏「取消归档」同一条 Client 接缝)。
6547
+ // 老宿主没有这个方法时传 undefined,组件会退回 HTTP recover。
6548
+ unarchiveSession:
6549
+ typeof ctx.uiWorkspace?.unarchiveSession === 'function'
6550
+ ? (targetSessionId) => ctx.uiWorkspace.unarchiveSession(targetSessionId)
6551
+ : undefined,
6552
+ }),
6553
+ },
6554
+ CompactRelayBar,
6555
+ ),
6556
+ );
6557
+
4830
6558
  // /handoff 命令卡:成功后自动切换(key 必须等于命令名)。
4831
6559
  ctx.slots.inject('conversation.chat.commandview', () =>
4832
6560
  ctx.slots.register(