dsh-compact-relay 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * dsh-compact-relay —— 浏览器半(DSH client bundle)。
3
3
  *
4
4
  * 职责:输入框左侧指示灯 / 悬停卡 / 菜单 / 压缩确认卡 / 交接单面板 / toast,
5
+ * 输入框上方接力条(0.5.0:已接力自 … + 下拉菜单),
5
6
  * 以及 /handoff 命令卡(命令成功后自动切到新会话)。
6
7
  * 数据:GET /api/compact-relay-state、SSE /api/compact-relay-stream;
7
8
  * 动作:POST /api/compact-relay-action;交接单:GET /api/compact-relay-handoffs。
@@ -3141,6 +3142,770 @@ window.__ModuleLoader__.load({
3141
3142
  );
3142
3143
  }
3143
3144
 
3145
+ /* ---------------- 0.5.0 接力条(v1):输入框上方一行「已接力自 …」 ---------------- */
3146
+
3147
+ /**
3148
+ * 0.5.0 接力条 v1 —— 只做「一行摘要 + 向上弹出的下拉菜单」。
3149
+ *
3150
+ * 位置:`conversation.input.dock`(list / session scope;宿主文档原文
3151
+ * “Full-width entries above the composer card”,即输入卡上方通栏)。
3152
+ * 显示:`readState.chain` 非 null,且这条记录是**本会话的入站记录**
3153
+ * (childId === sessionId)。出站记录(本会话交接给了下一代)不显示
3154
+ * 「已接力自」;老宿主/没有 chain 字段时组件返回 null,不占高度。
3155
+ * 数据:GET `/api/compact-relay-state?sessionId=…`。会话刚打开、宿主还没装日志
3156
+ * 时短重试几次(拿到本会话的 sessionId 后就停,不做常驻轮询/SSE)。
3157
+ * 子会话的 relay record 不带 path(F3 只写链号),缺 path 时再只读一次父
3158
+ * 会话状态,用父记录里的 path 打开交接单(childId/ordinal 校验)。
3159
+ * 交互:点击这一行 → fixed 定位、向上弹出的菜单(不被输入区裁切):
3160
+ * 查看交接单 / 回到原会话 / 路线图(即将上线,占位禁用)。
3161
+ */
3162
+
3163
+ /**
3164
+ * readState.chain → 接力条可用的最小结构;无效时返回 null(不猜、不补默认值)。
3165
+ *
3166
+ * 只接受 ordinal 为正整数的链;baseTitle/parentId/path 缺了就保持 null,
3167
+ * 由 UI 决定禁用或回落文案。老宿主的响应里没有 chain 字段 → null → 不渲染。
3168
+ *
3169
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3170
+ * @param {string|undefined} sessionId 当前会话 id(dock 的 session scope 参数)
3171
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3172
+ */
3173
+ function relayBarChainOf(data, sessionId) {
3174
+ const chain = relayChainProjectionOf(data);
3175
+ if (chain === null) return null;
3176
+ // 出站记录(本会话把接力棒交给下一代):它不是「已接力自」,v1 不显示。
3177
+ // childId 缺失时(旧链路记录形态)不据此隐藏,遵守「chain 非 null 就显示」。
3178
+ if (chain.childId !== null && typeof sessionId === 'string' && sessionId.length > 0 && chain.childId !== sessionId) return null;
3179
+ return chain;
3180
+ }
3181
+
3182
+ /**
3183
+ * 只做字段投影,不做「是不是本会话入站记录」的判断。
3184
+ *
3185
+ * 单独拆出来是给「父记录补 path」用:父会话里的同一条记录是出站的
3186
+ * (childId=本会话),用 relayBarChainOf 会被入站判断过滤掉,但它的 path
3187
+ * 恰恰是子会话要打开的那份交接单。
3188
+ *
3189
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3190
+ * @returns {{ordinal:number, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path:string|null}|null}
3191
+ */
3192
+ function relayChainProjectionOf(data) {
3193
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3194
+ const chain = data.chain;
3195
+ if (chain === null || typeof chain !== 'object') return null;
3196
+ if (!Number.isInteger(chain.ordinal) || chain.ordinal <= 0) return null;
3197
+ const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
3198
+ return {
3199
+ ordinal: chain.ordinal,
3200
+ baseTitle: text(chain.baseTitle),
3201
+ parentId: text(chain.parentId),
3202
+ childId: text(chain.childId),
3203
+ scheme: chain.scheme === 'handoff-n' || chain.scheme === 'history-n' ? chain.scheme : null,
3204
+ path: text(chain.path),
3205
+ };
3206
+ }
3207
+
3208
+ /**
3209
+ * 0.5.0:旧会话是否已被交接流程自动归档。
3210
+ *
3211
+ * 宿主 `readState` 在链有父会话时下发 `parentArchived`(boolean;服务不可用为 null)。
3212
+ * 老宿主没有这个字段 → null(客户端按「可尝试恢复」处理,恢复调用本身幂等)。
3213
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3214
+ * @returns {boolean|null}
3215
+ */
3216
+ function relayBarParentArchivedOf(data) {
3217
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3218
+ return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
3219
+ }
3220
+
3221
+ /**
3222
+ * 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
3223
+ * @param {object} chain relayBarChainOf 的结果
3224
+ * @returns {string}
3225
+ */
3226
+ function relayBarLabelOf(chain) {
3227
+ const baseTitle = chain.baseTitle === null ? '会话' : chain.baseTitle;
3228
+ return chain.scheme === 'history-n' ? `历史 ${chain.ordinal}:${baseTitle}` : `交接第 ${chain.ordinal} 次:${baseTitle}`;
3229
+ }
3230
+
3231
+ /**
3232
+ * 交接单绝对路径 → `/api/compact-relay-handoffs?file=` 只接受的 handoff-*.md 文件名。
3233
+ * 服务端只认文件名(不是全路径),这里取 basename 并做同一形态校验;不合法 → null。
3234
+ * @param {string|null} path
3235
+ * @returns {string|null}
3236
+ */
3237
+ function relayBarDocNameOf(path) {
3238
+ if (typeof path !== 'string' || path.length === 0) return null;
3239
+ const name = path.split(/[\\/]/).pop();
3240
+ return typeof name === 'string' && /^handoff-[0-9A-Za-z][0-9A-Za-z.\-_]*\.md$/.test(name) ? name : null;
3241
+ }
3242
+
3243
+ /** 接力条菜单里的一行(和指示灯菜单同一套视觉,但更轻)。 */
3244
+ function relayBarItem(label, sub, key, action, meta = {}) {
3245
+ const disabled = meta.disabled === true;
3246
+ return h(
3247
+ 'button',
3248
+ {
3249
+ type: 'button',
3250
+ key,
3251
+ role: 'menuitem',
3252
+ 'data-cr-relay-action': key,
3253
+ disabled,
3254
+ title: meta.title ?? (disabled ? sub : undefined),
3255
+ onClick: () => {
3256
+ if (disabled || typeof action !== 'function') return;
3257
+ action();
3258
+ },
3259
+ style: {
3260
+ display: 'flex',
3261
+ alignItems: 'center',
3262
+ gap: 10,
3263
+ width: '100%',
3264
+ minHeight: 32,
3265
+ padding: '5px 10px',
3266
+ border: 'none',
3267
+ borderRadius: 8,
3268
+ background: 'transparent',
3269
+ textAlign: 'left',
3270
+ cursor: disabled ? 'default' : 'pointer',
3271
+ color: 'var(--dsw-alias-label-primary, inherit)',
3272
+ opacity: disabled ? 0.55 : 1,
3273
+ font: 'inherit',
3274
+ },
3275
+ onMouseEnter: (event) => {
3276
+ if (!disabled) event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
3277
+ },
3278
+ onMouseLeave: (event) => {
3279
+ event.currentTarget.style.background = 'transparent';
3280
+ },
3281
+ },
3282
+ h(
3283
+ 'span',
3284
+ { style: { display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 } },
3285
+ h('span', { style: { fontSize: 12, fontWeight: 500 } }, label),
3286
+ sub !== null
3287
+ ? h(
3288
+ 'span',
3289
+ { style: { fontSize: 10, lineHeight: '14px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3290
+ sub,
3291
+ )
3292
+ : null,
3293
+ ),
3294
+ h(
3295
+ 'span',
3296
+ { style: { flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))', whiteSpace: 'nowrap' } },
3297
+ meta.right ?? (disabled ? '' : '›'),
3298
+ ),
3299
+ );
3300
+ }
3301
+
3302
+ /**
3303
+ * 接力条本体(`conversation.input.dock`,输入卡上方一行)。
3304
+ *
3305
+ * @param {object} props ctx.slots.register 注入的 props
3306
+ * @param {string} [props.sessionId] 当前会话 id(session scope)
3307
+ * @param {(sessionId:string)=>unknown} [props.openSession] 切到某个会话(ctx.uiWorkspace.openSession)
3308
+ * @param {(sessionId:string)=>Promise<unknown>|undefined} [props.unarchiveSession] 恢复归档会话
3309
+ * (ctx.uiWorkspace.unarchiveSession;老宿主没有 → 走本插件 HTTP recover 兜底)
3310
+ */
3311
+ function CompactRelayBar(props) {
3312
+ const sessionId = props === null || props === undefined ? undefined : props.sessionId;
3313
+ const openSession = props === null || props === undefined ? undefined : props.openSession;
3314
+ const unarchiveSession = props === null || props === undefined ? undefined : props.unarchiveSession;
3315
+ const hasSession = typeof sessionId === 'string' && sessionId.length > 0;
3316
+ const [chain, setChain] = useState(null);
3317
+ // null = 宿主没给归档状态(老宿主);true/false = readState.parentArchived。
3318
+ const [parentArchived, setParentArchived] = useState(null);
3319
+ const [open, setOpen] = useState(false);
3320
+ const [doc, setDoc] = useState(null);
3321
+ const [notice, setNotice] = useState(null);
3322
+ const [anchor, setAnchor] = useState(null);
3323
+ const theme = useAutoTheme();
3324
+ const rootRef = useRef(null);
3325
+ const triggerRef = useRef(null);
3326
+ const menuRef = useRef(null);
3327
+ const docRequestRef = useRef(0);
3328
+ const docAbortRef = useRef(null);
3329
+
3330
+ /** 取消未完成的交接单读取;同时让晚到的响应失效(requestId 对不上)。 */
3331
+ const abortDoc = useCallback(() => {
3332
+ docRequestRef.current += 1;
3333
+ if (docAbortRef.current !== null) {
3334
+ try {
3335
+ docAbortRef.current.abort();
3336
+ } catch {
3337
+ /* ignore */
3338
+ }
3339
+ docAbortRef.current = null;
3340
+ }
3341
+ }, []);
3342
+
3343
+ const closeMenu = useCallback(() => {
3344
+ abortDoc();
3345
+ setOpen(false);
3346
+ setDoc(null);
3347
+ setNotice(null);
3348
+ }, [abortDoc]);
3349
+
3350
+ // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
3351
+ const backToMenu = useCallback(() => {
3352
+ abortDoc();
3353
+ setDoc(null);
3354
+ }, [abortDoc]);
3355
+
3356
+ // 读链状态。会话刚打开时宿主可能还没把日志装进内存(readState.sessionId 为
3357
+ // null),这时要重试几次;一旦宿主确认「就是这个会话且没有 chain」就停,
3358
+ // 普通会话不会无限打接口。fetch 失败/老宿主没有 chain → setChain(null) → 不渲染。
3359
+ useEffect(() => {
3360
+ // 换会话/没有会话时先清掉上一条链(dock 组件可能被复用而不是重挂)。
3361
+ setChain(null);
3362
+ setParentArchived(null);
3363
+ setOpen(false);
3364
+ setDoc(null);
3365
+ setNotice(null);
3366
+ if (!hasSession) return undefined;
3367
+ let dead = false;
3368
+ let timer = 0;
3369
+ let attempts = 0;
3370
+ const url = `/api/compact-relay-state?sessionId=${encodeURIComponent(sessionId)}`;
3371
+ const schedule = () => {
3372
+ if (dead) return;
3373
+ attempts += 1;
3374
+ if (attempts > 12) return;
3375
+ timer = setTimeout(() => {
3376
+ void load();
3377
+ }, Math.min(500 * attempts, 3000));
3378
+ };
3379
+ async function loadParentPath(localChain) {
3380
+ try {
3381
+ const res = await fetch(`/api/compact-relay-state?sessionId=${encodeURIComponent(localChain.parentId)}`, {
3382
+ cache: 'no-store',
3383
+ });
3384
+ if (dead || !res.ok) return;
3385
+ const parentData = await res.json().catch(() => null);
3386
+ if (dead) return;
3387
+ const parentChain = relayChainProjectionOf(parentData);
3388
+ if (
3389
+ parentChain === null ||
3390
+ parentChain.childId !== sessionId ||
3391
+ parentChain.ordinal !== localChain.ordinal ||
3392
+ parentChain.path === null
3393
+ ) {
3394
+ return;
3395
+ }
3396
+ setChain((previous) => (previous === null ? previous : { ...previous, path: parentChain.path }));
3397
+ } catch {
3398
+ /* 父会话读不到(未加载/已归档):保持「暂无交接单」,不影响接力条本身 */
3399
+ }
3400
+ }
3401
+ async function load() {
3402
+ try {
3403
+ const res = await fetch(url, { cache: 'no-store' });
3404
+ if (dead) return;
3405
+ if (!res.ok) {
3406
+ // 4xx(鉴权/端点不存在)等下去也不会变;5xx/网络抖动才重试。
3407
+ if (res.status >= 400 && res.status < 500) return;
3408
+ schedule();
3409
+ return;
3410
+ }
3411
+ const data = await res.json().catch(() => null);
3412
+ if (dead) return;
3413
+ if (data !== null && typeof data === 'object' && data.ok === false) return;
3414
+ const parsed = relayBarChainOf(data, sessionId);
3415
+ if (parsed !== null) {
3416
+ setChain(parsed);
3417
+ setParentArchived(relayBarParentArchivedOf(data));
3418
+ // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3419
+ // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3420
+ // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
3421
+ if (parsed.path === null && parsed.parentId !== null) void loadParentPath(parsed);
3422
+ return;
3423
+ }
3424
+ // sessionId 对不上/为 null → 宿主还没加载本会话,继续等;对得上 → 确定没有链。
3425
+ const loaded =
3426
+ data !== null && typeof data === 'object' && typeof data.sessionId === 'string' && data.sessionId === sessionId;
3427
+ if (loaded) {
3428
+ setChain(null);
3429
+ setParentArchived(null);
3430
+ } else schedule();
3431
+ } catch {
3432
+ /* 宿主不在/网络失败:保持「没有链」—— 不显示,不占高度;稍后再试 */
3433
+ schedule();
3434
+ }
3435
+ }
3436
+ void load();
3437
+ return () => {
3438
+ dead = true;
3439
+ if (timer !== 0) clearTimeout(timer);
3440
+ };
3441
+ }, [hasSession, sessionId]);
3442
+
3443
+ // 菜单打开时按这一行的真实位置重算锚点(滚动/缩放不漂)。
3444
+ useEffect(() => {
3445
+ if (!open) return undefined;
3446
+ const update = () => {
3447
+ const root = rootRef.current;
3448
+ const el = root === null || root === undefined ? null : root.firstElementChild;
3449
+ if (el === null || el === undefined || typeof el.getBoundingClientRect !== 'function') return;
3450
+ const rect = el.getBoundingClientRect();
3451
+ setAnchor({
3452
+ left: Math.round(rect.left),
3453
+ right: Math.round(rect.right),
3454
+ top: Math.round(rect.top),
3455
+ bottom: Math.round(rect.bottom),
3456
+ });
3457
+ };
3458
+ update();
3459
+ window.addEventListener('scroll', update, true);
3460
+ window.addEventListener('resize', update);
3461
+ return () => {
3462
+ window.removeEventListener('scroll', update, true);
3463
+ window.removeEventListener('resize', update);
3464
+ };
3465
+ }, [open]);
3466
+
3467
+ // 点外面 / Esc 关闭菜单与交接单查看。
3468
+ useEffect(() => {
3469
+ if (!open) return undefined;
3470
+ const inside = (target) => {
3471
+ const root = rootRef.current;
3472
+ const menu = menuRef.current;
3473
+ return (root !== null && root !== undefined && root.contains(target)) || (menu !== null && menu !== undefined && menu.contains(target));
3474
+ };
3475
+ const onDown = (event) => {
3476
+ if (inside(event.target)) return;
3477
+ closeMenu();
3478
+ };
3479
+ const onKey = (event) => {
3480
+ if (event.key !== 'Escape') return;
3481
+ closeMenu();
3482
+ const trigger = triggerRef.current;
3483
+ if (trigger !== null && trigger !== undefined && typeof trigger.focus === 'function') {
3484
+ window.setTimeout(() => trigger.focus(), 0);
3485
+ }
3486
+ };
3487
+ document.addEventListener('mousedown', onDown);
3488
+ document.addEventListener('keydown', onKey);
3489
+ return () => {
3490
+ document.removeEventListener('mousedown', onDown);
3491
+ document.removeEventListener('keydown', onKey);
3492
+ };
3493
+ }, [open, closeMenu]);
3494
+
3495
+ // 打开后把焦点送进第一个可操作项(键盘用户不用再 Tab 找)。
3496
+ useEffect(() => {
3497
+ if (!open || doc !== null) return;
3498
+ const menu = menuRef.current;
3499
+ if (menu === null || menu === undefined || typeof menu.querySelector !== 'function') return;
3500
+ const first = menu.querySelector('button:not(:disabled)');
3501
+ if (first !== null && first !== undefined && typeof first.focus === 'function') first.focus();
3502
+ }, [open, doc]);
3503
+
3504
+ const toggleMenu = useCallback((event) => {
3505
+ const current = event === null || event === undefined ? null : event.currentTarget;
3506
+ if (current !== null && current !== undefined && typeof current.getBoundingClientRect === 'function') {
3507
+ const rect = current.getBoundingClientRect();
3508
+ setAnchor({
3509
+ left: Math.round(rect.left),
3510
+ right: Math.round(rect.right),
3511
+ top: Math.round(rect.top),
3512
+ bottom: Math.round(rect.bottom),
3513
+ });
3514
+ }
3515
+ abortDoc();
3516
+ setNotice(null);
3517
+ setDoc(null);
3518
+ setOpen((value) => !value);
3519
+ }, [abortDoc]);
3520
+
3521
+ const loadDoc = useCallback(async (name) => {
3522
+ if (typeof name !== 'string' || name.length === 0) return;
3523
+ if (docAbortRef.current !== null) {
3524
+ try {
3525
+ docAbortRef.current.abort();
3526
+ } catch {
3527
+ /* ignore */
3528
+ }
3529
+ }
3530
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
3531
+ docAbortRef.current = controller;
3532
+ const requestId = ++docRequestRef.current;
3533
+ setDoc({ loading: true, name });
3534
+ setNotice(null);
3535
+ try {
3536
+ const res = await fetch(`/api/compact-relay-handoffs?file=${encodeURIComponent(name)}`, {
3537
+ cache: 'no-store',
3538
+ ...(controller === null ? {} : { signal: controller.signal }),
3539
+ });
3540
+ const data = await res.json().catch(() => ({}));
3541
+ if (requestId !== docRequestRef.current) return;
3542
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '读取失败');
3543
+ setDoc({
3544
+ loading: false,
3545
+ name,
3546
+ content: typeof data.content === 'string' ? data.content : '',
3547
+ path: typeof data.path === 'string' ? data.path : null,
3548
+ });
3549
+ } catch (error) {
3550
+ if (requestId !== docRequestRef.current || (error && error.name === 'AbortError')) return;
3551
+ setDoc({
3552
+ loading: false,
3553
+ name,
3554
+ error: '读取失败:' + (error && error.message ? error.message : '未知错误'),
3555
+ });
3556
+ } finally {
3557
+ if (requestId === docRequestRef.current) docAbortRef.current = null;
3558
+ }
3559
+ }, []);
3560
+
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
+ });
3575
+ }
3576
+ return true;
3577
+ } catch (error) {
3578
+ setNotice('打不开原会话:' + (error && error.message ? error.message : '未知错误'));
3579
+ return false;
3580
+ }
3581
+ }, [chain, openSession]);
3582
+
3583
+ /**
3584
+ * 0.5.0:恢复归档的父会话并切过去。
3585
+ *
3586
+ * 优先用宿主 Client 侧的 `ctx.uiWorkspace.unarchiveSession`(与侧栏「取消归档」
3587
+ * 同一条接缝,会同步更新本地归档集合);老宿主没有时走本插件 HTTP
3588
+ * `action:'recover'`(服务端 workspaceRegistry.unarchiveSession),成功后同样尝试切换。
3589
+ * 恢复本身幂等:父会话没归档时宿主 no-op,仍然可以直接打开。
3590
+ */
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
+ }
3610
+ }
3611
+ setParentArchived(false);
3612
+ if (typeof openSession !== 'function') {
3613
+ setNotice('旧会话已恢复,但这个宿主不支持自动切换;请从侧栏打开。');
3614
+ setOpen(true);
3615
+ return;
3616
+ }
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
+ }
3624
+ setOpen(false);
3625
+ setDoc(null);
3626
+ } catch (error) {
3627
+ setNotice('恢复旧会话失败:' + (error && error.message ? error.message : '未知错误'));
3628
+ setOpen(true);
3629
+ }
3630
+ })();
3631
+ }, [chain, openSession, unarchiveSession]);
3632
+
3633
+ const goParent = useCallback(() => {
3634
+ setNotice(null);
3635
+ if (chain === null || chain.parentId === null) return;
3636
+ // 已归档 → 先恢复再打开(否则宿主会拒绝打开归档会话)。
3637
+ if (parentArchived === true) {
3638
+ void restoreParent();
3639
+ return;
3640
+ }
3641
+ if (openParentNow()) {
3642
+ setOpen(false);
3643
+ setDoc(null);
3644
+ }
3645
+ }, [chain, openParentNow, parentArchived, restoreParent]);
3646
+
3647
+ // 无链 → 什么都不渲染(不占 composerStack 的高度)。
3648
+ if (chain === null) return null;
3649
+
3650
+ const label = relayBarLabelOf(chain);
3651
+ const docName = relayBarDocNameOf(chain.path);
3652
+ const hasParent = chain.parentId !== null;
3653
+ // 归档状态已知且未归档 → 不需要恢复入口;true(已归档)或 null(老宿主不知道)
3654
+ // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
3655
+ const showRestore = hasParent && parentArchived !== false;
3656
+ const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
3657
+ const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
3658
+ const menuWidth = Math.min(doc === null ? 272 : 420, Math.max(240, vw - 16));
3659
+ const estimatedHeight = doc === null ? 168 : 360;
3660
+ const anchorTop = anchor === null ? vh - 96 : anchor.top;
3661
+ const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
3662
+ const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
3663
+ const left = Math.max(8, Math.min(anchor === null ? 8 : anchor.left, Math.max(8, vw - menuWidth - 8)));
3664
+ const position = openUp
3665
+ ? { left, bottom: Math.max(8, vh - anchorTop + 6) }
3666
+ : { left, top: Math.min(Math.max(8, anchorBottom + 6), Math.max(8, vh - Math.min(estimatedHeight, vh - 16) - 8)) };
3667
+
3668
+ const docPanel =
3669
+ doc === null
3670
+ ? null
3671
+ : h(
3672
+ 'div',
3673
+ { role: 'dialog', 'aria-label': '交接单', 'data-cr-relay-doc': '1', style: { display: 'flex', flexDirection: 'column', minHeight: 0 } },
3674
+ h(
3675
+ 'div',
3676
+ {
3677
+ style: {
3678
+ display: 'flex',
3679
+ alignItems: 'center',
3680
+ gap: 8,
3681
+ padding: '7px 10px',
3682
+ borderBottom: `1px solid ${theme.panelBorder}`,
3683
+ fontSize: 12,
3684
+ fontWeight: 600,
3685
+ },
3686
+ },
3687
+ h('span', { style: { flex: 1, minWidth: 0 } }, '交接单'),
3688
+ h(
3689
+ 'button',
3690
+ {
3691
+ type: 'button',
3692
+ 'data-cr-relay-doc-back': '1',
3693
+ onClick: backToMenu,
3694
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
3695
+ },
3696
+ '← 返回',
3697
+ ),
3698
+ h(
3699
+ 'button',
3700
+ {
3701
+ type: 'button',
3702
+ 'data-cr-relay-doc-close': '1',
3703
+ onClick: closeMenu,
3704
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
3705
+ },
3706
+ '关闭',
3707
+ ),
3708
+ ),
3709
+ doc.loading === true
3710
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: theme.subText } }, '读取中…')
3711
+ : doc.error !== undefined && doc.error !== null
3712
+ ? h('div', { style: { padding: '10px 12px', fontSize: 11, color: COLORS.error } }, String(doc.error))
3713
+ : h(
3714
+ 'div',
3715
+ { style: { overflowY: 'auto', overflowX: 'hidden', padding: '8px 12px', fontSize: 11, lineHeight: '16px', maxHeight: 'min(52vh, 320px)', wordBreak: 'break-word' } },
3716
+ renderMarkdownLite(doc.content ?? ''),
3717
+ ),
3718
+ );
3719
+
3720
+ const menu =
3721
+ open === true
3722
+ ? h(
3723
+ 'div',
3724
+ {
3725
+ ref: menuRef,
3726
+ role: 'menu',
3727
+ 'aria-label': '接力菜单',
3728
+ 'data-cr-relay-menu': '1',
3729
+ style: {
3730
+ position: 'fixed',
3731
+ ...position,
3732
+ width: menuWidth,
3733
+ maxWidth: 'calc(100vw - 16px)',
3734
+ maxHeight: 'min(65vh, 460px)',
3735
+ display: 'flex',
3736
+ flexDirection: 'column',
3737
+ overflow: 'hidden',
3738
+ padding: doc === null ? 5 : 0,
3739
+ borderRadius: 12,
3740
+ ...panelChrome(theme),
3741
+ color: theme.panelFg,
3742
+ // 顶层:高于指示灯菜单/确认卡(39–44),避免被输入区或其它浮层盖住。
3743
+ zIndex: 60,
3744
+ },
3745
+ },
3746
+ notice !== null
3747
+ ? h(
3748
+ 'div',
3749
+ { 'data-cr-relay-notice': '1', style: { padding: '7px 10px', fontSize: 11, lineHeight: '15px', color: 'var(--dsw-alias-state-error-primary, #D92D20)' } },
3750
+ notice,
3751
+ )
3752
+ : null,
3753
+ docPanel ??
3754
+ h(
3755
+ 'div',
3756
+ { style: { display: 'flex', flexDirection: 'column' } },
3757
+ relayBarItem('查看交接单', docName === null ? '这一链没有记录交接单路径' : '打开本次交接的九段交接单', 'doc', () => {
3758
+ void loadDoc(docName);
3759
+ }, {
3760
+ disabled: docName === null,
3761
+ // 需求文案:无 path 时明确提示「暂无交接单」。
3762
+ right: docName === null ? '暂无交接单' : '›',
3763
+ title: docName === null ? '暂无交接单:这条接力记录里没有交接单路径(旧记录,或父会话已不在)' : '打开交接单',
3764
+ }),
3765
+ relayBarItem('回到原会话', hasParent ? '切到上一个会话,继续看那边历史' : '这条记录没有父会话 id', 'parent', goParent, {
3766
+ disabled: !hasParent,
3767
+ right: hasParent ? '›' : '不可用',
3768
+ title: hasParent ? `打开原会话 ${chain.parentId}` : undefined,
3769
+ }),
3770
+ showRestore
3771
+ ? relayBarItem(
3772
+ '恢复旧会话',
3773
+ parentArchived === true
3774
+ ? '旧会话已归档:恢复后切回上一代'
3775
+ : '宿主没提供归档状态;恢复调用是幂等的,仍可直接尝试',
3776
+ 'unarchive',
3777
+ () => {
3778
+ void restoreParent();
3779
+ },
3780
+ {
3781
+ right: parentArchived === true ? '已归档' : '恢复',
3782
+ title: `从归档中恢复旧会话 ${chain.parentId}`,
3783
+ },
3784
+ )
3785
+ : null,
3786
+ relayBarItem('路线图(即将上线)', '跨会话时间线,0.6 之后', 'roadmap', null, {
3787
+ disabled: true,
3788
+ right: '即将上线',
3789
+ }),
3790
+ ),
3791
+ )
3792
+ : null;
3793
+
3794
+ return h(
3795
+ 'div',
3796
+ {
3797
+ ref: rootRef,
3798
+ 'data-cr-relay-root': '1',
3799
+ style: {
3800
+ boxSizing: 'border-box',
3801
+ flex: 'none',
3802
+ // 与宿主 TodoPanel(同一 dock 槽)一致:两侧留出 composer 的边距,
3803
+ // 宽度跟着输入卡走,避免通栏压到侧栏。
3804
+ width: 'calc(100% - var(--dsh-composer-side-clearance, 16px) * 2 - var(--dsh-composer-dock-inset, 8px) * 4)',
3805
+ maxWidth: 'calc(var(--dsh-composer-card-max-width, 920px) - var(--dsh-composer-dock-inset, 8px) * 4)',
3806
+ margin: '0 auto',
3807
+ },
3808
+ },
3809
+ // 卡片外观放在内层:backdrop-filter 会给 fixed 子元素造 containing block,
3810
+ // 菜单必须留在这一层之外,才能相对视口定位(不被输入区裁切)。
3811
+ h(
3812
+ 'div',
3813
+ {
3814
+ 'data-cr-relay-card': '1',
3815
+ style: {
3816
+ borderRadius: 'var(--dsw-radius-lg, 12px)',
3817
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.24))',
3818
+ // 底色/字体全部走 DSH 主题 token:切深浅色不需要重挂组件;老宿主用 theme 兜底。
3819
+ background: `var(--dsw-specific-menu, ${theme.panelBg})`,
3820
+ backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
3821
+ WebkitBackdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
3822
+ boxShadow: 'var(--dsw-elevation-panel, 0 1px 2px rgba(0,0,0,0.06))',
3823
+ color: 'var(--dsw-alias-label-primary, inherit)',
3824
+ overflow: 'hidden',
3825
+ },
3826
+ },
3827
+ h(
3828
+ 'button',
3829
+ {
3830
+ type: 'button',
3831
+ ref: triggerRef,
3832
+ '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 ?? '',
3837
+ // 空串 = 宿主没给归档状态(老宿主),'true'/'false' = readState.parentArchived。
3838
+ 'data-cr-relay-parent-archived': parentArchived === null ? '' : String(parentArchived),
3839
+ 'aria-haspopup': 'menu',
3840
+ 'aria-expanded': open,
3841
+ 'aria-label': `已接力自 ${label},打开接力菜单`,
3842
+ onClick: toggleMenu,
3843
+ style: {
3844
+ display: 'flex',
3845
+ alignItems: 'center',
3846
+ gap: 8,
3847
+ width: '100%',
3848
+ minHeight: 30,
3849
+ padding: '3px 10px',
3850
+ border: 'none',
3851
+ borderRadius: 'inherit',
3852
+ background: 'transparent',
3853
+ color: 'inherit',
3854
+ font: 'inherit',
3855
+ textAlign: 'left',
3856
+ cursor: 'pointer',
3857
+ },
3858
+ onMouseEnter: (event) => {
3859
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.10))';
3860
+ },
3861
+ onMouseLeave: (event) => {
3862
+ event.currentTarget.style.background = 'transparent';
3863
+ },
3864
+ },
3865
+ h(
3866
+ 'span',
3867
+ { style: { flex: 'none', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))' } },
3868
+ // 末尾留一个空格:flex gap 负责视觉间距,文本内容(读屏/可访问性树)也要读得通。
3869
+ '已接力自 ',
3870
+ ),
3871
+ h(
3872
+ 'span',
3873
+ {
3874
+ style: {
3875
+ flex: 1,
3876
+ minWidth: 0,
3877
+ fontSize: 13,
3878
+ fontWeight: 500,
3879
+ lineHeight: '20px',
3880
+ color: 'var(--dsw-alias-label-primary, inherit)',
3881
+ overflow: 'hidden',
3882
+ textOverflow: 'ellipsis',
3883
+ whiteSpace: 'nowrap',
3884
+ },
3885
+ },
3886
+ label,
3887
+ ),
3888
+ h(
3889
+ 'span',
3890
+ {
3891
+ 'aria-hidden': true,
3892
+ style: {
3893
+ flex: 'none',
3894
+ fontSize: 10,
3895
+ lineHeight: 1,
3896
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))',
3897
+ transform: open ? 'rotate(180deg)' : 'none',
3898
+ transition: 'transform 120ms ease',
3899
+ },
3900
+ },
3901
+ '▾',
3902
+ ),
3903
+ ),
3904
+ ),
3905
+ menu,
3906
+ );
3907
+ }
3908
+
3144
3909
  /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
3145
3910
  function handoffRow(text, tone, action) {
3146
3911
  return h(
@@ -4827,6 +5592,30 @@ window.__ModuleLoader__.load({
4827
5592
  ),
4828
5593
  );
4829
5594
 
5595
+ // 0.5.0 接力条 v1:输入卡上方通栏一行「已接力自 …」+ 向上弹出的下拉菜单。
5596
+ // 组件内部再判断 chain:无链/老宿主没有 chain 字段 → 返回 null,不占高度。
5597
+ ctx.slots.inject('conversation.input.dock', () =>
5598
+ ctx.slots.register(
5599
+ {
5600
+ name: 'conversation.input.dock',
5601
+ id: 'compact-relay',
5602
+ // 宿主 TodoPanel 是 0、queue 是 20;接力条夹在中间(输入卡正上方)。
5603
+ order: 10,
5604
+ inject: (sessionId) => ({
5605
+ sessionId,
5606
+ openSession: (targetSessionId) => ctx.uiWorkspace.openSession(targetSessionId),
5607
+ // 0.5.0:恢复归档旧会话(与侧栏「取消归档」同一条 Client 接缝)。
5608
+ // 老宿主没有这个方法时传 undefined,组件会退回 HTTP recover。
5609
+ unarchiveSession:
5610
+ typeof ctx.uiWorkspace?.unarchiveSession === 'function'
5611
+ ? (targetSessionId) => ctx.uiWorkspace.unarchiveSession(targetSessionId)
5612
+ : undefined,
5613
+ }),
5614
+ },
5615
+ CompactRelayBar,
5616
+ ),
5617
+ );
5618
+
4830
5619
  // /handoff 命令卡:成功后自动切换(key 必须等于命令名)。
4831
5620
  ctx.slots.inject('conversation.chat.commandview', () =>
4832
5621
  ctx.slots.register(