dsh-sidebar-suite 0.2.9 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,12 +1,47 @@
1
1
  # 更新日志 / Changelog
2
2
 
3
- > 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`)。
3
+ > 版本号以 npm registry **实际发布记录**为准(当前:`0.0.0-stage`、`0.1.3`、`0.2.4`、`0.2.5`、`0.2.6`、`0.2.7`、`0.2.8`、`0.2.9`、`0.3.0`、`0.4.0`)。
4
+ > ⚠️ **`0.3.0` 已弃用 / deprecated**:它会让 DSH 卡在 `Loading plugins…` 且主窗口不显示(见 0.4.0 章节第一条)。请升级到 `0.4.0` 或更高版本。`0.3.0` hangs DSH on the loading screen; upgrade to 0.4.0 or later.
4
5
  > `0.2.0`、`0.2.1`、`0.2.2`、`0.2.3` 是开发过程中的本地迭代号,**从未单独发布**;它们的功能一次性随 `0.2.4` 上线,所以在下面合并记录。
5
6
  >
6
7
  > Versions mirror the npm registry. `0.2.0`–`0.2.3` were local iteration numbers that were never published on their own; all of their features shipped together in `0.2.4`.
7
8
 
8
9
  ---
9
10
 
11
+ ## 0.4.0 — 临时会话可以「转正」或「立即删除」/ Promote or delete a temp session(2026-11-11)
12
+
13
+ **这次改了运行时代码**(`lib/client.js` 与宿主半 `lib/temp-session-host.mjs`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches both halves; quit DSH from the tray and reopen once after upgrading.
14
+
15
+ - **⛔ 修复启动死循环(严重,0.3.0 起就有)** — 0.3.0 的页签条插在控制栏与宿主「扩展」面板之间,但 `ensureBar()` 仍要求控制栏的 `nextSibling` 就是那个面板,于是每次 `paint()` 把控制栏搬一次、`ensureTabStrip()` 又搬回来;root 的 childList 变化触发插件自己的 `MutationObserver` → `scheduleSync()` → `paint()`,微任务队列永不排空。表现是**渲染进程 100%、主窗口隐藏、停在 `Loading plugins…`,整个 DSH 打不开**。修复后位置判断改成「顺序在前」而不是「紧邻」,`ensureTabStrip()` 同理;并加了一道「同一宏任务内最多 16 次 sync」的熔断,将来任何重绘自激也不会再把应用锁死。三种布局、以及插件页签接管侧栏的场景都做了真机验收(窗口正常、CDP 秒回、root 子节点顺序 4 次采样不变、8 秒内各进程 CPU 增量 ≤ 0.17s)。**`0.3.0` 已在 npm 标记弃用,请升级到 0.4.0。** The 0.3.0 tab strip sat between the control bar and the panels nav while `ensureBar()` still demanded adjacency, so every repaint moved the bar and fired the plugin's own observer — an endless microtask loop that hid the window on "Loading plugins…". Position checks now compare order instead of adjacency, plus a per-macrotask sync cap. The published 0.3.0 is affected and has been deprecated on npm.
16
+ - **测试** — 新增 `tests/test_033.mjs`(13 项):让 DOM 桩真的把 `MutationObserver` 回调投递回来,一次普通的宿主 DOM 变化就会复现自激。修复前 6 项失败(撞上 200 次变更上限,三种布局全部中招),修复后每次重绘 0 次 root 变更、节点顺序不变。`npm test` 链扩为 11 套。A new suite delivers observer callbacks the way a browser does; the old code spins past a 200-mutation cap, the fixed code settles on zero mutations.
17
+ - **右键临时会话行,弹出「保留为正式会话 / 立即删除」** — 之前临时会话是「要么全删,要么全留」:想留下某一个,只能去把 `DELETE_ON_EXIT` 关掉,结果**所有**临时会话都变成永久。现在单个会话可以就地处理。Right-clicking a temporary row now offers both options, so one session no longer forces a global setting.
18
+ - **只接管临时会话那一行** — 右键普通会话行时插件**完全不碰事件**(不 `preventDefault`、不 `stopPropagation`),宿主或别的插件的右键菜单照常工作。The handler never touches rows it does not own.
19
+ - **「保留为正式会话」后端零改动** — 走的是 0.2.0 就有的 `POST /api/dsh-temp-session.untrack`,只是过去没有入口。Promotion reuses the existing untrack route.
20
+ - **「立即删除」新增一个路由** — `POST /api/dsh-temp-session.remove?sessionId=…`,复用 `purgeAll` 的删除与兜底:先删目录,确认真的没了;文件被占用就先从列表隐藏(`archiveSession`),下次启动再删;**删不掉就仍然留在 tracked 里**,保证下次启动会重试,而不是悄悄变成一个点不开的幽灵。A new single-id route reuses the same delete/hide/retry ladder as the bulk cleanup.
21
+ - **这条路由只接受「临时会话」** — 命中 `state.tracked` 之外的 id 直接拒绝,且在任何文件操作之前拒绝。它能从页面调到,所以绝不能变成「按 id 删任意会话」的接口。It refuses anything outside the tracked set, before touching the filesystem.
22
+ - **菜单本身** — 顶部一行是被右键那一行的标题(截断 48 字,因为会话标题由宿主拥有),下面两个动作各带一句后果说明;靠近视口右/下边缘会自动翻转回界内;`Esc` 关闭并把焦点还给那一行、方向键在两项间移动、点菜单外 / 滚动 / 改窗口大小都会关掉。The menu echoes the row title, explains each consequence, flips near viewport edges, and closes on Esc, outside click, scroll or resize.
23
+ - **`TEMP_ROW_MENU=on` 新开关** — 万一和别的插件抢右键菜单,把它改成 `off` 即可(详情页也有同一个开关,不用手改文件)。A new switch, also exposed on the detail page, in case another plugin owns the context menu.
24
+ - **测试** — 新增 `tests/test_032.mjs`(60 项):跑真实 `lib/client.js`,按宿主的方式造出 `data-row-key="session:<id>"` 行,然后像用户那样右键、点动作、按 Esc、点外部;覆盖「非临时行完全不被拦截」「保留后属性消失」「删除后调用正确的路由」「文件被占用时提示隐藏而非删除」「失败不吞掉」以及尺寸翻转。顺手把 DOM 桩抽成 `tests/dom-stub.mjs`,由 `test_031` 与 `test_032` 共用(`test_031` 仍是 53 项)。`npm test` 链扩为 10 套。One new suite drives the real bundle against fabricated session rows and clicks through the menu; the fake DOM is now shared by both rendering suites.
25
+
26
+ ---
27
+
28
+ ## 0.3.0 — 第三方插件可以注册自己的页签 / Third-party tabs(2026-11-11)
29
+
30
+ **这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
31
+
32
+ - **侧栏变成可插拔的页容器** — 客户端通过 `ctx.provide('sidebarSuite', …)` 发布一个服务,别的插件 `inject = ['sidebarSuite']` 之后就能 `registerTab({ id, title, icon, order, mount })` 把自己的界面挂进侧栏。The sidebar is now a pluggable page container: a `sidebarSuite` service is published on the client context, and any plugin injecting it can register its own page.
33
+ - **给的是普通 DOM 容器,不是 React 组件** — `mount(container, tab)` 拿到一个空 `<div>`,原生 DOM / React / canvas 都行,消费方不必和本插件的框架版本对齐。Consumers get a plain container, so any stack works and no React version has to line up.
34
+ - **懒挂载 + 自带清理** — `mount` 只在页签第一次被打开时执行;返回的清理函数在页签移除、消费方卸载、DSH 退出时都会调用。`registerTab` 返回自己的 dispose 函数,正好能直接塞进 `ctx.effect`。Mount runs on first activation, and the returned disposer plugs straight into `ctx.effect`.
35
+ - **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换保证不会叠出两个同名页签,且旧页签的清理函数先跑。Re-registering an id replaces in place, which is what a hot reload needs.
36
+ - **失败只烂在自己那一页** — `mount` 抛异常时错误写进它自己的容器并 `console.error` 一次,侧栏其余部分照常工作。A throwing consumer is contained inside its own page.
37
+ - **页签条按布局出现** — 布局 B 下是 `[扩展][工作区][插件页…]` 并排;布局 A / C 下只有插件页进条(内置两组仍由原按钮控制,那两种布局不是页签模型)。**一个消费方都没有时这排页签是 `hidden` 的,不占高度**,看到的和 0.2.9 完全一样。Layout B lists the built-in groups alongside the plugin pages; layouts A and C list plugin pages only. With no consumers the strip is `hidden`: zero height, no visual change from 0.2.9.
38
+ - **选中态是宿主主题色** — 当前页签底部一条 2px 强调色(`--dsw-alias-state-business-primary`),跟随工作区主题;窄侧栏(< 300px)自动只留图标,没给图标的页签保留文字而不是变成空白方块。The active page carries a 2 px accent underline in the workspace's theme colour; narrow columns go icon-only, and a tab without an icon keeps its label.
39
+ - **选中哪一页跟着侧栏走** — 存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点一次当前页签退回原来的组。The selection lives in its own storage key, and re-clicking the active page returns to the groups.
40
+ - **点了内置页签也会通知订阅者** — 布局 B 下点「扩展 / 工作区」等于把这一列从插件页手里拿走,`subscribe` 现在会像其它切换一样发出 `('active', null)`;反过来,在两个内置组之间来回切是静默的,不会假装有插件页移动。没有这一条,靠订阅停表 / 停轮询 / 停画布的消费方会以为自己还挂在屏幕上。A click on a built-in page notifies subscribers exactly like any other switch; moving between two built-ins stays silent.
41
+ - **测试** — 新增两套:`tests/test_030.mjs` 在 vm 沙箱里按消费方的方式调服务(注册、列表、激活、订阅、注销、重复注册、非法入参,外加渲染与样式的源码契约、文档与版本联动,70 项);`tests/test_031.mjs` 自己搭了一个符合宿主契约的侧栏 DOM,跑真实 `lib/client.js`,然后像用户那样点页签、再点一次退回、切布局 A/C、注销、点内置页签看通知(53 项,含「连续 60 次重绘不新增节点」的防抖动断言——`paint()` 每次插入节点的话,它自己装的 `MutationObserver` 会把它重新触发成死循环)。`npm test` 链扩为 9 套。Two new suites: one drives the service the way a consumer would, the other runs the real bundle against a synthetic host sidebar and clicks through it, including a repaint-loop guard.
42
+
43
+ ---
44
+
10
45
  ## 0.2.9 — 布局栏重做 + 视觉精修 / Toolbar rebuilt as a segmented control(2026-11-11)
11
46
 
12
47
  **这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
package/DETAIL.md CHANGED
@@ -81,6 +81,45 @@ The toolbar is one segmented control: `[fold | tabs | overlay] │ actions`. The
81
81
 
82
82
  `换布局 / Cycle` 默认不出现(`showQuickSwitch`),打开后和其它动作按钮共用同一套图标。`Cycle` is off by default and shares the icon set when enabled.
83
83
 
84
+ ## 第三方页签 / Third-party tabs(0.3.0)
85
+
86
+ 侧栏从 0.3.0 起是一个**可插拔的页容器**:别的插件通过 `ctx.sidebarSuite` 注册自己的页,用户点一下就能在你的界面和你自己的界面之间切换。
87
+
88
+ ```js
89
+ export const inject = ['sidebarSuite']
90
+
91
+ export function apply(ctx) {
92
+ ctx.effect(() => ctx.sidebarSuite.registerTab({
93
+ id: 'my-plugin',
94
+ title: { zh: '我的面板', en: 'My panel' },
95
+ icon: '<svg viewBox="0 0 16 16">…</svg>', // 或一个 emoji
96
+ order: 100,
97
+ mount(container, tab) {
98
+ container.textContent = 'hello'
99
+ return () => { /* 清理 */ }
100
+ },
101
+ }))
102
+ }
103
+ ```
104
+
105
+ 设计取舍 / Why it looks like this:
106
+
107
+ - **给 DOM 容器,不给 React 组件** — 消费方可以是任何技术栈写的插件,不必和本插件的 React 版本对齐。A plain container works for every consumer regardless of framework.
108
+ - **懒挂载** — `mount` 只在页签第一次被打开时执行,没打开过的页签不跑任何代码。Mount runs on first activation only.
109
+ - **`registerTab` 返回自己的 dispose** — 正好是 `ctx.effect` 要的形状,消费方不用自己维护注销;消费方卸载时页签自动消失。The returned disposer plugs straight into `ctx.effect`.
110
+ - **同 id 重注册 = 原地替换** — 客户端热重载会重复注册,替换能保证只有一个页签。Re-registering an id replaces in place, which is what a hot client reload needs.
111
+ - **失败只烂在自己那一页** — `mount` 抛异常时,错误写进它自己的容器并 `console.error` 一次,侧栏其余部分不受影响。A throwing consumer is contained inside its own page.
112
+ - **"失去这一列"只有一种通知方式** — 不管是被另一个插件页顶掉、被用户再点一次退回、还是布局 B 下点了「扩展 / 工作区」,走的都是同一次 `subscribe('active', null)`;在两个内置组之间来回切则不通知,因为对消费方来说什么都没变。订阅者不必自己推断"我现在还在不在屏幕上"。Losing the column always arrives as one notification, whoever took it; switching between two built-ins stays silent because nothing changed for the consumer.
113
+ - **进不去的边界** — 页签只拿到发给它的容器,没有能力移动、覆盖或重构宿主 DOM。A tab owns its container and nothing else.
114
+
115
+ 三种布局的差异 / Under the three layouts:
116
+
117
+ - B 标签页:`[扩展][工作区][你的页…]` 并排,都是页。
118
+ - A 折叠、C 浮层:**只有第三方页**进条;内置两组仍由原按钮控制,因为这两种布局不是页签模型。
119
+ - 没有任何第三方注册:页签条本身带 `hidden`,不占高度、不参与布局,看到的和 0.2.x 一模一样(只是多一个隐藏的空容器)。With no consumer the strip carries `hidden`: zero height, no layout impact — visually identical to 0.2.x.
120
+
121
+ 选中态存 `localStorage["dsh-sidebar-suite:active-tab"]`,不写插件配置;再点当前页签退回原来的组。
122
+
84
123
  ## 安装与生效 / Install & apply
85
124
 
86
125
  ```sh
@@ -118,7 +157,7 @@ For local development, point the same command at this folder or a packed `.tgz`.
118
157
 
119
158
  **为什么删除是安全的**:`dsh-session-query-sqlite` 的 `_reconcile()` 会拿「磁盘上实际存在的会话」和索引行对比,多出来的行直接 `persistentDeletes`,因此删目录之后搜索索引会自愈,不会留下点不开的幽灵会话。
120
159
 
121
- 配置:`SHOW_TEMP_BUTTON` / `TEMP_IN_CHAT_MODE` / `TEMP_BUTTON_STYLE` / `EXPAND_ALL_SESSIONS` / `DELETE_ON_EXIT` / `SCHEDULE_DELETE` / `SCHEDULE_TIME` / `MAX_KEPT` —— **全部 8 项都能在详情页设**,用记事本改等号右边也行,保存后约 3 秒自动生效,不用重启。
160
+ 配置:`SHOW_TEMP_BUTTON` / `TEMP_IN_CHAT_MODE` / `TEMP_BUTTON_STYLE` / `EXPAND_ALL_SESSIONS` / `TEMP_ROW_MENU` / `DELETE_ON_EXIT` / `SCHEDULE_DELETE` / `SCHEDULE_TIME` / `MAX_KEPT` —— **全部 9 项都能在详情页设**,用记事本改等号右边也行,保存后约 3 秒自动生效,不用重启。
122
161
 
123
162
  **详情页设置(0.2.2)**:插件详情页顶部卡片新增「临时会话自动删除」区块 —— 「退出 DSH 时删除」和「每天定时清理 + 时间」两个独立开关。它不走 `configForms`,而是 `GET/POST /api/dsh-temp-session.config` 交给 Host 半执行,因为**只有一个数据源**:`temp-session-config.txt`。Host 半按行替换(`KEY=value` 只换 `= ` 右边),注释、空行、键顺序原样保留,用户手写的内容不会丢;缺失的键追加到末尾。保存后立即 `restartScheduler()`,无需重启。
124
163
 
@@ -132,12 +171,29 @@ For local development, point the same command at this folder or a packed `.tgz`.
132
171
  - 退出删除(`DELETE_ON_EXIT`)不受 `skipActiveMs` 保护:退出本身就是用户要求清理,而且进程马上消失,没有「下次再说」的机会。
133
172
  - 两个开关互相独立,可以同时开:白天不关 DSH 的人要「每晚 3 点清」,每天关机的人要「退出即清」。
134
173
 
174
+ **单条转正 / 立即删除(0.4.0)**
175
+
176
+ 之前临时会话是「要么全删、要么全留」:想留下某一个,只能关掉 `DELETE_ON_EXIT`,代价是**所有**临时会话一起变成永久。0.4.0 补上了单行入口 —— 右键带「临时」标签的会话行。
177
+
178
+ 四个设计取舍:
179
+
180
+ - **入口用右键,不在行里加按钮**。会话行由 React 拥有,往里塞节点会被下一次重渲染清掉;`data-dsbs-temp-row` 是属性,`applyTempRows()` 随时能补回来,节点不行。右键是零 DOM 侵入的入口。
181
+ - **只拦自己的行**。`onTempContextMenu` 先解析 `data-row-key`,id 不在 `tempIds` 里就直接 `return`,`preventDefault()` 根本不会执行。所以宿主或别的插件的右键菜单在普通会话上照常工作 —— 这是这条功能唯一的兼容性风险,用「不碰」消掉它。真撞上了还有 `TEMP_ROW_MENU=off` 整条让出。
182
+ - **「保留」复用已有后端**。0.2.0 起就有 `POST /api/dsh-temp-session.untrack`,只是当时没有 UI 入口。所以转正是纯前端改动,Host 半一行没动。
183
+ - **「删除」新增路由,且只能删 tracked**。`POST /api/dsh-temp-session.remove?sessionId=…` 走 `removeOne()`:先 `removeSession()` 删目录,再用 `sessionDirsSync()` 确认真的空了;没删掉就 `hideSession()` 归档隐藏。关键在**只接受 `state.tracked` 里的 id,且在调用任何文件操作之前就拒绝** —— 这条路由能从页面调到,不能变成「按 id 删任意会话」的通用删除接口。删除失败时不从 tracked 移除,下次启动继续重试(与 `purgeAll` 一致),否则会留下「已经不算临时、却还在磁盘上」的孤儿。
184
+
185
+ 菜单本身:`role="menu"` + `role="menuitem"`,首项自动获得焦点;方向键在两项间循环,`Esc` 关闭并把焦点还给那一行,点外部 / `scroll` / `resize` 都会关闭(菜单是 `position:fixed`,不关就会飘在错误的行旁边)。视口边缘翻转用 `getBoundingClientRect()` 实测,读不到时退回 `offsetWidth`/`offsetHeight` 与保守的 220×96。
186
+
187
+ Plain English: right-clicking a temporary row opens a two-item menu. "Keep" simply calls the untrack route that has existed since 0.2.0; "Delete now" goes through a new single-id route that refuses anything outside the tracked set before touching the filesystem, hides the row when a file is locked, and keeps the id tracked so the next launch retries. The menu never touches rows this plugin does not own.
188
+
135
189
  **限制 / Limits**
136
190
 
137
191
  - 退出前会话仍写在磁盘上(原生窗口的前提)。
138
192
  - 强杀 DSH 时退出钩子来不及跑,残留会话在下一次启动时删除。
139
193
  - 文件被占用导致删不掉时,先归档隐藏,下一次启动再删。
140
194
  - CHAT 官网模式下临时按钮默认隐藏(那条链路是 DeepSeek 网页账号,和 DSH 会话不是一回事);`TEMP_IN_CHAT_MODE=show` 可强显,`TEMP_BUTTON_STYLE=own` 可完全避开与侧栏顶部控件的抢位。
195
+ - 右键菜单只作用于**临时会话行**。若另一个插件也接管了这些行的右键,把 `TEMP_ROW_MENU` 改成 `off` 让出位置(详情页有同一个开关)。
196
+ - 菜单动作是异步的:点「立即删除」后,行从列表里消失要等宿主自己刷新(插件会尝试调 `uiWorkspace().sessions.reload/refresh/invalidate`,没有这个 API 时静默跳过)。
141
197
 
142
198
  **侧栏顶部按钮的 DOM 约束(0.2.1)**:`dsh-deepseek-chat` 的侧栏适配器(`lib/client.js` 里的 `attachSidebarAdapter`)会从 `sidebar.brand.name` 插槽往上走五层,然后要求侧栏根节点下 `<button>` 子元素**恰好 1 个**(`controls()` 里的 `buttons.length === 1 && buttons[0] === row.nextElementSibling`)。0.2.0 把「临时」按钮作为根节点的第 2 个 `<button>` 插进去,适配器直接 `return null`,于是它在切换器下渲染「当前侧栏不兼容:顶部新会话仍使用 HARNESS」并且不再接管「新会话」。0.2.1 把按钮装进 `[data-dsbs-temp-slot]`(`<div>`),根节点的 `<button>` 计数回到 1,适配器恢复。回归测试见 `_probe/chat-compat.mjs`:它从插件 bundle 里抽出真实的 `attachSidebarAdapter` 跑一遍假 DOM。
143
199
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ The full detail document is [DETAIL.md](DETAIL.md).
24
24
 
25
25
  | 设计取向 / Design goal | 落在哪 / What it means |
26
26
  | --- | --- |
27
- | **强实用性 / Seriously practical** | 三种布局一键切换、默认布局在详情页顶部直接改、会话列表默认全展开(不用再点「展开其余」)、「临时」开真会话且**默认在退出 DSH 时删除**(`DELETE_ON_EXIT=on`;**每天定时清理默认关**,需要时在详情页开启并设时间)、详情页三种反馈入口 |
27
+ | **强实用性 / Seriously practical** | 三种布局一键切换、默认布局在详情页顶部直接改、会话列表默认全展开(不用再点「展开其余」)、「临时」开真会话且**默认在退出 DSH 时删除**(`DELETE_ON_EXIT=on`;**每天定时清理默认关**,需要时在详情页开启并设时间)、**临时会话可右键「保留为正式会话」或「立即删除」**(0.4.0)、详情页三种反馈入口 |
28
28
  | **美观性 / Looks native** | 颜色与层级全取宿主设计变量、深浅主题自动跟随;细字号控制栏、迷你布局示意图、「临时」用 CSS 标签不破坏行高;不重构宿主 DOM、不弹窗、不另起一套皮肤 |
29
29
 
30
30
  完整的「强实用性 / 美观性」逐条说明见 [DETAIL.md](DETAIL.md) 的「这是一款什么插件」一节。
@@ -104,6 +104,53 @@ The toolbar is one segmented control: the three mutually exclusive layouts share
104
104
  `换布局 / Cycle` 默认不出现,可用 `showQuickSwitch: true` 打开;它和其余动作按钮一起走同一套图标。
105
105
  `Cycle` stays hidden unless `showQuickSwitch: true`; it uses the same icon set as the other action buttons.
106
106
 
107
+ ## 第三方插件接入:注册自己的页签 / Third-party tabs(0.3.0)
108
+
109
+ 别的插件可以往侧栏注册一页自己的界面。客户端会拿到一个 `sidebarSuite` 服务:
110
+
111
+ Any other plugin can put its own page in the sidebar through one client service.
112
+
113
+ ```js
114
+ // 在消费方插件里 / in the consumer plugin
115
+ export const inject = ['sidebarSuite']
116
+
117
+ export function apply(ctx) {
118
+ ctx.effect(() => ctx.sidebarSuite.registerTab({
119
+ id: 'my-plugin', // 必填,[A-Za-z0-9._-],≤48 字符
120
+ title: { zh: '我的面板', en: 'My panel' }, // 必填,或直接给字符串
121
+ icon: '<svg viewBox="0 0 16 16">…</svg>', // 可选,也可以是一个 emoji
122
+ order: 100, // 可选,越小越靠左,默认 100
123
+ mount(container, tab) { // 必填(别名 render)
124
+ container.textContent = 'hello'
125
+ return () => { /* 清理 */ } // 可选:移除/卸载时调用
126
+ },
127
+ }))
128
+ }
129
+ ```
130
+
131
+ - `mount` 拿到的是**一个空的普通 `<div>`**,原生 DOM、React、canvas 都行,不要求你用哪套框架。
132
+ - **首次点开才调用 `mount`**:没被打开过的页签不执行任何代码。
133
+ - 返回的清理函数在页签被移除、消费方插件卸载、DSH 退出这三种情况下都会执行。
134
+ - `registerTab` 返回它自己的 dispose 函数,所以 `ctx.effect(() => api.registerTab(...))` 就是完整写法,不用自己记着取消。
135
+ - 同一个 id 重复注册 = **热重载**,会原地替换而不是叠出两个页签。
136
+
137
+ 页签出现在哪儿 / Where the page shows up:
138
+
139
+ | 布局 | 表现 |
140
+ |---|---|
141
+ | B 标签页 | 和控制栏下面那排「扩展 / 工作区」并排 |
142
+ | A 折叠、C 浮层 | 只有插件页签进条;内置两组仍由原按钮控制(这两种布局本来就不是"页签"模型) |
143
+ | 没人注册 | 这排页签**完全不出现**,界面和 0.2.x 一模一样 |
144
+
145
+ 选中哪一页记在 `localStorage["dsh-sidebar-suite:active-tab"]`;**再点一次当前页签就退回原来的组**(布局 A / C 下这是唯一的退出口)。
146
+
147
+ 服务其余方法:`unregisterTab(id)`、`getTabs()`、`activateTab(id)`、`getActiveTab()`、`subscribe(fn)`(`fn(tabs, reason, activeId)`)、`getLayout()`。
148
+
149
+ `reason` 的取值是 `register` / `unregister` / `active` / `orphan`。**点内置页签(布局 B 下的「扩展 / 工作区」)也算一次 `active`**(`activeId` 为 `null`),所以靠订阅停表、停轮询、停动画的代码不需要为内置页签另写一套判断;反过来,在两个内置组之间切换是静默的。
150
+
151
+ > 边界是刻意的:一个页签只拥有发给它的那个容器,没有能力移动或重构侧栏,也不能覆盖宿主自己的界面。
152
+ > A tab owns its container and nothing else — it cannot restructure the sidebar.
153
+
107
154
  ## 实现要点 / How it works
108
155
 
109
156
  - 所有持久状态写在 `<html>` 属性上:`data-dsbs-mode`、`data-dsbs-tab`、`data-dsbs-fold-ext`、`data-dsbs-fold-ws`、`data-dsbs-pop`、`data-dsbs-quick`、`data-dsbs-rail`、`data-dsbs-narrow`。CSS 负责显隐,插件不重构宿主 DOM。All state lives in `<html>` attributes; CSS owns visibility.
@@ -162,6 +209,27 @@ DSH 自己的会话列表每个工作区只先显示 5 条,剩下的折叠成
162
209
  - **用的是 DSH 原生会话窗口**:原生消息流、原生输入框、原生的工具调用/文件读写/命令执行,模型就是你当前在 DSH 里选的那个。插件不再自己画窗口,也不再自己调模型。
163
210
  - 唯一的区别是**退出 DSH 时它会被删掉**。行首会有一个「**临时**」小标签,方便你一眼认出来。
164
211
 
212
+ ### 右键:保留为正式会话 / 立即删除(0.4.0)
213
+
214
+ **在侧栏里右键任何一个带「临时」标签的会话行**,会弹出一个两项菜单:
215
+
216
+ | 选项 | 结果 |
217
+ |---|---|
218
+ | **保留为正式会话** | 它从此不算临时 —— 退出 DSH 不会删、定时清理不会碰,和普通会话完全一样 |
219
+ | **立即删除** | 当场删掉这个会话的磁盘文件,不用等退出(不可恢复) |
220
+
221
+ 出现的时机与边界:
222
+
223
+ - **只对临时会话行生效**。右键普通会话时插件**完全不碰那个事件**,宿主或别的插件的右键菜单照常弹出。
224
+ - 菜单第一行是那一行的标题(太长会截断),每个选项下面有一句后果说明,避免误点「立即删除」。
225
+ - `Esc` 关闭、方向键切换、`Enter` 执行;点菜单外面、滚动、改变窗口大小都会关掉。
226
+ - 靠近屏幕右边缘或底边缘时会自动往回收,不会被切掉。
227
+
228
+ **想关掉它**:详情页的「临时会话右键菜单」开关,或把 `TEMP_ROW_MENU` 改成 `off`。如果哪天有别的插件也想接管右键,用这个开关把位置让出去即可。
229
+
230
+ > 「删不掉」时不会假装成功:文件被别的进程占用时,先把这行从列表里隐藏,并提示「下次启动再删」,而不是留一个点不开的幽灵会话。
231
+ > If a file is locked, the row is hidden and retried next launch instead of pretending the delete worked.
232
+
165
233
  ### 它是怎么被删掉的(说清楚,不夸大)
166
234
 
167
235
  DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`session/create`、`session/rename`、`workspace/archiveSession` 都有,就是没有 delete;官方界面上的「归档」只是把会话从列表里藏起来,文件还在磁盘上)。所以删除是插件自己做的,两道保险:
@@ -186,7 +254,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
186
254
 
187
255
  ### 全部设置图形化 + 配置热重载(0.2.8)
188
256
 
189
- 详情页最上面那张卡现在能改**全部 8 项**配置,不用再碰文本:
257
+ 详情页最上面那张卡现在能改**全部 9 项**配置,不用再碰文本:
190
258
 
191
259
  | 设置 | 对应键 |
192
260
  | --- | --- |
@@ -194,6 +262,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
194
262
  | CHAT 官网模式:隐藏 / 显示 | `TEMP_IN_CHAT_MODE` |
195
263
  | 按钮摆法:并排 / 独占一行 | `TEMP_BUTTON_STYLE` |
196
264
  | 会话默认全展开 | `EXPAND_ALL_SESSIONS` |
265
+ | 临时会话右键菜单 | `TEMP_ROW_MENU` |
197
266
  | 退出 DSH 时删除 | `DELETE_ON_EXIT` |
198
267
  | 每天定时清理 + 时间 | `SCHEDULE_DELETE` / `SCHEDULE_TIME` |
199
268
  | 最多保留几个 | `MAX_KEPT` |
@@ -214,6 +283,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
214
283
  | `TEMP_IN_CHAT_MODE` | `hide` | 装了 `dsh-deepseek-chat` 时,处于 CHAT 官网模式要不要显示按钮 |
215
284
  | `TEMP_BUTTON_STYLE` | `pair` | `pair` = 和「新会话」并排;`own` = 单独占一行,完全不动「新会话」 |
216
285
  | `EXPAND_ALL_SESSIONS` | `on` | 会话列表全部展开并隐藏「展开其余 N 个会话」;`off` = 恢复宿主折叠 |
286
+ | `TEMP_ROW_MENU` | `on` | 右键临时会话是否弹出「保留为正式会话 / 立即删除」;`off` = 不接管右键 |
217
287
  | `DELETE_ON_EXIT` | `on` | 退出时删除;改 `off` 就不再删,临时会话等于普通会话 |
218
288
  | `SCHEDULE_DELETE` | `off` | 每天定时清理;`on` = 到点把临时会话删掉 |
219
289
  | `SCHEDULE_TIME` | `03:00` | 定时清理的时间(24 小时制 `HH:MM`,本机时间) |