dsh-sidecard-ask 2.1.7 → 2.1.9

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/client.js CHANGED
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
117
117
  * the Host and the module's `api.version` both read it, so a report can
118
118
  * never claim a generation the browser is not actually running.
119
119
  */
120
- const CLIENT_VERSION = '2.1.7'
120
+ const CLIENT_VERSION = '2.1.9'
121
121
 
122
122
  /**
123
123
  * Tab kind served by the DSH native right rail (also its implementation id).
@@ -206,6 +206,10 @@ window.__ModuleLoader__.load({
206
206
  // tool-narrowing channel 0.2.1 honors (see the settings row help text).
207
207
  sideAgentPreset: '',
208
208
  floatMode: 'capsule',
209
+ // 2.1.9:答完自动把浮层从胶囊展开成卡片。胶囊只渲染一行状态,答案要
210
+ // 点一下 › 才看得到;用户明确要求「提问完成之后直接在侧边卡片显示回答内容」。
211
+ // 置 false 可恢复旧行为(始终停在胶囊)。
212
+ autoExpandOnDone: true,
209
213
  floatMaxWidth: 340,
210
214
  // Docked presentation (1.8.3): when the right rail is on screen, the
211
215
  // answer paints inside the rail's rectangle instead of floating as a
@@ -463,6 +467,12 @@ window.__ModuleLoader__.load({
463
467
  // where a page goes. It also works on builds whose rail markers we have
464
468
  // never seen — including the leftover "card closed" tab, which still
465
469
  // occupies a page body.
470
+ //
471
+ // 0.28.x moved the side-card plugin's tabs INTO this same rail, so this
472
+ // marker now also matches a DIFFERENT card's page. `findRail` refuses a
473
+ // body that declares another card id (`data-dsa-card-id`) instead of
474
+ // painting our card on top of it; unmarked bodies (older hosts, leftover
475
+ // closed-card faces) stay eligible.
466
476
  '[data-dsa-rail-host]',
467
477
  '[data-sidebar-right-panel][data-sidebar-right-open] [data-dockkit-pane]',
468
478
  '[data-sidebar-right-panel][data-sidebar-right-open]',
@@ -539,17 +549,31 @@ window.__ModuleLoader__.load({
539
549
  *
540
550
  * @returns {{node: Element, box: {left: number, top: number, width: number, height: number}}|null}
541
551
  */
542
- function findRail(scope) {
552
+ function findRail(scope, ownCardIds) {
543
553
  if (scope === null || scope === undefined || typeof scope.querySelector !== 'function') return null
554
+ const owns = ownCardIds instanceof Set ? ownCardIds : null
555
+ let occupied = false
544
556
  for (const selector of RAIL_SELECTORS) {
545
557
  let node = null
546
558
  try { node = scope.querySelector(selector) } catch { node = null }
547
559
  if (node === null || typeof node.getBoundingClientRect !== 'function') continue
560
+ // A body that names a card this layer does not own belongs to another
561
+ // side card (or another session's page). Docking into it would cover
562
+ // that card, so it is skipped and remembered as "occupied" — the layer
563
+ // then stays floating and says why through `data-dsa-dock`.
564
+ let cardId = null
565
+ try {
566
+ cardId = typeof node.getAttribute === 'function' ? node.getAttribute('data-dsa-card-id') : null
567
+ } catch { cardId = null }
568
+ if (typeof cardId === 'string' && cardId !== '' && (owns === null || !owns.has(cardId))) {
569
+ occupied = true
570
+ continue
571
+ }
548
572
  let box = null
549
573
  try { box = dockedBodyRect(node) } catch { box = null }
550
- if (railOnScreen(box)) return { node, box }
574
+ if (railOnScreen(box)) return { node, box, occupied }
551
575
  }
552
- return null
576
+ return occupied ? { node: null, box: null, occupied: true } : null
553
577
  }
554
578
 
555
579
  /**
@@ -627,6 +651,7 @@ window.__ModuleLoader__.load({
627
651
  answerNonStreaming: '当前 DSH 版本未提供流式帧,答案一次性返回',
628
652
  answerEmpty: '(没有内容)',
629
653
  cardClosed: '该卡片已关闭',
654
+ closeThisTab: '关闭此页',
630
655
  cardMovedToFlow: '该卡片已移至浮层显示',
631
656
  cardDelete: '删除这条追问对话',
632
657
  cardDeleteTitle: '删除这条追问对话?',
@@ -660,6 +685,7 @@ window.__ModuleLoader__.load({
660
685
  errorBusy: '并发追问已达上限({n}),请稍后再试',
661
686
  errorNoSideEngineBody: '这台 DSH 组合没有可用的子代理 provider',
662
687
  errorNoParentBody: '当前没有活动的会话代理,无法发起独立作答',
688
+ errorNoReadonlyGuardBody: '这台宿主的工具注册表里认不出只读工具,为保证侧边作答不写工作区,本次已拒绝',
663
689
  errorAborted: '作答被取消或超时',
664
690
  errorUnreachableBody: '宿主接口不可达',
665
691
  errorUnauthorizedBody: '需要先通过 DSH 登录(宿主会话未认证)',
@@ -689,6 +715,8 @@ window.__ModuleLoader__.load({
689
715
  setFloatModeCapsule: '胶囊(不遮挡对话,可随时展开)',
690
716
  setFloatModeFull: '直接展开成卡片',
691
717
  setFloatModeHint: '浮层贴右侧边缘,只在空间不足时才可能压到对话区',
718
+ setAutoExpandOnDone: '答完自动展开卡片',
719
+ setAutoExpandOnDoneHint: '开启后,回答一结束就展开成卡片显示正文;关闭后保持一行胶囊,点一下才展开',
692
720
  setFloatMaxWidth: '浮层宽度上限(px)',
693
721
  setFloatMaxWidthHint: '空间不足时自动收窄,不会硬顶',
694
722
  setDockRail: '右侧栏打开时占满它',
@@ -902,6 +930,7 @@ window.__ModuleLoader__.load({
902
930
  answerNonStreaming: 'This DSH build publishes no stream frames; the answer arrived at once',
903
931
  answerEmpty: '(empty)',
904
932
  cardClosed: 'This card was closed',
933
+ closeThisTab: 'Close this tab',
905
934
  cardMovedToFlow: 'This card moved to the floating stack',
906
935
  cardDelete: 'Delete this follow-up conversation',
907
936
  cardDeleteTitle: 'Delete this follow-up conversation?',
@@ -935,6 +964,7 @@ window.__ModuleLoader__.load({
935
964
  errorBusy: 'Too many concurrent answers (limit {n}); try again shortly',
936
965
  errorNoSideEngineBody: 'This DSH composition has no usable subagent provider',
937
966
  errorNoParentBody: 'No active session agent to answer independently',
967
+ errorNoReadonlyGuardBody: 'This host\'s tool registry has no recognisable read-only tools, so the side answer was refused to keep the workspace untouched',
938
968
  errorAborted: 'The answer was cancelled or timed out',
939
969
  errorUnreachableBody: 'The host API is unreachable',
940
970
  errorUnauthorizedBody: 'Sign in to DSH first (the host session is not authenticated)',
@@ -964,6 +994,8 @@ window.__ModuleLoader__.load({
964
994
  setFloatModeCapsule: 'Capsule (never covers the conversation)',
965
995
  setFloatModeFull: 'Expanded card stack',
966
996
  setFloatModeHint: 'The layer is anchored to the right edge and only overlaps on very narrow frames',
997
+ setAutoExpandOnDone: 'Expand the card when the answer finishes',
998
+ setAutoExpandOnDoneHint: 'On: the answer appears in the card the moment it settles. Off: the one-line capsule stays until you click it',
967
999
  setFloatMaxWidth: 'Floating layer max width (px)',
968
1000
  setFloatMaxWidthHint: 'Narrows automatically when there is not enough room',
969
1001
  setDockRail: 'Fill the right rail while it is open',
@@ -1215,6 +1247,9 @@ window.__ModuleLoader__.load({
1215
1247
  busy: { key: 'errorBusy', vars: () => ({ n: store?.state?.config?.maxConcurrentAsks ?? 0 }) },
1216
1248
  'no-side-engine': { key: 'errorNoSideEngineBody' },
1217
1249
  'no-parent': { key: 'errorNoParentBody' },
1250
+ // 2.1.9: the read-only guard now fails closed. The code must be named
1251
+ // Client-side, or an English UI would show the Host's Chinese line.
1252
+ 'no-readonly-guard': { key: 'errorNoReadonlyGuardBody' },
1218
1253
  aborted: { key: 'errorAborted' },
1219
1254
  unreachable: { key: 'errorUnreachableBody' },
1220
1255
  // The Host now gates these routes on the DSH browser session, so both are
@@ -1606,6 +1641,23 @@ window.__ModuleLoader__.load({
1606
1641
  store.set({ floatMode: mode })
1607
1642
  }
1608
1643
 
1644
+ /**
1645
+ * Bring a finished card on screen.
1646
+ *
1647
+ * The floating layer defaults to the `capsule` pill (one line of status), so
1648
+ * an answer that finishes while collapsed never shows its text. Only a card
1649
+ * carried by the flow layer needs this: a card in the right rail already has
1650
+ * its own page, and an already-expanded card is left alone.
1651
+ * @param {string} id - the card that just finished.
1652
+ */
1653
+ function revealFinishedCard(id) {
1654
+ if (store.state.config.autoExpandOnDone === false) return
1655
+ const card = store.state.cards.find(item => item.id === id)
1656
+ if (card === undefined || card.surface !== 'flow') return
1657
+ setFloatMode('full')
1658
+ patchCard(id, { open: true })
1659
+ }
1660
+
1609
1661
  /** Replace one card by id (immutably; a no-op when it is gone). */
1610
1662
  function patchCard(id, patch) {
1611
1663
  const cards = store.state.cards.map(card => (card.id === id ? { ...card, ...patch } : card))
@@ -2332,21 +2384,34 @@ window.__ModuleLoader__.load({
2332
2384
  * this id instead of guessing at a `tab.meta` that no longer exists.
2333
2385
  */
2334
2386
  cardId: null,
2387
+ /** Per-session tab ownership; see `ownerKeyOf`. */
2388
+ owners: new Map(),
2335
2389
  open(card) {
2336
2390
  if (!this.available() || typeof this.controller.openTab !== 'function') return false
2337
2391
  // 同一个 kind 在原生栏里只有一个地址(见 cardId 的注释):先把栏里
2338
2392
  // 其它卡片迁回浮层,否则它们既不在栏里也不在浮层上,流式输出还会
2339
2393
  // 继续往一张看不见的卡片里写。
2394
+ const openSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2395
+ ? card.sessionId
2396
+ : store.state.sessionId
2397
+ const openKey = ownerKeyOf(openSession)
2340
2398
  for (const other of store.state.cards) {
2341
- if (other.id !== card.id && other.surface === 'native-rightbar') {
2342
- // `open: true` is load-bearing: the floating layer only mounts a
2343
- // card whose `surface === 'flow'` AND `open` is true, so migrating
2344
- // a card back to the flow while leaving it folded hid it on BOTH
2345
- // surfaces — not in the rail, not on screen, still streaming.
2346
- patchCard(other.id, { surface: 'flow', open: true })
2347
- }
2399
+ if (other.id === card.id || other.surface !== 'native-rightbar') continue
2400
+ // Only the SAME session's other card loses its address: every
2401
+ // session keeps its own tab, so migrating across sessions would
2402
+ // yank a card out of a rail the user is still looking at.
2403
+ const otherSession = typeof other.sessionId === 'string' && other.sessionId !== ''
2404
+ ? other.sessionId
2405
+ : store.state.sessionId
2406
+ if (ownerKeyOf(otherSession) !== openKey) continue
2407
+ // `open: true` is load-bearing: the floating layer only mounts a
2408
+ // card whose `surface === 'flow'` AND `open` is true, so migrating
2409
+ // a card back to the flow while leaving it folded hid it on BOTH
2410
+ // surfaces — not in the rail, not on screen, still streaming.
2411
+ patchCard(other.id, { surface: 'flow', open: true })
2348
2412
  }
2349
2413
  this.cardId = card.id
2414
+ this.owners.set(openKey, card.id)
2350
2415
  try {
2351
2416
  this.controller.openTab(CARD_KIND, { params: { cardId: card.id }, revealIfOpened: true })
2352
2417
  } catch (error) {
@@ -2372,12 +2437,20 @@ window.__ModuleLoader__.load({
2372
2437
  // 却把另一种真实场景关在门外:页面重载后宿主会从布局里恢复出这张
2373
2438
  // tab,而 `cardId` 是内存态、必然是 null —— 此时没有卡片认领它,
2374
2439
  // 删除历史条目也永远关不掉,就是专家复现的"幽灵面板"。
2375
- // 规则改为:**已知别的卡拥有它才拒绝**;无主时按 kind + 会话定位。
2376
- if (this.cardId !== null && this.cardId !== card.id) return false
2377
- // 卡片自己的会话优先:用户切走会话后关闭 A 的 tab,仍应落在 A。
2440
+ // 规则改为:**本会话里有别的卡拥有它才拒绝**;本会话无主时按
2441
+ // kind + 会话定位(重载后的幽灵面板由此能关掉)。
2442
+ //
2443
+ // 2.1.9:归属按会话记。进程级单槽在跨会话时会误判「别的卡拥有它」,
2444
+ // 让 A 会话的 tab 永远关不掉(专家复现的跨会话残留)。
2378
2445
  const ownerSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2379
2446
  ? card.sessionId
2380
2447
  : store.state.sessionId
2448
+ const knownSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2449
+ const holderId = this.owners.get(ownerKeyOf(ownerSession))
2450
+ if (holderId !== undefined && holderId !== card.id) return false
2451
+ // 会话未知(历史条目/幽灵 id)时,退回进程级单槽:没有任何会话认领
2452
+ // 它,而栏里那张属于别的卡,就不能替它关。
2453
+ if (holderId === undefined && !knownSession && this.cardId !== null && this.cardId !== card.id) return false
2381
2454
  let rows = []
2382
2455
  try {
2383
2456
  rows = typeof controller.openTabs?.getSnapshot === 'function'
@@ -2403,6 +2476,7 @@ window.__ModuleLoader__.load({
2403
2476
  return false
2404
2477
  }
2405
2478
  if (this.cardId === card.id) this.cardId = null
2479
+ if (this.owners.get(ownerKeyOf(ownerSession)) === card.id) this.owners.delete(ownerKeyOf(ownerSession))
2406
2480
  return true
2407
2481
  } catch (error) {
2408
2482
  console.warn(`[${PLUGIN_ID}] the right rail refused the close:`, error)
@@ -2425,6 +2499,8 @@ window.__ModuleLoader__.load({
2425
2499
  * that is not this owner must not close someone else's tab.
2426
2500
  */
2427
2501
  cardId: null,
2502
+ /** Per-session tab ownership; see `ownerKeyOf`. */
2503
+ owners: new Map(),
2428
2504
  /**
2429
2505
  * Why this adapter is (not) usable, in the settings page's own words.
2430
2506
  * `null` means "usable"; a string explains the refusal.
@@ -2445,7 +2521,21 @@ window.__ModuleLoader__.load({
2445
2521
  */
2446
2522
  available() {
2447
2523
  if (this.service === null || this.error !== null) return false
2448
- return Array.isArray(this.features) && this.features.includes('tabMeta')
2524
+ if (!(Array.isArray(this.features) && this.features.includes('tabMeta'))) return false
2525
+ // 0.28.x: every registered type has its own on/off switch in the
2526
+ // side-card plugin's settings, and `openTab` on a disabled type
2527
+ // warns and returns instead of throwing. Treat it as "not usable"
2528
+ // here so `auto` goes straight to the floating card instead of
2529
+ // spending 600 ms proving a surface that deliberately refused.
2530
+ try {
2531
+ if (typeof this.service.isTabEnabled === 'function'
2532
+ && this.service.isTabEnabled(CARD_KIND_BETTER) === false) {
2533
+ this.reason = 'tab-disabled'
2534
+ return false
2535
+ }
2536
+ } catch { /* the probe is optional */ }
2537
+ if (this.reason === 'tab-disabled') this.reason = null
2538
+ return true
2449
2539
  },
2450
2540
  open(card) {
2451
2541
  if (!this.available() || typeof this.service.openTab !== 'function') return false
@@ -2468,21 +2558,25 @@ window.__ModuleLoader__.load({
2468
2558
  // `dedupeKey` on our descriptor is what collapses repeat opens onto
2469
2559
  // the same tab; both are part of the stable consumer contract.
2470
2560
  this.service.openTab(
2471
- { type: CARD_KIND_BETTER, id: tabId, title: card.question.slice(0, 32), meta: { cardId: card.id } },
2561
+ { type: CARD_KIND_BETTER, id: tabId, title: t('answerTitle'), meta: { cardId: card.id } },
2472
2562
  typeof targetSession === 'string' && targetSession !== '' ? { sessionId: targetSession } : undefined,
2473
2563
  )
2474
2564
  this.cardId = card.id
2565
+ this.owners.set(ownerKeyOf(targetSession), card.id)
2475
2566
  return true
2476
2567
  },
2477
2568
  close(card) {
2478
2569
  if (!this.available() || typeof this.service.closeTab !== 'function') return false
2479
- // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab。
2480
- // 同 native.close:无主(cardId 为 null,页面重载后)也要放行,
2481
- // 否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
2482
- if (this.cardId !== null && this.cardId !== card.id) return false
2570
+ // 单一 page 地址(每个会话一个):只有本会话里最后 open 的那张卡
2571
+ // 拥有栏里的 tab。同 native.close:本会话无主(页面重载后)也要
2572
+ // 放行,否则在历史管理器里删掉一条 better 卡,栏里那张 tab 永远留空。
2483
2573
  const sessionId = typeof card.sessionId === 'string' && card.sessionId !== ''
2484
2574
  ? card.sessionId
2485
2575
  : (store.state.sessionId ?? undefined)
2576
+ const knownSession = typeof card.sessionId === 'string' && card.sessionId !== ''
2577
+ const holderId = this.owners.get(ownerKeyOf(sessionId))
2578
+ if (holderId !== undefined && holderId !== card.id) return false
2579
+ if (holderId === undefined && !knownSession && this.cardId !== null && this.cardId !== card.id) return false
2486
2580
  const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
2487
2581
  // 0.28.x 的原生 tab id 由宿主 mint(tab1…),不是我们传入的 seed id;
2488
2582
  // CardHost 挂载时把真实 id 记进 embeddedTabIds。
@@ -2490,17 +2584,20 @@ window.__ModuleLoader__.load({
2490
2584
  if (typeof mountedId === 'string' && mountedId !== '') {
2491
2585
  this.service.closeTab(mountedId, scope)
2492
2586
  this.cardId = null
2587
+ if (this.owners.get(ownerKeyOf(sessionId)) === card.id) this.owners.delete(ownerKeyOf(sessionId))
2493
2588
  return true
2494
2589
  }
2495
2590
  // 卡片还没挂载过(或已从 store 移除):按 kind + 会话在栏的清单里
2496
2591
  // 找到唯一那张 tab,经原生控制器关闭。
2497
2592
  if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
2498
2593
  this.cardId = null
2594
+ if (this.owners.get(ownerKeyOf(sessionId)) === card.id) this.owners.delete(ownerKeyOf(sessionId))
2499
2595
  return true
2500
2596
  }
2501
2597
  // 0.19 之前的 bottom workbench:seed id 就是 tab id。
2502
2598
  this.service.closeTab(`${CARD_KIND_BETTER}:${card.id}`, scope)
2503
2599
  this.cardId = null
2600
+ if (this.owners.get(ownerKeyOf(sessionId)) === card.id) this.owners.delete(ownerKeyOf(sessionId))
2504
2601
  return true
2505
2602
  },
2506
2603
  },
@@ -2549,6 +2646,29 @@ window.__ModuleLoader__.load({
2549
2646
  * @param {object} state - `store.state`.
2550
2647
  * @returns {object} the report body (allow-listed again on the Host).
2551
2648
  */
2649
+ /**
2650
+ * How many rail rows currently belong to this plugin.
2651
+ *
2652
+ * 0.28.x's `available` only says the descriptor registered — it cannot say
2653
+ * the tab ever sat in the rail, because a disabled type or a missing guide
2654
+ * entry makes `openTab` return silently. Counting the host's own inventory
2655
+ * is the only first-hand signal, and it is what a bug report needs.
2656
+ * @returns {number}
2657
+ */
2658
+ function clientRailTabCount() {
2659
+ const controller = surfaces.native === null ? null : surfaces.native.controller
2660
+ try {
2661
+ const rows = typeof controller?.openTabs?.getSnapshot === 'function'
2662
+ ? controller.openTabs.getSnapshot()
2663
+ : []
2664
+ if (!Array.isArray(rows)) return 0
2665
+ return rows.filter(row => typeof row?.kind === 'string'
2666
+ && (row.kind === CARD_KIND
2667
+ || row.kind === CARD_KIND_BETTER
2668
+ || row.kind === `dsh-better-sidebar:${CARD_KIND_BETTER}`)).length
2669
+ } catch { return 0 }
2670
+ }
2671
+
2552
2672
  function buildClientReport(state) {
2553
2673
  return {
2554
2674
  // Read from the module's own version constant so the report can never
@@ -2567,6 +2687,10 @@ window.__ModuleLoader__.load({
2567
2687
  ...(surfaces.better.version === null ? {} : { version: surfaces.better.version }),
2568
2688
  ...(surfaces.better.reason === null ? {} : { reason: String(surfaces.better.reason) }),
2569
2689
  features: surfaces.better.features,
2690
+ // First-hand proof that a page really exists in the rail, plus the
2691
+ // last host tab id a body actually mounted with.
2692
+ railTabs: clientRailTabCount(),
2693
+ mountedTabId: lastMountedHostTabId,
2570
2694
  },
2571
2695
  // §4.5 family singleton verdict (host-side diagnostics only).
2572
2696
  family: {
@@ -2586,6 +2710,9 @@ window.__ModuleLoader__.load({
2586
2710
  */
2587
2711
  let renderedCardId = null
2588
2712
 
2713
+ /** The host tab id the most recent embedded body mounted with. */
2714
+ let lastMountedHostTabId = null
2715
+
2589
2716
  /**
2590
2717
  * Host tab ids for cards mounted by an external embedder, keyed by card id.
2591
2718
  *
@@ -2595,8 +2722,29 @@ window.__ModuleLoader__.load({
2595
2722
  * open lands in DSH's native rail, so `closeTab(seed.id)` is a strict no-op.
2596
2723
  * The body records the real id here and `surfaces.better.close` uses it.
2597
2724
  */
2725
+ /**
2726
+ * The key an adapter records tab ownership under.
2727
+ *
2728
+ * Ownership is per SESSION, never process-global: each session has its own
2729
+ * page address for a kind, so one slot could only ever describe the last
2730
+ * opened card and would deny every other session its own close.
2731
+ * @param {string|null|undefined} sessionId
2732
+ * @returns {string} '' for "unknown session"
2733
+ */
2734
+ const ownerKeyOf = sessionId => (typeof sessionId === 'string' && sessionId !== '' ? sessionId : '')
2735
+
2598
2736
  const embeddedTabIds = new Map()
2599
2737
 
2738
+ /**
2739
+ * Card ids this page has actually rendered a body for.
2740
+ *
2741
+ * Mount is the only thing that proves a tab belongs to THIS page: the host
2742
+ * persists the rail layout, so another window can restore the same tab
2743
+ * record while its card lives there. A restored-but-unmounted tab therefore
2744
+ * gets a close button, never an automatic close.
2745
+ */
2746
+ const everMountedCards = new Set()
2747
+
2600
2748
  /**
2601
2749
  * Pick the surface for a new card.
2602
2750
  * @param {string} wanted - `auto` | adapter kind.
@@ -4911,8 +5059,10 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4911
5059
  } catch { return null }
4912
5060
  }
4913
5061
  const laneRect = measure(lane)
4914
- const rail = findRail(document)
4915
- const railBox = rail === null ? null : rail.box
5062
+ const ownCards = new Set(store.state.cards.filter(item => item.surface === 'flow').map(item => item.id))
5063
+ const rail = findRail(document, ownCards)
5064
+ const railBox = rail === null || rail.box === null ? null : rail.box
5065
+ const railOccupied = rail !== null && rail.occupied === true && railBox === null
4916
5066
  const laneRight = laneRect === null ? rootWidth : laneRect.right
4917
5067
  const railLeft = railBox === null ? null : railBox.left
4918
5068
  // 2.1.3: the composer occupies the bottom of the conversation column,
@@ -4965,7 +5115,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
4965
5115
  ? 'dock'
4966
5116
  : (mode !== 'full'
4967
5117
  ? 'mode'
4968
- : (dockWanted ? (railBox === null ? 'no-rail' : 'rail-too-small') : 'off'))
5118
+ : (dockWanted
5119
+ ? (railBox !== null ? 'rail-too-small' : (railOccupied ? 'occupied' : 'no-rail'))
5120
+ : 'off'))
4969
5121
  // The rail can appear AFTER this effect ran (opening it mounts new
4970
5122
  // nodes), and it can be re-created later: keep the observer pointed at
4971
5123
  // whichever node is currently accepted.
@@ -5722,6 +5874,14 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
5722
5874
  : notifyPerm === 'denied'
5723
5875
  ? t('notifyDenied')
5724
5876
  : t('setNotifyOnDoneHint')),
5877
+ h('label', { className: 'dsa-check' },
5878
+ h('input', {
5879
+ type: 'checkbox',
5880
+ checked: draft.autoExpandOnDone !== false,
5881
+ onChange: event => setDraft({ ...draft, autoExpandOnDone: event.target.checked }),
5882
+ }),
5883
+ t('setAutoExpandOnDone')),
5884
+ h('span', { className: 'dsa-muted' }, t('setAutoExpandOnDoneHint')),
5725
5885
  h('label', { className: 'dsa-check' },
5726
5886
  h('input', {
5727
5887
  type: 'checkbox',
@@ -6240,7 +6400,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6240
6400
  : []
6241
6401
  } catch { return false }
6242
6402
  if (!Array.isArray(rows)) return false
6243
- const candidates = rows.filter(entry => entry?.kind === kind
6403
+ const candidates = rows.filter(entry => (entry?.kind === kind
6404
+ || entry?.kind === `dsh-better-sidebar:${kind}`)
6244
6405
  && typeof entry?.tabId === 'string' && entry.tabId !== '')
6245
6406
  // 会话已知时按会话匹配;会话未知(旧历史没有 sessionId)时只有
6246
6407
  // 全进程恰好一张该 kind 的 tab 才敢关,否则可能误关别的会话。
@@ -6276,15 +6437,19 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6276
6437
  const better = surfaces.better
6277
6438
  if (better === null || better === undefined) return
6278
6439
  if (better.service === null || typeof better.service?.closeTab !== 'function') return
6279
- // 单一 page 地址:只有最后 open 的那张卡拥有栏里的 tab;释放一张
6280
- // 不是当前所有者的历史条目,会把现在真正在栏里的卡片连带关掉。
6281
- // 无主(null)时也放行:理由见 better.close。
6282
- if (better.cardId !== null && better.cardId !== id) return
6440
+ // 单一 page 地址(每个会话一个):只有本会话里最后 open 的那张卡
6441
+ // 拥有栏里的 tab;释放一张不是本会话所有者的历史条目,会把现在
6442
+ // 真正在栏里的卡片连带关掉。本会话无主时也放行(理由见 better.close)。
6443
+ const knownSession = typeof sessionId === 'string' && sessionId !== ''
6444
+ const holderId = better.owners.get(ownerKeyOf(sessionId))
6445
+ if (holderId !== undefined && holderId !== id) return
6446
+ if (holderId === undefined && !knownSession && better.cardId !== null && better.cardId !== id) return
6283
6447
  const scope = typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : undefined
6284
6448
  const mountedId = embeddedTabIds.get(id)
6285
6449
  if (typeof mountedId === 'string' && mountedId !== '') {
6286
6450
  try { better.service.closeTab(mountedId, scope) } catch { /* the host may already be gone */ }
6287
6451
  better.cardId = null
6452
+ if (better.owners.get(ownerKeyOf(sessionId)) === id) better.owners.delete(ownerKeyOf(sessionId))
6288
6453
  return
6289
6454
  }
6290
6455
  if (closeRailRow(CARD_KIND_BETTER, sessionId)) {
@@ -6310,8 +6475,9 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6310
6475
  const native = surfaces.native
6311
6476
  if (native === null || native === undefined) return
6312
6477
  if (native.controller === null || native.controller === undefined) return
6313
- // 无主(null)时也放行:理由见 native.close。
6314
- if (native.cardId !== null && native.cardId !== id) return
6478
+ // 本会话无主时也放行:理由见 native.close。
6479
+ const holderId = native.owners.get(ownerKeyOf(sessionId))
6480
+ if (holderId !== undefined && holderId !== id) return
6315
6481
  try {
6316
6482
  native.close({ id, sessionId })
6317
6483
  } catch { /* the rail owns its tab's lifetime */ }
@@ -6349,6 +6515,41 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6349
6515
  }
6350
6516
 
6351
6517
  const actions = {
6518
+ /**
6519
+ * Close the rail page that is rendering `props`, by its host tab id
6520
+ * when the host gave one, else by kind + session through the rail
6521
+ * inventory. Used by the orphan page's own close button.
6522
+ * @param {object} props - the body props the host handed `CardHost`.
6523
+ * @returns {boolean} whether a close was issued.
6524
+ */
6525
+ closeRailTab(props) {
6526
+ const tabId = typeof props?.tab?.id === 'string' && props.tab.id !== '' ? props.tab.id : null
6527
+ const scopeId = typeof props?.scope?.sessionId === 'string' && props.scope.sessionId !== ''
6528
+ ? props.scope.sessionId
6529
+ : (props?.tab?.sessionId ?? null)
6530
+ const controller = surfaces.native === null ? null : surfaces.native.controller
6531
+ try {
6532
+ if (tabId !== null && controller !== null && controller !== undefined) {
6533
+ if (typeof controller.closeIn === 'function' && typeof scopeId === 'string') {
6534
+ controller.closeIn(scopeId, tabId)
6535
+ return true
6536
+ }
6537
+ if (typeof controller.close === 'function') {
6538
+ controller.close(tabId)
6539
+ return true
6540
+ }
6541
+ }
6542
+ } catch { /* the rail owns its tab's lifetime */ }
6543
+ if (typeof scopeId === 'string' && closeRailRow(CARD_KIND, scopeId)) return true
6544
+ const better = surfaces.better
6545
+ try {
6546
+ if (tabId !== null && better?.service && typeof better.service.closeTab === 'function') {
6547
+ better.service.closeTab(tabId, typeof scopeId === 'string' ? { sessionId: scopeId } : undefined)
6548
+ return true
6549
+ }
6550
+ } catch { /* host may be gone */ }
6551
+ return false
6552
+ },
6352
6553
  openPopover(candidate) {
6353
6554
  // P2-2: the composer must be remembered BEFORE the popover's
6354
6555
  // textarea takes focus, otherwise "insert into composer" has no
@@ -6503,7 +6704,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6503
6704
  const config = store.state.config
6504
6705
  const cut = truncateSelection(candidate.text, config.maxChars)
6505
6706
  const card = {
6506
- id: `card-${Date.now()}-${nextCardId++}`,
6707
+ id: `card-${Date.now().toString(36)}-${nextCardId++}-${Math.random().toString(36).slice(2, 8)}`,
6507
6708
  question: payload.question,
6508
6709
  selection: candidate.text,
6509
6710
  selected: cut.text,
@@ -6589,6 +6790,15 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6589
6790
  // what the "moved to the flow stack" body below covers.
6590
6791
  const proof = setTimeout(() => {
6591
6792
  pendingProofs.delete(card.id)
6793
+ // A card whose session is not the one on screen is queued by
6794
+ // the host, not refused: the rail only mounts on-screen tabs.
6795
+ // Judging it "unproven" here moved a perfectly live card to
6796
+ // the flow stack and closed the tab it had just opened.
6797
+ const ownSession = typeof card.sessionId === 'string' && card.sessionId !== ''
6798
+ ? card.sessionId
6799
+ : null
6800
+ if (ownSession !== null && store.state.sessionId !== null
6801
+ && ownSession !== store.state.sessionId) return
6592
6802
  const live = store.state.cards.find(item => item.id === card.id)
6593
6803
  if (live === undefined || live.surface === 'flow') return
6594
6804
  if (renderedCardId === card.id) return
@@ -6874,7 +7084,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
6874
7084
  // and managed explicitly in the settings-page history manager.
6875
7085
  },
6876
7086
  async saveConfig(patch) {
6877
- const value = await postJson('config', patch)
7087
+ // 宿主要求 inherit 显式确认(防误触/静默提权);用户点"保存"本身
7088
+ // 就是那个确认动作,恶意同源脚本仍可伪造 —— 它不是安全边界,
7089
+ // 只让这种变更必须是有意的。
7090
+ const body = patch?.sideTools === 'inherit' ? { ...patch, confirmInheritTools: true } : patch
7091
+ const value = await postJson('config', body)
6878
7092
  store.set({
6879
7093
  config: { ...CLIENT_DEFAULTS, ...(value.config ?? {}) },
6880
7094
  problems: value.problems ?? [],
@@ -7046,6 +7260,8 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7046
7260
  })
7047
7261
  controllers.delete(cardId)
7048
7262
  syncPersisted()
7263
+ // 2.1.9:答完就让答案出现在卡片里(胶囊态不再只显示一行状态)。
7264
+ revealFinishedCard(cardId)
7049
7265
  // #5: background finish notification (guarded inside).
7050
7266
  const finished = store.state.cards.find(item => item.id === cardId)
7051
7267
  if (finished !== undefined) notifyCardFinish(finished)
@@ -7055,6 +7271,7 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7055
7271
  flushStreamBuffers()
7056
7272
  patchCard(cardId, { status: 'error', error, elapsedMs: Date.now() - startedAt })
7057
7273
  controllers.delete(cardId)
7274
+ revealFinishedCard(cardId)
7058
7275
  const failed = store.state.cards.find(item => item.id === cardId)
7059
7276
  if (failed !== undefined) notifyCardFinish(failed)
7060
7277
  },
@@ -7536,6 +7753,12 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7536
7753
  title: () => t('answerTitle'),
7537
7754
  description: () => t('settingsDesc'),
7538
7755
  order: 120,
7756
+ // 0.28.x: a registered type is added to the + menu automatically,
7757
+ // and a guide entry opened with no card behind it renders this
7758
+ // plugin's "card closed" page — two dead entries per session
7759
+ // (native guide + this mirror). `hidden` keeps the type usable
7760
+ // through `openTab` while dropping it from the menu.
7761
+ hidden: true,
7539
7762
  dedupeKey: tab => (tab?.meta?.cardId !== undefined ? String(tab.meta.cardId) : undefined),
7540
7763
  component: props => h(CardHost, {
7541
7764
  props: { tab: props?.tab, sessionId: props?.scope?.sessionId },
@@ -7587,7 +7810,16 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7587
7810
  function CardHost({ props, surface }) {
7588
7811
  const state = useStoreState()
7589
7812
  const cardId = hostCardIdOf(props)
7590
- const card = state.cards.find(item => item.id === cardId)
7813
+ // A tab's `meta.cardId` is just a string the host persisted; it must
7814
+ // not route this body to a card that lives in ANOTHER session.
7815
+ const scopeId = typeof props?.scope?.sessionId === 'string' && props.scope.sessionId !== ''
7816
+ ? props.scope.sessionId
7817
+ : null
7818
+ const card = state.cards.find(item => item.id === cardId
7819
+ && (scopeId === null
7820
+ || typeof item.sessionId !== 'string'
7821
+ || item.sessionId === ''
7822
+ || item.sessionId === scopeId))
7591
7823
  // The embedder's REAL tab id (better-sidebar 0.28.x mints it and
7592
7824
  // ignores the seed id we passed), recorded for close/delete.
7593
7825
  const hostTabId = typeof props?.tab?.id === 'string' && props.tab.id !== '' ? props.tab.id : null
@@ -7596,7 +7828,11 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7596
7828
  React.useEffect(() => {
7597
7829
  if (typeof cardId !== 'string' || cardId === '') return undefined
7598
7830
  renderedCardId = cardId
7599
- if (hostTabId !== null) embeddedTabIds.set(cardId, hostTabId)
7831
+ everMountedCards.add(cardId)
7832
+ if (hostTabId !== null) {
7833
+ embeddedTabIds.set(cardId, hostTabId)
7834
+ lastMountedHostTabId = hostTabId
7835
+ }
7600
7836
  // Mounting settles the render proof, even when this host mounted
7601
7837
  // late (a lazy tab panel slower than the 600ms timer) — the proof
7602
7838
  // must never fire after the card is demonstrably on screen.
@@ -7605,6 +7841,13 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7605
7841
  clearTimeout(proof)
7606
7842
  pendingProofs.delete(cardId)
7607
7843
  }
7844
+ // A body whose card is gone, for a tab THIS page mounted before:
7845
+ // release it now. A tab restored by another window was never
7846
+ // mounted here (`everMountedCards`), so it keeps its close button
7847
+ // instead of being stolen from that window.
7848
+ if (card === undefined && everMountedCards.has(cardId)) {
7849
+ try { actions.closeRailTab(props) } catch { /* host may be gone */ }
7850
+ }
7608
7851
  return () => {
7609
7852
  if (renderedCardId === cardId) renderedCardId = null
7610
7853
  if (hostTabId !== null && embeddedTabIds.get(cardId) === hostTabId) {
@@ -7612,10 +7855,26 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7612
7855
  }
7613
7856
  }
7614
7857
  }, [cardId, hostTabId])
7858
+ // No live card claims this body: a tab the host restored from its
7859
+ // persisted layout, or a card deleted before its body mounted. Give
7860
+ // the page an exit — the old build rendered one grey line and left
7861
+ // the user with no way to close it (A1 was fixed for the native
7862
+ // sweep, never for this face).
7615
7863
  if (card === undefined) {
7616
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
7864
+ return h('div', {
7865
+ className: 'dsa-settings',
7866
+ 'data-dsa-root': '',
7867
+ 'data-dsa-rail-host': '',
7868
+ 'data-dsa-card-id': typeof cardId === 'string' ? cardId : '',
7869
+ },
7617
7870
  h(Styles, null),
7618
- h('div', { className: 'dsa-muted' }, t('cardClosed')))
7871
+ h('div', { className: 'dsa-muted' }, t('cardClosed')),
7872
+ h('button', {
7873
+ type: 'button',
7874
+ className: 'dsa-btn',
7875
+ 'data-dsa-action': 'close-orphan-tab',
7876
+ onClick: () => { try { actions.closeRailTab(props) } catch { /* host may be gone */ } },
7877
+ }, t('closeThisTab')))
7619
7878
  }
7620
7879
  if (card.surface === 'flow') {
7621
7880
  // The proof already moved this card to the flow stack and this
@@ -7624,11 +7883,22 @@ body[data-ds-dark-theme] .dsa-seg button[data-on="true"]:hover{
7624
7883
  // note for a rail that keeps it.
7625
7884
  try { surfaces.native.close(card) } catch { /* the rail owns its lifecycle */ }
7626
7885
  try { surfaces.better.close(card) } catch { /* the host may already be gone */ }
7627
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '' },
7886
+ return h('div', {
7887
+ className: 'dsa-settings',
7888
+ 'data-dsa-root': '',
7889
+ 'data-dsa-rail-host': '',
7890
+ 'data-dsa-card-id': typeof cardId === 'string' ? cardId : '',
7891
+ },
7628
7892
  h(Styles, null),
7629
7893
  h('div', { className: 'dsa-muted' }, t('cardMovedToFlow')))
7630
7894
  }
7631
- return h('div', { className: 'dsa-settings', 'data-dsa-root': '', 'data-dsa-rail-host': '', style: { padding: '10px' } },
7895
+ return h('div', {
7896
+ className: 'dsa-settings',
7897
+ 'data-dsa-root': '',
7898
+ 'data-dsa-rail-host': '',
7899
+ 'data-dsa-card-id': typeof cardId === 'string' ? cardId : '',
7900
+ style: { padding: '10px' },
7901
+ },
7632
7902
  h(Styles, null),
7633
7903
  h(MemoAnswerCard, {
7634
7904
  card,