dsh-sidecard-ask 2.1.8 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.9
4
+
5
+ **主题:承载面适配对齐 dsh-better-sidebar 0.28.2,修掉两个已复现的用户可见缺陷。**
6
+
7
+ 触发是一次专家评审(架构 / 安全 / 验收三线独立复核):0.28.x 把 better 的 tab 从它自己的
8
+ workbench 搬进了 **DSH 原生右栏**,而本插件仍按「better 是一个独立面板」建模——落点矩阵、
9
+ `dockRail` 锚点、tab 归属、菜单入口四处同时失真。四个发布闸门当时全绿(1928),
10
+ 但绿只证明旧契约面没坏。
11
+
12
+ ### 修复(适配)
13
+
14
+ - **A1 跨会话关闭/删除不关 tab**:归属从进程级单槽 `cardId` 改为按 `(kind, session)` 记录
15
+ (`owners` Map)。单槽在跨会话时会误判「别的卡拥有它」,让 A 会话的 tab 永远关不掉。
16
+ 会话未知(历史条目、幽灵 id)时仍退回单槽,保住 2.1.5 R2 的归属语义。
17
+ - **A2 重载后的幽灵面板没有出口**:无主 body 现在渲染「关闭此页」;**只有本页真的挂载过**
18
+ 该 tab 才自动关闭。宿主会持久化栏布局,别的窗口恢复的同一张 tab 不会被本页抢走。
19
+ - **A3 dockRail 铺到别人的页面上**:`CardHost` 根节点声明 `data-dsa-card-id`,
20
+ `findRail` 拒绝不属于本层卡片的 body;全部候选被占时 `data-dsa-dock="occupied"`,浮层保持浮动。
21
+ - **A4 `+` 菜单里的空页胶囊**:better 描述符加 `hidden: true`——保留 `openTab` 可用,只是不再进菜单。
22
+ 原生 guide 那一条缺宿主语义材料,仍会显示,已记入 README §5.6「仍未验证」。
23
+ - **A5 禁用态被误判成故障**:`available()` 读 0.28.x 的 `isTabEnabled`,禁用即 `reason='tab-disabled'`,
24
+ `auto` 直接落浮层,不再先挂 600 ms 再报「承载面未证实」。
25
+ - **A6 非当前会话的卡被证明迁走**:宿主对非当前屏的 `openTab` 是排队而不是拒绝,渲染证明
26
+ 现在只对当前屏会话武装。
27
+ - **A7 自检看不清真相**:上报增 `railTabs`(宿主清单里本插件行数)与 `mountedTabId`。
28
+
29
+ ### 修复(安全 / 归属)
30
+
31
+ - `/config` 改 SMTP 主机/端口/TLS/账号任一项 → 已存授权码作废(凭据不再跟随新目标外送)。
32
+ - `/config` 的 `sideTools: "inherit"` 需要 `confirmInheritTools: true`(防误触;不是安全边界)。
33
+ - 只读护栏 **fail-closed**:注册表答了却认不出只读工具时拒跑(新错误码 `no-readonly-guard`,
34
+ 客户端中英文案 + 错误码分类已补),不再静默继承父会话全部工具。
35
+ - `cardId` 不可预测;better tab 标题不再携带问题原文;`tab.meta.cardId` 校验归属会话。
36
+ - `closeRailRow` 容忍带 `dsh-better-sidebar:` 前缀的 kind;`/ask` 的 start 帧回带
37
+ `parentSessionId` / `requestedSessionId`,跨会话作答可被看见。
38
+
39
+ ### 新增(行为)
40
+
41
+ - **答完自动展开卡片**:浮层默认是胶囊(一行状态),答案在那里只看得到「已完成」三个字。
42
+ 现在回答结束(`done` / `stopped` / `error`)时自动把浮层切到卡片形态并展开该卡,
43
+ 答案内容直接出现在侧边卡片里。设置页新增「答完自动展开卡片」开关(默认开);
44
+ 置 `autoExpandOnDone: false` 可恢复旧行为(始终停在胶囊,点一下才展开)。
45
+ ### 闸门
46
+
47
+ - `node test/verify.mjs` → 58 / 0
48
+ - `node test/contract-test.mjs` → 1499 / 0
49
+ - `node test/float-placement.mjs` → 100 / 0
50
+ - `node test/smoke-test.mjs` → 291 / 0
51
+ - 合计 **1948**(2.1.8 基线 1928,新增 20 条:hidden、禁用门、SMTP 换目标清密码、
52
+ inherit 确认、`no-readonly-guard` 拒跑等)。
53
+
54
+ ### 未执行
55
+
56
+ 真实浏览器/Electron 点按与视觉、桌面 profile、macOS/Linux 均未验证;详见 README §5.6。
57
+
3
58
  ## 2.1.8
4
59
 
5
60
  **主题:交付《非 Node 宿主契约》文档,无代码改动。**
package/README.md CHANGED
@@ -167,6 +167,7 @@ plugin_manager(action="install_bundle", target="D:\\Users\\34332\\AI\\dsh-sideca
167
167
  | `sideSurface` | `auto` \| `native-rightbar` \| `better-sidebar` \| `flow` | `auto` | **窗口模式(侧边承载面)**:自动挑选(先 dsh-better-sidebar,再内置浮层卡片)/ 强制 DSH 原生右侧栏(不参与自动)/ 强制 dsh-better-sidebar / 强制内置浮层卡片 | 立即 |
168
168
  | `maxChars` | 200–60000 | `4000` | **最大字符数**:超出部分头尾保留、中间截断并标注 | 立即 |
169
169
  | `floatMode` | `capsule` \| `full` | `capsule` | **内置浮层的默认形态**:胶囊(一行,最小遮挡)/ 完整卡片栈。运行时可即时收起为胶囊,但不写回本项——刷新后仍回到这里设的默认。旧配置里存过 `off` 的会在启动时校验失败、回退为 `capsule`,并在 `problems` 里给出提示(`index.js` 的 `sanitizeLayer`),无需手工迁移 | 立即 |
170
+ | `autoExpandOnDone` | 布尔 | `true` | **答完自动展开卡片**:回答结束(`done` / `stopped` / `error`)时把浮层从胶囊切到卡片形态并展开该卡,答案正文直接显示在侧边卡片里。置 `false` 则始终停在胶囊,点一下才展开(2.1.9 起) | 立即 |
170
171
  | `floatMaxWidth` | 240–560 | `340` | **内置浮层宽度上限**(px):实际宽度 = `min(本项, 对话区右侧可用带宽)`,可用带宽不足 240px 时才会压入对话区 | 立即 |
171
172
  | `dockRail` | 布尔 | `true` | **右侧栏打开时占满它**:答案卡铺满右栏标签条下方的内容区,读起来像栏内的一页,而不是 340px 的小浮层。收起为胶囊、点卡片底部「关闭」、右栏宽度 < 280px 或高度 < 240px 时自动回到常规浮层;实现靠测量右栏 DOM 矩形,**没有**走 DSH 的右栏 API(见 §4.2 与 §6.1) | 立即 |
172
173
  | `shortcut` | 形如 `Alt+Q`、`Ctrl+Shift+K` | `Alt+Q` | **快捷键**:唤起提问框(`trigger` 允许时) | 立即 |
@@ -549,6 +550,72 @@ dsh-better-sidebar 为此专门带了一条防御(`html[data-platform] body>[d
549
550
 
550
551
  ---
551
552
 
553
+ ### 5.6 2026-11-03 复核(v2.1.9 / dsh-better-sidebar 0.28.2)
554
+
555
+ 本轮把适配核对的**对象**从上表的 0.22.1(网页)/ 0.24.1(桌面)更新到本机实际运行的
556
+ **0.28.2**(`SIDEBAR_SERVICE_VERSION = "0.28.2"`,`SIDEBAR_FEATURES` 14 项),并修掉四处
557
+ 按旧模型写的假设。证据分三类:**源码级**(读 0.28.2 的 `lib/types/client/service.d.ts`
558
+ 与 `src/client/*.ts`)、**替身复现**(`test/` 下的 0.28.2 形状替身)、**真机**(本轮**未做**,见末尾)。
559
+
560
+ #### 0.28.2 的关键事实(相对 0.22.1 的漂移)
561
+
562
+ | # | 事实 | 对适配的影响 |
563
+ |---|---|---|
564
+ | 1 | 组件类 seed 的 `openTab` 实际落到 **DSH 原生右栏**(`service.ts:1120-1145`),不再进 better 自己的 workbench | better 与 native **共用同一个物理容器**;`dockRail` 的锚点会指到别人的页面 |
565
+ | 2 | tab id 由宿主 mint(`tab1…`),seed id 被忽略,`closeTab(seed.id)` 是 no-op | 关闭必须用 `CardHost` 记录的真实 id(2.1.6 S1 已做) |
566
+ | 3 | 同 kind 每会话只有**一个 page 地址**(无 `multiple`) | 同会话再开一张 = 导航,旧卡必须迁回浮层(2.1.6 S2 已做) |
567
+ | 4 | `isTabEnabled(type)` 禁用后 `openTab` **warn + return**,不抛错 | 禁用态会被误判成「承载面未证实」并弹假故障(2.1.9 A5 修) |
568
+ | 5 | 每个注册类型自动进 `+` 菜单,并生成 guide 胶囊 | 点开是一张无 `cardId` 的空页(2.1.9 A4 用 `hidden: true` 修) |
569
+ | 6 | `OpenTabSeed.target` 支持 `right/bottom/side`,另有 `preferNewPane` 与 split pane | 本插件不传 target(默认 right);分栏时 panel 级回退会横跨两栏 |
570
+ | 7 | 非当前会话的 `openTab` 是**排队**,不是拒绝;只有 on-screen 才 mount | 600 ms 渲染证明会把排队中的卡误判为失败(2.1.9 A6 修) |
571
+ | 8 | 镜像注册的 `id` 是 `dsh-better-sidebar:<id>`、`kind` 是描述符 id | 两个适配器同 kind 相撞的旧缺陷**已清除**;但 `closeRailRow` 需容忍带前缀的 kind |
572
+
573
+ > `.d.ts` 里 `openTab` 的注释写「每次 open 落在 bottom workbench」,而 `OpenTabSeed.target`
574
+ > 的注释写默认 `'right'` 是原生右栏——两者矛盾,**只有 `src/client/service.ts` 给出真值**。
575
+ > 下一位维护者不要只看 `.d.ts`。
576
+
577
+ #### 本版修掉的适配缺陷
578
+
579
+ | 编号 | 缺陷 | 修法 |
580
+ |---|---|---|
581
+ | A1 | 跨会话关闭/删除不关 better tab(进程级单槽 `cardId` 误判归属) | 归属改为按 `(kind, session)` 记(`owners` Map);「本会话有别的卡持有才拒绝」,会话未知时退回单槽 |
582
+ | A2 | 刷新后宿主还原的 better tab 无人认领、没有出口 | 空页渲染「关闭此页」;**只有本页真的挂载过**该 tab 才自动关(别的窗口的 tab 不抢) |
583
+ | A3 | `dockRail` 把浮层卡铺到另一张卡自己的页面上 | `CardHost` 根节点声明 `data-dsa-card-id`;`findRail` 拒绝不属于本层的 body,全被拒时 `data-dsa-dock="occupied"` |
584
+ | A4 | `+` 菜单两条「点开即空页」胶囊 | descriptor 加 `hidden: true`(保留可 open,只是不进菜单);原生 guide 那条缺宿主语义材料,仍会显示 |
585
+ | A5 | 禁用的 tab 类型仍被 `auto` 选中并报假故障 | `available()` 读 `isTabEnabled`,禁用即 `reason='tab-disabled'`,直接落浮层 |
586
+ | A6 | 非当前会话的卡被 600 ms 证明迁走 | 证明只对「当前屏会话」武装 |
587
+ | A7 | 自检看不出「到底有没有落栏」 | 上报增 `railTabs`(宿主清单里本插件行数)与 `mountedTabId`(最近一次真实挂载的宿主 tab id) |
588
+
589
+ #### 安全相关(同一轮,详见 §十二)
590
+
591
+ - `/config` 改 `feedbackSmtpHost/Port/Secure/User` 任一项 → **已存授权码作废**。否则「空密码 = 保留旧值」
592
+ 等于把凭据交给新主机,而 `AUTH PLAIN` 是明文。
593
+ - `/config` 写 `sideTools: "inherit"` 需要 `confirmInheritTools: true`。**这不是安全边界**
594
+ (同源脚本可以伪造这个字段),只让这次提权必须是有意的;同源信任边界由宿主权限决定。
595
+ - 只读护栏改为 **fail-closed**:工具注册表答了却认不出任何只读工具时,除非另有 `agentPreset`
596
+ 收窄,否则**拒跑**(新错误码 `no-readonly-guard`,卡片文案中英齐全)。
597
+ - `cardId` 改为不可预测(`card-<base36 时间>-<序号>-<随机 6 位>`);better tab 标题不再携带问题原文
598
+ (0.28.x 会把 title/meta 随布局持久化);`tab.meta.cardId` 增加「属于本会话」校验。
599
+
600
+ #### 0.28.2 新 API 的准入条件(采用前必须满足)
601
+
602
+ - `updateTab(tabId, { title, path, meta }, sessionId)`:① 只允许 `embeddedTabIds` 里的自持 tabId;
603
+ ② `sessionId === card.sessionId`;③ **永不带 `path`**(那等于给出「在后台会话打开任意文件路径」的原语)。
604
+ - `stateSubscription` / `getSnapshot`:只用来读,**禁止**用快照的 `sessionId` 重定向在飞请求。
605
+ - `pluginSettings`:只放 UI 偏好,绝不放卡片正文或选中文本。
606
+
607
+ #### 仍未验证(如实记录)
608
+
609
+ - 真实浏览器 / Electron 页面里的**点按与视觉**:本机无 GUI 自动化。`dockRail` 的 `occupied`、
610
+ `hidden` 之后的 `+` 菜单、跨窗口清扫都**没有被真机执行**。
611
+ - 桌面端:desktop profile 同样是 `dsh-better-sidebar ^0.28.2`,§5.3 的 0.24.1 核对**已过期**;
612
+ 本版按同一套源码事实处理,但没跑桌面。
613
+ - macOS / Linux:零用例,仓库没有 CI。
614
+ - `openTabs` 是否跨窗口共享(`app.asar` 内的 `sidebar-right` 读不到):因此「自动清扫」只采用
615
+ 「本页挂载过」这一按页成立的判据,没有做 boot 期盲扫。
616
+
617
+ ---
618
+
552
619
  ## 六、未知 DSH API:占位接口与替换方式
553
620
 
554
621
  开发时以**运行时能力探测**为主,任何"本机没验证到"的接口都在代码里显式留了占位与替换点。它们集中在三处:
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.8'
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,
package/index.js CHANGED
@@ -45,7 +45,7 @@ export const name = 'dsh-sidecard-ask'
45
45
  export const inject = ['webServer']
46
46
 
47
47
  /** Version of this plugin (kept in step with package.json by test/verify.mjs). */
48
- export const PLUGIN_VERSION = '2.1.8'
48
+ export const PLUGIN_VERSION = '2.1.9'
49
49
 
50
50
  /** Route prefix of the plugin's own API. */
51
51
  export const ROUTE_PREFIX = '/sidecard-ask/api'
@@ -127,6 +127,9 @@ export const DEFAULT_CONFIG = {
127
127
  * floating layer is used either way.
128
128
  */
129
129
  dockRail: true,
130
+ // 2.1.9: expand the floating layer when an answer settles, so the answer is
131
+ // visible in the side card instead of hiding behind the capsule pill.
132
+ autoExpandOnDone: true,
130
133
  /**
131
134
  * One-click quick actions rendered beside the selection trigger.
132
135
  *
@@ -534,7 +537,7 @@ export function sanitizeLayer(raw, problems = []) {
534
537
  if (value !== null && (value === '' || looksLikeEmail(value))) out.feedbackSmtpFrom = value
535
538
  else problems.push('feedbackSmtpFrom: 必须是合法邮箱或空字符串,已忽略')
536
539
  }
537
- for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions', 'dockRail']) {
540
+ for (const boolKey of ['showInUnclassified', 'notifyOnDone', 'contextMenu', 'followUpSuggestions', 'dockRail', 'autoExpandOnDone']) {
538
541
  if (raw[boolKey] === undefined) continue
539
542
  if (typeof raw[boolKey] === 'boolean') out[boolKey] = raw[boolKey]
540
543
  else problems.push(`${boolKey}: 必须是布尔值,已忽略`)
@@ -1346,7 +1349,12 @@ export function readOnlyToolFilter(ctx) {
1346
1349
  if (!Array.isArray(known)) return undefined
1347
1350
  const names = new Set(known.map(schema => schema?.name).filter(name => typeof name === 'string'))
1348
1351
  const allow = READONLY_TOOL_NAMES.filter(name => names.has(name))
1349
- return allow.length === 0 ? undefined : { allow }
1352
+ // A registry that answered but names NONE of our read-only tools cannot
1353
+ // honour the guard. Returning `undefined` here made the child inherit every
1354
+ // parent tool (fail-open) while the card only said "unsupported" — the guard
1355
+ // disappeared exactly when a host upgrade renamed things.
1356
+ if (allow.length === 0) return { blocked: 'no-readonly-guard' }
1357
+ return { allow }
1350
1358
  }
1351
1359
 
1352
1360
  // ────────────────────────────────────────────────────────────────────────────
@@ -1762,6 +1770,7 @@ function createSideEngine(ctx, configOf) {
1762
1770
  ]
1763
1771
  const seen = new Set()
1764
1772
  let fallback
1773
+ let live
1765
1774
  for (const agent of listed) {
1766
1775
  const id = agentSessionId(agent)
1767
1776
  if (id === undefined || seen.has(id)) continue
@@ -1769,11 +1778,16 @@ function createSideEngine(ctx, configOf) {
1769
1778
  const isArchived = archived.has(id)
1770
1779
  candidates.push({ id, archived: isArchived })
1771
1780
  if (fallback === undefined) fallback = { agent, id, archived: isArchived }
1772
- if (!isArchived) return { agent, id, archived: false, candidates }
1773
- }
1774
- return fallback === undefined
1781
+ if (live === undefined && !isArchived) live = { agent, id, archived: false }
1782
+ }
1783
+ // A caller that NAMED a session which is not live is answered by another
1784
+ // one (the host's historical fallback — older clients depend on it), but the
1785
+ // start frame now says WHICH session answered so the card can show it
1786
+ // instead of silently publishing into an unfamiliar conversation.
1787
+ const chosen = live ?? fallback
1788
+ return chosen === undefined
1775
1789
  ? { agent: undefined, id: undefined, archived: false, candidates }
1776
- : { ...fallback, candidates }
1790
+ : { ...chosen, candidates }
1777
1791
  }
1778
1792
 
1779
1793
  /**
@@ -1967,6 +1981,20 @@ function createSideEngine(ctx, configOf) {
1967
1981
  const preset = typeof config.sideAgentPreset === 'string' ? config.sideAgentPreset.trim() : ''
1968
1982
  const presetSupported = caps?.agentPreset === true
1969
1983
  const presetInForce = config.sideTools === 'readonly' && preset !== '' && presetSupported
1984
+ // 2.1.9: the read-only guard must fail CLOSED. Without a preset there is
1985
+ // no other real narrowing channel on this host, so a guard we cannot
1986
+ // build is a refusal, not an inheritance.
1987
+ if (config.sideTools === 'readonly' && !presetInForce
1988
+ && toolFilter !== undefined && toolFilter.blocked === 'no-readonly-guard') {
1989
+ sink.send('error', {
1990
+ id,
1991
+ code: 'no-readonly-guard',
1992
+ message: '这台宿主的工具注册表里认不出任何只读工具,无法保证侧边作答只读,已拒绝本次作答',
1993
+ retryable: false,
1994
+ fallback: 'main',
1995
+ })
1996
+ return
1997
+ }
1970
1998
  // Style and the user's own extra instruction ride the SAME persona
1971
1999
  // string, so the "persona vs prompt" gate above covers all three of
1972
2000
  // them with one branch — there is no second injection point.
@@ -1979,7 +2007,7 @@ function createSideEngine(ctx, configOf) {
1979
2007
  signal: controller.signal,
1980
2008
  ...(personaSupported ? { persona: personaText } : {}),
1981
2009
  ...(presetInForce ? { agentPreset: preset } : {}),
1982
- ...(toolFilter === undefined || !toolFilterSupported ? {} : { toolFilter }),
2010
+ ...(toolFilter === undefined || toolFilter.allow === undefined || !toolFilterSupported ? {} : { toolFilter }),
1983
2011
  })
1984
2012
  runRecord.dispose = run.dispose
1985
2013
 
@@ -2005,12 +2033,20 @@ function createSideEngine(ctx, configOf) {
2005
2033
  ? 'inherit'
2006
2034
  : (presetInForce
2007
2035
  ? 'preset'
2008
- : (toolFilter === undefined
2009
- ? 'unsupported'
2010
- : (toolFilterSupported ? 'applied' : 'unsupported'))),
2036
+ : (toolFilter !== undefined && toolFilter.blocked !== undefined
2037
+ ? 'blocked'
2038
+ : (toolFilter === undefined
2039
+ ? 'unsupported'
2040
+ : (toolFilterSupported ? 'applied' : 'unsupported')))),
2011
2041
  agentPreset: presetInForce ? preset : null,
2012
2042
  persona: personaSupported ? 'section' : 'prompt',
2013
2043
  parentArchived: parent.archived === true,
2044
+ // The session that actually owns this child. A mismatch with the
2045
+ // requested session is shown on the card, never silently absorbed.
2046
+ parentSessionId: parent.id ?? null,
2047
+ requestedSessionId: typeof request.sessionId === 'string' && request.sessionId !== ''
2048
+ ? request.sessionId
2049
+ : null,
2014
2050
  })
2015
2051
 
2016
2052
  const childId = run.id
@@ -2698,6 +2734,25 @@ export function apply(ctx, patchConfig) {
2698
2734
  writeError(res, 400, 'invalid-config', problems.join(';'))
2699
2735
  return
2700
2736
  }
2737
+ // 2.1.9: changing WHERE mail is sent (host/port/tls/user) invalidates the
2738
+ // stored authorization code. Keeping it made "host:" a one-field takeover:
2739
+ // point the host at an attacker and the next /feedback hands them the
2740
+ // password in cleartext AUTH PLAIN. Losing the password is recoverable;
2741
+ // leaking it is not.
2742
+ const endpointChanged = ['feedbackSmtpHost', 'feedbackSmtpPort', 'feedbackSmtpSecure', 'feedbackSmtpUser']
2743
+ .some(key => accepted[key] !== undefined && String(accepted[key]) !== String(state.config[key] ?? ''))
2744
+ if (endpointChanged && accepted.feedbackSmtpPass === undefined) {
2745
+ delete userLayer.feedbackSmtpPass
2746
+ problems.push('SMTP 目标已变更,已保存的授权码随之作废,请重新填写')
2747
+ }
2748
+ // The side answerer inheriting the whole tool set is the one irreversible
2749
+ // kind of change here (a page-level script can ask the host to run with the
2750
+ // session's tools). A written confirmation does not stop same-origin code,
2751
+ // but it stops the accident and makes the intent auditable.
2752
+ if (accepted.sideTools === 'inherit' && body.confirmInheritTools !== true) {
2753
+ writeError(res, 400, 'privileged-key', 'sideTools=inherit 需要显式确认(confirmInheritTools: true)')
2754
+ return
2755
+ }
2701
2756
  userLayer = { ...userLayer, ...accepted }
2702
2757
  const saved = writePersistedConfig(userLayer, ctx.logger)
2703
2758
  if (!saved.ok) {
package/locale/en.json CHANGED
@@ -41,5 +41,7 @@
41
41
  "versionViewing": "Viewing an older version (read-only)",
42
42
  "suggestionsLabel": "Suggested follow-ups",
43
43
  "setFollowUps": "Suggest follow-ups at the end",
44
- "setFollowUpsHint": "The model ends each answer with a <followups> block of 2–3 next questions, rendered as clickable chips (no extra request)"
44
+ "setFollowUpsHint": "The model ends each answer with a <followups> block of 2–3 next questions, rendered as clickable chips (no extra request)",
45
+ "setAutoExpandOnDone": "Expand the card when the answer finishes",
46
+ "setAutoExpandOnDoneHint": "On: the answer appears in the card the moment it settles. Off: the one-line capsule stays until you click it"
45
47
  }
package/locale/zh.json CHANGED
@@ -41,5 +41,7 @@
41
41
  "versionViewing": "正在查看历史版本(只读)",
42
42
  "suggestionsLabel": "建议追问",
43
43
  "setFollowUps": "答案末尾给出建议追问",
44
- "setFollowUpsHint": "开启后模型会用 <followups> 块在答案末尾给出 2–3 个可点的下一步问题,点一下即追问(不额外发请求)"
44
+ "setFollowUpsHint": "开启后模型会用 <followups> 块在答案末尾给出 2–3 个可点的下一步问题,点一下即追问(不额外发请求)",
45
+ "setAutoExpandOnDone": "答完自动展开卡片",
46
+ "setAutoExpandOnDoneHint": "开启后,回答一结束就展开成卡片显示正文;关闭后保持一行胶囊,点一下才展开"
45
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidecard-ask",
3
- "version": "2.1.8",
3
+ "version": "2.1.9",
4
4
  "description": "DSH 划词追问(侧边卡片作答):在聊天区与任务区选中文本就地追问,答案由独立子代理在侧边卡片里流式呈现,也可选择落回主对话。",
5
5
  "type": "module",
6
6
  "main": "index.js",