dsh-sidebar-suite 0.3.0 → 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,30 @@
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`、`0.3.0`)。
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
+
10
28
  ## 0.3.0 — 第三方插件可以注册自己的页签 / Third-party tabs(2026-11-11)
11
29
 
12
30
  **这次改了运行时代码**(`lib/client.js`),所以**升级后要托盘退出 DSH 再重开一次**。This release touches `lib/client.js`; quit DSH from the tray and reopen once after upgrading.
package/DETAIL.md CHANGED
@@ -157,7 +157,7 @@ For local development, point the same command at this folder or a packed `.tgz`.
157
157
 
158
158
  **为什么删除是安全的**:`dsh-session-query-sqlite` 的 `_reconcile()` 会拿「磁盘上实际存在的会话」和索引行对比,多出来的行直接 `persistentDeletes`,因此删目录之后搜索索引会自愈,不会留下点不开的幽灵会话。
159
159
 
160
- 配置:`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 秒自动生效,不用重启。
161
161
 
162
162
  **详情页设置(0.2.2)**:插件详情页顶部卡片新增「临时会话自动删除」区块 —— 「退出 DSH 时删除」和「每天定时清理 + 时间」两个独立开关。它不走 `configForms`,而是 `GET/POST /api/dsh-temp-session.config` 交给 Host 半执行,因为**只有一个数据源**:`temp-session-config.txt`。Host 半按行替换(`KEY=value` 只换 `= ` 右边),注释、空行、键顺序原样保留,用户手写的内容不会丢;缺失的键追加到末尾。保存后立即 `restartScheduler()`,无需重启。
163
163
 
@@ -171,12 +171,29 @@ For local development, point the same command at this folder or a packed `.tgz`.
171
171
  - 退出删除(`DELETE_ON_EXIT`)不受 `skipActiveMs` 保护:退出本身就是用户要求清理,而且进程马上消失,没有「下次再说」的机会。
172
172
  - 两个开关互相独立,可以同时开:白天不关 DSH 的人要「每晚 3 点清」,每天关机的人要「退出即清」。
173
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
+
174
189
  **限制 / Limits**
175
190
 
176
191
  - 退出前会话仍写在磁盘上(原生窗口的前提)。
177
192
  - 强杀 DSH 时退出钩子来不及跑,残留会话在下一次启动时删除。
178
193
  - 文件被占用导致删不掉时,先归档隐藏,下一次启动再删。
179
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 时静默跳过)。
180
197
 
181
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。
182
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) 的「这是一款什么插件」一节。
@@ -209,6 +209,27 @@ DSH 自己的会话列表每个工作区只先显示 5 条,剩下的折叠成
209
209
  - **用的是 DSH 原生会话窗口**:原生消息流、原生输入框、原生的工具调用/文件读写/命令执行,模型就是你当前在 DSH 里选的那个。插件不再自己画窗口,也不再自己调模型。
210
210
  - 唯一的区别是**退出 DSH 时它会被删掉**。行首会有一个「**临时**」小标签,方便你一眼认出来。
211
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
+
212
233
  ### 它是怎么被删掉的(说清楚,不夸大)
213
234
 
214
235
  DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`session/create`、`session/rename`、`workspace/archiveSession` 都有,就是没有 delete;官方界面上的「归档」只是把会话从列表里藏起来,文件还在磁盘上)。所以删除是插件自己做的,两道保险:
@@ -233,7 +254,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
233
254
 
234
255
  ### 全部设置图形化 + 配置热重载(0.2.8)
235
256
 
236
- 详情页最上面那张卡现在能改**全部 8 项**配置,不用再碰文本:
257
+ 详情页最上面那张卡现在能改**全部 9 项**配置,不用再碰文本:
237
258
 
238
259
  | 设置 | 对应键 |
239
260
  | --- | --- |
@@ -241,6 +262,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
241
262
  | CHAT 官网模式:隐藏 / 显示 | `TEMP_IN_CHAT_MODE` |
242
263
  | 按钮摆法:并排 / 独占一行 | `TEMP_BUTTON_STYLE` |
243
264
  | 会话默认全展开 | `EXPAND_ALL_SESSIONS` |
265
+ | 临时会话右键菜单 | `TEMP_ROW_MENU` |
244
266
  | 退出 DSH 时删除 | `DELETE_ON_EXIT` |
245
267
  | 每天定时清理 + 时间 | `SCHEDULE_DELETE` / `SCHEDULE_TIME` |
246
268
  | 最多保留几个 | `MAX_KEPT` |
@@ -261,6 +283,7 @@ DSH **没有公开的「删除会话」接口**(我核对过 `app.asar`:`ses
261
283
  | `TEMP_IN_CHAT_MODE` | `hide` | 装了 `dsh-deepseek-chat` 时,处于 CHAT 官网模式要不要显示按钮 |
262
284
  | `TEMP_BUTTON_STYLE` | `pair` | `pair` = 和「新会话」并排;`own` = 单独占一行,完全不动「新会话」 |
263
285
  | `EXPAND_ALL_SESSIONS` | `on` | 会话列表全部展开并隐藏「展开其余 N 个会话」;`off` = 恢复宿主折叠 |
286
+ | `TEMP_ROW_MENU` | `on` | 右键临时会话是否弹出「保留为正式会话 / 立即删除」;`off` = 不接管右键 |
264
287
  | `DELETE_ON_EXIT` | `on` | 退出时删除;改 `off` 就不再删,临时会话等于普通会话 |
265
288
  | `SCHEDULE_DELETE` | `off` | 每天定时清理;`on` = 到点把临时会话删掉 |
266
289
  | `SCHEDULE_TIME` | `03:00` | 定时清理的时间(24 小时制 `HH:MM`,本机时间) |
package/lib/client.js CHANGED
@@ -21,7 +21,7 @@ window.__ModuleLoader__.load({
21
21
  var ENTRY = 'dsh-sidebar-suite'
22
22
  // Reported to tab consumers so they can feature-detect. Kept in lockstep
23
23
  // with package.json by tests/test_030.mjs.
24
- var VERSION = '0.3.0'
24
+ var VERSION = '0.4.0'
25
25
  var NS = 'sidebar-suite'
26
26
  var LS_KEY = 'dsh-sidebar-suite:state'
27
27
  // Where the third-party tab choice lives. Kept out of LS_KEY on purpose:
@@ -106,6 +106,15 @@ window.__ModuleLoader__.load({
106
106
  'temp.feedbackHint': 'Report it on the plugin detail page.',
107
107
  'temp.restartHint': 'Fully quit DSH (tray icon → Quit) and reopen it — a hot reload does not reload the plugin host.',
108
108
  'temp.badge': 'TEMP',
109
+ 'temp.menu.label': 'Temporary session actions',
110
+ 'temp.menu.keep': 'Keep as a normal session',
111
+ 'temp.menu.keep.hint': 'It will no longer be deleted when DSH quits.',
112
+ 'temp.menu.delete': 'Delete now',
113
+ 'temp.menu.delete.hint': 'Removes this session from disk. It cannot be undone.',
114
+ 'temp.menu.kept': 'Kept as a normal session — it now survives quitting DSH.',
115
+ 'temp.menu.deleted': 'Temporary session deleted.',
116
+ 'temp.menu.hidden': 'Files were locked, so the row was hidden instead. Deletion is retried next launch.',
117
+ 'temp.menu.failed': 'Action failed: ',
109
118
  'policy.title': 'Temporary session cleanup',
110
119
  'policy.help': 'Choose when temporary sessions are removed. Defaults: delete on quit = on, daily cleanup = off. Saved to the plugin settings file and applied immediately — no restart.',
111
120
  'policy.onQuit': 'Delete when DSH quits',
@@ -160,6 +169,15 @@ window.__ModuleLoader__.load({
160
169
  'temp.feedbackHint': '可在插件详情页反馈。',
161
170
  'temp.restartHint': '请从托盘图标彻底退出 DSH 再打开 —— 热重载不会重载插件后端。',
162
171
  'temp.badge': '临时',
172
+ 'temp.menu.label': '临时会话操作',
173
+ 'temp.menu.keep': '保留为正式会话',
174
+ 'temp.menu.keep.hint': '退出 DSH 时不再删除它。',
175
+ 'temp.menu.delete': '立即删除',
176
+ 'temp.menu.delete.hint': '把这个会话从磁盘删掉,无法恢复。',
177
+ 'temp.menu.kept': '已保留为正式会话:退出 DSH 时不会再删除它。',
178
+ 'temp.menu.deleted': '临时会话已删除。',
179
+ 'temp.menu.hidden': '文件被占用,已先把它从列表里隐藏,下次启动再删。',
180
+ 'temp.menu.failed': '操作失败:',
163
181
  'policy.title': '临时会话自动删除',
164
182
  'policy.help': '决定临时会话什么时候被清掉。默认:退出 DSH 时删除=开,每天定时清理=关。写入插件设置文件,改完立即生效,不用重启。',
165
183
  'policy.onQuit': '退出 DSH 时删除',
@@ -206,6 +224,8 @@ window.__ModuleLoader__.load({
206
224
  'options.styleOwn': '独占一行 / Own row',
207
225
  'options.expandAll': '会话默认全展开 / Always expand sessions',
208
226
  'options.expandHint': '关掉后恢复 DSH 原来的折叠行为;已经展开的列表要切换一次工作区才会收回去。 / Turning it off restores host folding; already-expanded groups collapse after you switch workspaces once.',
227
+ 'options.rowMenu': '临时会话右键菜单 / Right-click menu on temp sessions',
228
+ 'options.rowMenuHint': '右键临时会话可「保留为正式会话」或「立即删除」。若和别的插件抢右键菜单,关掉这一项即可。 / Right-clicking a temp session offers "keep as normal" or "delete now". Turn this off if another plugin owns the context menu.',
209
229
  'options.maxKept': '最多保留 / Keep at most',
210
230
  'options.maxKeptHint': '个临时会话(1–200) / temp sessions (1–200)',
211
231
  'options.purge': '立即清理临时会话 / Clean up now',
@@ -314,6 +334,19 @@ window.__ModuleLoader__.load({
314
334
  // In the collapsed rail there is no horizontal room for two buttons.
315
335
  'html[data-dsbs-temp-solo="true"] [data-dsbs-temp-slot]{display:none!important}',
316
336
  'html[data-dsbs-temp-solo="true"]:not([data-dsbs-chat-mode]) [data-dsbs-newchat] button[class*="newSession"]{width:auto!important;height:38px!important;font-size:14px!important;line-height:22px!important;margin:0 calc(var(--dsh-sidebar-paint-inset,3px) + 1px) 10px!important}',
337
+ // ---- per-row menu on temporary sessions (0.4.0) -------------------
338
+ // Right-clicking a temporary row offers "keep as normal" and "delete
339
+ // now". Styled from host tokens like every other surface here; the row
340
+ // itself gains no children, so React re-renders cannot wipe it.
341
+ '[data-dsbs-temp-menu]{position:fixed;z-index:9400;min-width:196px;max-width:min(280px,calc(100vw - 16px));box-sizing:border-box;padding:4px;display:flex;flex-direction:column;gap:1px;border-radius:10px;background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-base,#1f1f1f));color:var(--dsw-alias-label-primary,inherit);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));box-shadow:0 12px 32px rgba(0,0,0,.32)}',
342
+ '[data-dsbs-temp-menu][hidden]{display:none!important}',
343
+ '[data-dsbs-temp-menu-head]{padding:4px 8px 6px;margin-bottom:2px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.9));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
344
+ '[data-dsbs-temp-menu] button{appearance:none;-webkit-appearance:none;display:block;width:100%;box-sizing:border-box;border:0;background:transparent;color:inherit;font:inherit;font-size:12px;line-height:1.45;text-align:left;padding:6px 8px;border-radius:7px;cursor:pointer;white-space:normal}',
345
+ '[data-dsbs-temp-menu] button:hover,[data-dsbs-temp-menu] button:focus-visible{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));outline:none}',
346
+ '[data-dsbs-temp-menu] button:focus-visible{outline:2px solid var(--dsw-alias-label-primary,currentColor);outline-offset:-2px}',
347
+ '[data-dsbs-temp-menu] button[data-dsbs-temp-action="delete"]{color:var(--dsw-alias-state-business-danger,#d9534f)}',
348
+ '[data-dsbs-temp-menu-sub]{display:block;font-size:10px;line-height:1.4;opacity:.68}',
349
+ 'html[data-dsbs-temp-menu="false"] [data-dsbs-temp-menu]{display:none!important}',
317
350
  // A temporary session is a NORMAL DSH session, so its sidebar row is the
318
351
  // host's own row. Nothing is added to the DOM: we tag the row and let CSS
319
352
  // draw the badge, which survives every React re-render of the list.
@@ -714,6 +747,12 @@ window.__ModuleLoader__.load({
714
747
  var configPollPending = false
715
748
  var tempChatGuard = null
716
749
  var tempIds = [] // session ids the Host half will delete on quit
750
+ // 0.4.0 — per-row menu. `tempMenuEnabled` mirrors TEMP_ROW_MENU and is the
751
+ // escape hatch if another plugin wants the context menu on these rows.
752
+ var tempMenuEnabled = true
753
+ var tempRowMenu = null
754
+ var tempMenuRow = null
755
+ var tempMenuDismiss = null
717
756
  var tempBusy = false
718
757
  var tempToastTimer = 0
719
758
 
@@ -982,6 +1021,275 @@ window.__ModuleLoader__.load({
982
1021
  }
983
1022
  }
984
1023
 
1024
+ // ---- per-row menu on temporary rows (0.4.0) -----------------------
1025
+ // A temporary session used to be all-or-nothing: either it vanished at
1026
+ // quit, or you turned DELETE_ON_EXIT off and every temp session became a
1027
+ // normal one. Now a single row can be promoted ("keep as a normal
1028
+ // session") or deleted on the spot.
1029
+ //
1030
+ // Both actions already existed on the Host half — `untrack` and the new
1031
+ // `remove` route — so this is purely a missing entrance. It never touches
1032
+ // rows that are not temporary: no preventDefault, no stopPropagation, the
1033
+ // host's own context menu keeps working everywhere else.
1034
+
1035
+ /** Nearest ancestor carrying data-row-key="session:<id>". */
1036
+ function tempRowOf(node) {
1037
+ var el = node
1038
+ var guard = 0
1039
+ while (el && el !== document && guard < 60) {
1040
+ guard += 1
1041
+ var key = el.getAttribute ? el.getAttribute('data-row-key') : ''
1042
+ if (key && key.indexOf('session:') === 0) return el
1043
+ el = el.parentNode
1044
+ }
1045
+ return null
1046
+ }
1047
+
1048
+ function tempMenuButtons() {
1049
+ var out = []
1050
+ if (!tempRowMenu) return out
1051
+ var list = null
1052
+ try { list = tempRowMenu.querySelectorAll('button[data-dsbs-temp-action]') } catch (error) { void error }
1053
+ if (!list) return out
1054
+ for (var i = 0; i < list.length; i++) out.push(list[i])
1055
+ return out
1056
+ }
1057
+
1058
+ function closeTempMenu(restoreFocus) {
1059
+ if (!tempRowMenu) return false
1060
+ var row = tempMenuRow
1061
+ try { if (tempRowMenu.parentNode) tempRowMenu.parentNode.removeChild(tempRowMenu) } catch (error) { void error }
1062
+ tempRowMenu = null
1063
+ tempMenuRow = null
1064
+ detachTempMenuDismiss()
1065
+ if (restoreFocus && row && typeof row.focus === 'function') {
1066
+ try { row.focus() } catch (error) { void error }
1067
+ }
1068
+ return true
1069
+ }
1070
+
1071
+ function detachTempMenuDismiss() {
1072
+ var handlers = tempMenuDismiss
1073
+ if (!handlers) return
1074
+ tempMenuDismiss = null
1075
+ try { document.removeEventListener('pointerdown', handlers.onDown, true) } catch (error) { void error }
1076
+ try { document.removeEventListener('keydown', handlers.onKey, true) } catch (error) { void error }
1077
+ try { window.removeEventListener('resize', handlers.onViewport) } catch (error) { void error }
1078
+ try { window.removeEventListener('scroll', handlers.onViewport, true) } catch (error) { void error }
1079
+ }
1080
+
1081
+ function attachTempMenuDismiss() {
1082
+ if (tempMenuDismiss) return
1083
+ var handlers = {
1084
+ onDown: function (event) {
1085
+ if (!tempRowMenu) return
1086
+ var target = event && event.target
1087
+ if (target && tempRowMenu.contains && tempRowMenu.contains(target)) return
1088
+ closeTempMenu(false)
1089
+ },
1090
+ onKey: function (event) {
1091
+ if (!tempRowMenu || !event) return
1092
+ var key = event.key
1093
+ if (key === 'Escape') {
1094
+ event.preventDefault()
1095
+ event.stopPropagation()
1096
+ closeTempMenu(true)
1097
+ return
1098
+ }
1099
+ if (key === 'Tab') { closeTempMenu(false); return }
1100
+ if (key !== 'ArrowDown' && key !== 'ArrowUp') return
1101
+ var buttons = tempMenuButtons()
1102
+ if (!buttons.length) return
1103
+ event.preventDefault()
1104
+ var active = document.activeElement
1105
+ var at = buttons.indexOf(active)
1106
+ var next
1107
+ if (key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % buttons.length
1108
+ else next = at <= 0 ? buttons.length - 1 : at - 1
1109
+ try { buttons[next].focus() } catch (error) { void error }
1110
+ },
1111
+ // The menu is positioned in viewport coordinates, so any scroll or
1112
+ // resize would leave it floating next to the wrong row.
1113
+ onViewport: function () { closeTempMenu(false) },
1114
+ }
1115
+ tempMenuDismiss = handlers
1116
+ try { document.addEventListener('pointerdown', handlers.onDown, true) } catch (error) { void error }
1117
+ try { document.addEventListener('keydown', handlers.onKey, true) } catch (error) { void error }
1118
+ try { window.addEventListener('resize', handlers.onViewport) } catch (error) { void error }
1119
+ try { window.addEventListener('scroll', handlers.onViewport, true) } catch (error) { void error }
1120
+ }
1121
+
1122
+ function makeTempMenuItem(action, labelKey, hintKey, onClick) {
1123
+ var button = document.createElement('button')
1124
+ button.type = 'button'
1125
+ button.setAttribute('role', 'menuitem')
1126
+ button.setAttribute('data-dsbs-temp-action', action)
1127
+ var label = document.createElement('span')
1128
+ label.textContent = t(labelKey)
1129
+ button.appendChild(label)
1130
+ var hint = t(hintKey)
1131
+ if (hint) {
1132
+ var sub = document.createElement('span')
1133
+ sub.setAttribute('data-dsbs-temp-menu-sub', '')
1134
+ sub.textContent = hint
1135
+ button.appendChild(sub)
1136
+ }
1137
+ button.addEventListener('click', function (event) {
1138
+ if (event) {
1139
+ event.preventDefault()
1140
+ event.stopPropagation()
1141
+ }
1142
+ closeTempMenu(false)
1143
+ onClick()
1144
+ })
1145
+ return button
1146
+ }
1147
+
1148
+ function openTempMenu(row, id, x, y) {
1149
+ closeTempMenu(false)
1150
+ var menu = document.createElement('div')
1151
+ menu.setAttribute('data-dsbs-temp-menu', '')
1152
+ menu.setAttribute('role', 'menu')
1153
+ menu.setAttribute('aria-label', t('temp.menu.label'))
1154
+ // The row's own text is the only title available (the host owns the
1155
+ // session list), and it is what the user just pointed at.
1156
+ var text = (row.textContent || '').replace(/\s+/g, ' ').trim()
1157
+ if (text) {
1158
+ var head = document.createElement('div')
1159
+ head.setAttribute('data-dsbs-temp-menu-head', '')
1160
+ head.textContent = text.length > 48 ? text.slice(0, 48) + '…' : text
1161
+ menu.appendChild(head)
1162
+ }
1163
+ menu.appendChild(makeTempMenuItem('keep', 'temp.menu.keep', 'temp.menu.keep.hint', function () {
1164
+ keepTempSession(id)
1165
+ }))
1166
+ menu.appendChild(makeTempMenuItem('delete', 'temp.menu.delete', 'temp.menu.delete.hint', function () {
1167
+ deleteTempSession(id)
1168
+ }))
1169
+ document.body.appendChild(menu)
1170
+
1171
+ // Measure first, then flip near the viewport edges. Both reads are
1172
+ // wrapped: a detached or unmeasured node must not break the menu.
1173
+ var width = 0
1174
+ var height = 0
1175
+ try {
1176
+ var rect = menu.getBoundingClientRect ? menu.getBoundingClientRect() : null
1177
+ width = (rect && rect.width) || menu.offsetWidth || 0
1178
+ height = (rect && rect.height) || menu.offsetHeight || 0
1179
+ } catch (error) { void error }
1180
+ if (!width) width = 220
1181
+ if (!height) height = 96
1182
+ var vw = window.innerWidth || 1024
1183
+ var vh = window.innerHeight || 768
1184
+ var left = Number.isFinite(x) ? x : 0
1185
+ var top = Number.isFinite(y) ? y : 0
1186
+ if (left + width > vw - 8) left = Math.max(8, vw - width - 8)
1187
+ if (top + height > vh - 8) top = Math.max(8, vh - height - 8)
1188
+ menu.style.left = Math.round(left) + 'px'
1189
+ menu.style.top = Math.round(top) + 'px'
1190
+
1191
+ tempRowMenu = menu
1192
+ tempMenuRow = row
1193
+ attachTempMenuDismiss()
1194
+ var buttons = tempMenuButtons()
1195
+ if (buttons.length) {
1196
+ try { buttons[0].focus() } catch (error) { void error }
1197
+ }
1198
+ }
1199
+
1200
+ /** Promote one session: it stops being temporary, nothing is deleted. */
1201
+ function keepTempSession(id) {
1202
+ tempCall('api/dsh-temp-session.untrack?sessionId=' + encodeURIComponent(id), { method: 'POST' })
1203
+ .then(function (data) {
1204
+ if (!data || data.ok !== true) throw new Error((data && data.error) || 'untrack failed')
1205
+ applyTempIdList(data.ids, id)
1206
+ toast(t('temp.menu.kept'), false)
1207
+ scheduleSync()
1208
+ })
1209
+ .catch(function (error) {
1210
+ toast(t('temp.menu.failed') + String((error && error.message) || error), true)
1211
+ })
1212
+ }
1213
+
1214
+ /** Delete one session now, with the same fallback `purgeAll` uses. */
1215
+ function deleteTempSession(id) {
1216
+ tempCall('api/dsh-temp-session.remove?sessionId=' + encodeURIComponent(id), { method: 'POST' })
1217
+ .then(function (data) {
1218
+ if (!data || data.ok !== true) throw new Error((data && data.error) || 'delete failed')
1219
+ applyTempIdList(data.ids, id)
1220
+ toast(t(data.hidden ? 'temp.menu.hidden' : 'temp.menu.deleted'), false)
1221
+ refreshHostSessions()
1222
+ scheduleSync()
1223
+ })
1224
+ .catch(function (error) {
1225
+ toast(t('temp.menu.failed') + String((error && error.message) || error), true)
1226
+ })
1227
+ }
1228
+
1229
+ /** Adopt the id list the Host returned, or just drop the one id. */
1230
+ function applyTempIdList(ids, removedId) {
1231
+ if (Array.isArray(ids)) tempIds = ids.slice()
1232
+ else tempIds = tempIds.filter(function (item) { return item !== removedId })
1233
+ applyTempRows()
1234
+ if (tempRowMenu && tempMenuRow) {
1235
+ var key = tempMenuRow.getAttribute('data-row-key') || ''
1236
+ if (tempIds.indexOf(key.slice('session:'.length)) < 0) closeTempMenu(false)
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * Ask the host's own session list to re-read. There is no documented API
1242
+ * for this, so every common name is tried and failure is silent: a row
1243
+ * that outlives its files disappears on the next list refresh anyway.
1244
+ */
1245
+ function refreshHostSessions() {
1246
+ try {
1247
+ var ui = uiWorkspace()
1248
+ var sessions = ui && ui.sessions
1249
+ var names = ['reload', 'refresh', 'reloadSessions', 'invalidate']
1250
+ for (var i = 0; i < names.length; i++) {
1251
+ if (sessions && typeof sessions[names[i]] === 'function') {
1252
+ sessions[names[i]]()
1253
+ return true
1254
+ }
1255
+ }
1256
+ } catch (error) {
1257
+ void error
1258
+ }
1259
+ return false
1260
+ }
1261
+
1262
+ function onTempContextMenu(event) {
1263
+ if (runtime.disposed || !tempMenuEnabled || !event) return
1264
+ var row = tempRowOf(event.target)
1265
+ if (!row) return
1266
+ var key = row.getAttribute('data-row-key') || ''
1267
+ var id = key.slice('session:'.length)
1268
+ // Not one of ours: leave the event completely alone so the host (or
1269
+ // whichever plugin owns that row) still gets its own menu.
1270
+ if (!id || tempIds.indexOf(id) < 0) return
1271
+ event.preventDefault()
1272
+ event.stopPropagation()
1273
+ openTempMenu(row, id, event.clientX, event.clientY)
1274
+ }
1275
+
1276
+ var tempMenuUnmount = null
1277
+ function mountTempRowMenu() {
1278
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
1279
+ document.addEventListener('contextmenu', onTempContextMenu)
1280
+ tempMenuUnmount = function () {
1281
+ try { document.removeEventListener('contextmenu', onTempContextMenu) } catch (error) { void error }
1282
+ closeTempMenu(false)
1283
+ }
1284
+ }
1285
+
1286
+ function applyTempMenuSetting(data) {
1287
+ var next = !data || data.rowMenu !== false
1288
+ if (next === tempMenuEnabled) return
1289
+ tempMenuEnabled = next
1290
+ if (!next) closeTempMenu(false)
1291
+ }
1292
+
985
1293
  function refreshTempIds() {
986
1294
  return tempCall('api/dsh-temp-session.list')
987
1295
  .then(function (data) {
@@ -1089,6 +1397,8 @@ window.__ModuleLoader__.load({
1089
1397
  expandAll = !data || data.expandAllSessions !== false
1090
1398
  expandAllKnown = true
1091
1399
  applyExpandFlag()
1400
+ // 0.4.0 — same treatment: a Host without this key keeps the menu on.
1401
+ applyTempMenuSetting(data)
1092
1402
  syncChatMode()
1093
1403
  expandAllSessions()
1094
1404
  scheduleSync()
@@ -1282,11 +1592,32 @@ window.__ModuleLoader__.load({
1282
1592
  return bar
1283
1593
  }
1284
1594
 
1595
+ // A paint must be able to leave the tree exactly as it found it. Asking
1596
+ // for an exact neighbour is not enough: the 0.3.0 tab strip (and an
1597
+ // active plugin's panel) legitimately sit between the control bar and
1598
+ // the host's panels nav, so `bar.nextSibling === nav` can never be true
1599
+ // once those exist. Moving the bar on every paint mutates the observed
1600
+ // root, which fires the MutationObserver, which schedules another paint
1601
+ // -- a microtask loop that never returns to the event loop and hangs DSH
1602
+ // on "Loading plugins..." with the renderer at 100%. Compare ORDER, not
1603
+ // adjacency.
1604
+ function isBefore(node, reference) {
1605
+ if (!node || !reference || !node.parentNode || node.parentNode !== reference.parentNode) return false
1606
+ var list = node.parentNode.children
1607
+ var at = -1
1608
+ var before = -1
1609
+ for (var i = 0; i < list.length; i++) {
1610
+ if (list[i] === node) at = i
1611
+ if (list[i] === reference) before = i
1612
+ }
1613
+ return at >= 0 && before >= 0 && at < before
1614
+ }
1615
+
1285
1616
  function ensureBar(fx) {
1286
1617
  var bar = runtime.bar
1287
1618
  if (bar && bar.parentNode !== fx.root) {
1288
1619
  try {
1289
- bar.parentNode.removeChild(bar)
1620
+ if (bar.parentNode) bar.parentNode.removeChild(bar)
1290
1621
  } catch (error) {
1291
1622
  void error
1292
1623
  }
@@ -1298,7 +1629,7 @@ window.__ModuleLoader__.load({
1298
1629
  }
1299
1630
  var anchor = fx.nav || fx.regionArea
1300
1631
  if (anchor && anchor.parentNode === fx.root) {
1301
- if (bar.nextSibling !== anchor) fx.root.insertBefore(bar, anchor)
1632
+ if (bar.parentNode !== fx.root || !isBefore(bar, anchor)) fx.root.insertBefore(bar, anchor)
1302
1633
  } else if (bar.parentNode !== fx.root) {
1303
1634
  fx.root.appendChild(bar)
1304
1635
  }
@@ -1566,7 +1897,7 @@ window.__ModuleLoader__.load({
1566
1897
  var strip = runtime.tabStrip
1567
1898
  if (strip && strip.parentNode !== fx.root) {
1568
1899
  try {
1569
- strip.parentNode.removeChild(strip)
1900
+ if (strip.parentNode) strip.parentNode.removeChild(strip)
1570
1901
  } catch (error) {
1571
1902
  void error
1572
1903
  }
@@ -1579,12 +1910,17 @@ window.__ModuleLoader__.load({
1579
1910
  strip.setAttribute('aria-label', t('tabs.label'))
1580
1911
  runtime.tabStrip = strip
1581
1912
  }
1582
- // Sits under the control bar so the two read as one header. The
1583
- // anchor matters: the host rebuilds the groups, never the bar.
1584
- if (strip.parentNode === fx.root && runtime.bar && strip.previousSibling === runtime.bar) return strip
1585
- var anchor = runtime.bar ? runtime.bar.nextSibling : (fx.nav || fx.regionArea)
1586
- if (anchor && anchor.parentNode === fx.root && anchor !== strip) fx.root.insertBefore(strip, anchor)
1587
- else if (strip.parentNode !== fx.root) fx.root.appendChild(strip)
1913
+ // Sits under the control bar so the two read as one header. A plugin
1914
+ // panel may sit after it, so "immediately after the bar" is too strict
1915
+ // a condition for the steady state: demanding an exact previousSibling
1916
+ // moves the strip on every paint and spins the observer loop.
1917
+ if (strip.parentNode === fx.root && (!runtime.bar || isBefore(runtime.bar, strip))) return strip
1918
+ var anchor = runtime.bar && runtime.bar.parentNode === fx.root
1919
+ ? runtime.bar.nextSibling
1920
+ : (fx.nav || fx.regionArea)
1921
+ if (anchor === strip) return strip
1922
+ if (anchor && anchor.parentNode === fx.root) fx.root.insertBefore(strip, anchor)
1923
+ else fx.root.appendChild(strip)
1588
1924
  return strip
1589
1925
  }
1590
1926
 
@@ -2033,8 +2369,32 @@ window.__ModuleLoader__.load({
2033
2369
  }
2034
2370
 
2035
2371
  var pending = false
2372
+ var syncStreak = 0
2373
+ var streakTimer = null
2374
+ var streakDeferred = false
2375
+ // Safety net for the observer feedback loop: a paint that mutates the
2376
+ // observed root chains MutationObserver -> scheduleSync -> paint in
2377
+ // microtasks forever, so the renderer never gets a turn and DSH hangs on
2378
+ // "Loading plugins...". A healthy startup needs only a handful of syncs
2379
+ // between two macrotasks. Cap the streak, yield once, then resume; this
2380
+ // cannot lose a state change, and it keeps unrelated future regressions
2381
+ // from taking the whole app down.
2036
2382
  function scheduleSync() {
2037
2383
  if (runtime.disposed || pending) return
2384
+ if (!streakTimer) {
2385
+ streakTimer = setTimeout(function () {
2386
+ streakTimer = null
2387
+ syncStreak = 0
2388
+ var again = streakDeferred
2389
+ streakDeferred = false
2390
+ if (again && !runtime.disposed) scheduleSync()
2391
+ }, 0)
2392
+ }
2393
+ if (syncStreak >= 16) {
2394
+ streakDeferred = true
2395
+ return
2396
+ }
2397
+ syncStreak += 1
2038
2398
  pending = true
2039
2399
  Promise.resolve().then(function () {
2040
2400
  pending = false
@@ -2488,6 +2848,8 @@ window.__ModuleLoader__.load({
2488
2848
  inChatMode: data.inChatMode === 'show' ? 'show' : 'hide',
2489
2849
  buttonStyle: data.buttonStyle === 'own' ? 'own' : 'pair',
2490
2850
  expandAllSessions: data.expandAllSessions !== false,
2851
+ // Older Host halves have no rowMenu key; the menu ships on.
2852
+ rowMenu: data.rowMenu !== false,
2491
2853
  maxKept: kept > 0 ? Math.min(Math.round(kept), 200) : 20,
2492
2854
  configRevision: typeof data.configRevision === 'number' ? data.configRevision : null,
2493
2855
  configFile: typeof data.configFile === 'string' ? data.configFile : '',
@@ -2690,6 +3052,10 @@ window.__ModuleLoader__.load({
2690
3052
  persist({ expandAllSessions: next })
2691
3053
  }),
2692
3054
  h('div', { style: dim }, t('options.expandHint')),
3055
+ policyRow(t('options.rowMenu'), policy.rowMenu === true, function (next) {
3056
+ persist({ rowMenu: next })
3057
+ }),
3058
+ h('div', { style: dim }, t('options.rowMenuHint')),
2693
3059
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' } },
2694
3060
  h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary,inherit)' } }, t('options.maxKept')),
2695
3061
  h('input', {
@@ -2993,6 +3359,14 @@ window.__ModuleLoader__.load({
2993
3359
  }
2994
3360
  }, 'sidebar-suite: dismissal')
2995
3361
 
3362
+ effect(function () {
3363
+ // 0.4.0 — right-click on a temporary row. One delegated listener on the
3364
+ // document, because the host recreates session rows on every list
3365
+ // refresh; per-row listeners would need re-binding after each render.
3366
+ mountTempRowMenu()
3367
+ return tempMenuUnmount
3368
+ }, 'sidebar-suite: temp row menu')
3369
+
2996
3370
  effect(function () {
2997
3371
  // Safety net: the sidebar root is created and destroyed by the host, so
2998
3372
  // a root replacement is invisible to an observer that watches the old
@@ -3038,6 +3412,13 @@ window.__ModuleLoader__.load({
3038
3412
  teardown = function () {
3039
3413
  if (runtime.disposed) return
3040
3414
  runtime.disposed = true
3415
+ try {
3416
+ if (streakTimer) clearTimeout(streakTimer)
3417
+ streakTimer = null
3418
+ streakDeferred = false
3419
+ } catch (error) {
3420
+ void error
3421
+ }
3041
3422
  try {
3042
3423
  clearTimeout(tempToastTimer)
3043
3424
  var toastNode = document.querySelector('[data-dsbs-temp-toast]')
@@ -3091,6 +3472,17 @@ window.__ModuleLoader__.load({
3091
3472
  } catch (error) {
3092
3473
  void error
3093
3474
  }
3475
+ try {
3476
+ // 0.4.0 — the row menu lives in <body> and its document listeners
3477
+ // outlive the sidebar if they are not dropped explicitly.
3478
+ closeTempMenu(false)
3479
+ detachTempMenuDismiss()
3480
+ if (tempMenuUnmount) { tempMenuUnmount(); tempMenuUnmount = null }
3481
+ tempRowMenu = null
3482
+ tempMenuRow = null
3483
+ } catch (error) {
3484
+ void error
3485
+ }
3094
3486
  try {
3095
3487
  if (runtime.bar && runtime.bar.parentNode) runtime.bar.parentNode.removeChild(runtime.bar)
3096
3488
  } catch (error) {
@@ -58,6 +58,10 @@ const DEFAULT_CONFIG = {
58
58
  // 0.2.4: the host's workspace list folds after five sessions and paints a
59
59
  // "展开其余 N 个会话" button. This plugin expands every group instead.
60
60
  expandAllSessions: true,
61
+ // 0.4.0: right-clicking a temporary session row offers "keep as a normal
62
+ // session" and "delete now". Off means the row behaves like a plain session
63
+ // row again — the escape hatch if another plugin owns the context menu.
64
+ rowMenu: true,
61
65
  deleteOnExit: true,
62
66
  // Daily cleanup (0.2.2). Independent of deleteOnExit: a user who keeps DSH
63
67
  // open for days wants "delete at 03:00 every night", and a user who closes it
@@ -148,6 +152,7 @@ const CONFIG_KEYS = {
148
152
  inChatMode: { key: 'TEMP_IN_CHAT_MODE', text: (value) => String(value) },
149
153
  buttonStyle: { key: 'TEMP_BUTTON_STYLE', text: (value) => String(value) },
150
154
  expandAllSessions: { key: 'EXPAND_ALL_SESSIONS', text: (value) => (value ? 'on' : 'off') },
155
+ rowMenu: { key: 'TEMP_ROW_MENU', text: (value) => (value ? 'on' : 'off') },
151
156
  deleteOnExit: { key: 'DELETE_ON_EXIT', text: (value) => (value ? 'on' : 'off') },
152
157
  scheduleDelete: { key: 'SCHEDULE_DELETE', text: (value) => (value ? 'on' : 'off') },
153
158
  scheduleTime: { key: 'SCHEDULE_TIME', text: (value) => String(value) },
@@ -185,6 +190,7 @@ function parseConfig() {
185
190
  if (kv.EXPAND_ALL_SESSIONS !== undefined) {
186
191
  next.expandAllSessions = kv.EXPAND_ALL_SESSIONS.toLowerCase() !== 'off'
187
192
  }
193
+ if (kv.TEMP_ROW_MENU !== undefined) next.rowMenu = kv.TEMP_ROW_MENU.toLowerCase() !== 'off'
188
194
  if (kv.DELETE_ON_EXIT !== undefined) next.deleteOnExit = kv.DELETE_ON_EXIT.toLowerCase() !== 'off'
189
195
  if (kv.SCHEDULE_DELETE !== undefined) next.scheduleDelete = kv.SCHEDULE_DELETE.toLowerCase() !== 'off'
190
196
  if (kv.SCHEDULE_TIME !== undefined) {
@@ -224,7 +230,7 @@ function refreshConfigIfChanged() {
224
230
  state.configStamp = stamp
225
231
  const next = parseConfig()
226
232
  const fields = [
227
- 'showButton', 'inChatMode', 'buttonStyle', 'expandAllSessions',
233
+ 'showButton', 'inChatMode', 'buttonStyle', 'expandAllSessions', 'rowMenu',
228
234
  'deleteOnExit', 'scheduleDelete', 'scheduleTime', 'maxKept',
229
235
  ]
230
236
  const changed = fields.some((field) => next[field] !== state.config[field])
@@ -255,6 +261,7 @@ function saveConfig(patch) {
255
261
  if (input.inChatMode === 'show' || input.inChatMode === 'hide') next.inChatMode = input.inChatMode
256
262
  if (input.buttonStyle === 'own' || input.buttonStyle === 'pair') next.buttonStyle = input.buttonStyle
257
263
  if (typeof input.expandAllSessions === 'boolean') next.expandAllSessions = input.expandAllSessions
264
+ if (typeof input.rowMenu === 'boolean') next.rowMenu = input.rowMenu
258
265
  if (typeof input.deleteOnExit === 'boolean') next.deleteOnExit = input.deleteOnExit
259
266
  if (typeof input.scheduleDelete === 'boolean') next.scheduleDelete = input.scheduleDelete
260
267
  const clock = normalizeClock(input.scheduleTime)
@@ -324,6 +331,7 @@ function publicConfig() {
324
331
  inChatMode: state.config.inChatMode,
325
332
  buttonStyle: state.config.buttonStyle,
326
333
  expandAllSessions: state.config.expandAllSessions,
334
+ rowMenu: state.config.rowMenu,
327
335
  deleteOnExit: state.config.deleteOnExit,
328
336
  scheduleDelete: state.config.scheduleDelete,
329
337
  scheduleTime: state.config.scheduleTime,
@@ -536,6 +544,59 @@ async function purgeAll(reason, options) {
536
544
  return report
537
545
  }
538
546
 
547
+ /**
548
+ * Delete one tracked session from the sidebar menu (0.4.0).
549
+ *
550
+ * Same steps as `purgeAll` for a single id: remove the directories, verify they
551
+ * are really gone, fall back to hiding the row when a file is locked. Two
552
+ * deliberate differences:
553
+ *
554
+ * - **Only tracked ids are accepted.** This route is reachable from the page, so
555
+ * it must never become "delete any session by id". An untracked id is refused
556
+ * before any filesystem call.
557
+ * - **A failed delete keeps the id tracked**, exactly like `purgeAll`, so the
558
+ * next launch retries it instead of leaving an undeletable row that silently
559
+ * stopped being temporary.
560
+ */
561
+ async function removeOne(sessionId) {
562
+ await ensureReady()
563
+ const id = normalizeId(sessionId)
564
+ if (id.length === 0) return { ok: false, error: 'invalid session id' }
565
+ if (!state.tracked.includes(id)) return { ok: false, error: 'not a temporary session' }
566
+ let removed = 0
567
+ try {
568
+ removed = await removeSession(id)
569
+ } catch (error) {
570
+ void error
571
+ }
572
+ const gone = removed > 0 && sessionDirsSync(id).length === 0
573
+ let hidden = false
574
+ if (!gone) hidden = await hideSession(id)
575
+ const report = {
576
+ reason: 'single',
577
+ at: new Date().toISOString(),
578
+ tried: 1,
579
+ removed: gone ? 1 : 0,
580
+ hidden: hidden ? 1 : 0,
581
+ skipped: 0,
582
+ failed: [],
583
+ }
584
+ if (gone || hidden) {
585
+ state.tracked = state.tracked.filter((item) => item !== id)
586
+ await saveStore()
587
+ state.lastPurge = report
588
+ return { ok: true, removed: gone, hidden, ids: state.tracked.slice(), count: state.tracked.length }
589
+ }
590
+ report.failed.push(id)
591
+ state.lastPurge = report
592
+ return {
593
+ ok: false,
594
+ error: 'delete failed — the session files are locked',
595
+ ids: state.tracked.slice(),
596
+ count: state.tracked.length,
597
+ }
598
+ }
599
+
539
600
  /** Delete every tracked session synchronously — the quit path has no `await`. */
540
601
  function purgeSync(reason) {
541
602
  if (state.tracked.length === 0) return { reason, tried: 0, removed: 0, kept: [] }
@@ -723,6 +784,7 @@ function status() {
723
784
  chatModePolicy: state.config.inChatMode,
724
785
  buttonStyle: state.config.buttonStyle,
725
786
  expandAllSessions: state.config.expandAllSessions,
787
+ rowMenu: state.config.rowMenu,
726
788
  deleteOnExit: state.config.deleteOnExit,
727
789
  scheduleDelete: state.config.scheduleDelete,
728
790
  scheduleTime: state.config.scheduleTime,
@@ -860,6 +922,12 @@ export function createTempSessionHost(initialCtx) {
860
922
  if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
861
923
  return json(await untrack(sessionId))
862
924
  }],
925
+ // 0.4.0 — delete one temporary session from the row menu.
926
+ ['/api/dsh-temp-session.remove', ['POST', 'GET'], async (request) => {
927
+ const { sessionId } = queryOf(request)
928
+ if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
929
+ return json(await removeOne(sessionId))
930
+ }],
863
931
  ['/api/dsh-temp-session.purge', ['POST', 'GET'], async () => {
864
932
  await ensureReady()
865
933
  return json({ ok: true, ...(await purgeAll('manual')) })
package/locale/en.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "侧栏布局大师 · Sidebar Suite",
4
- "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、会话默认全展开、临时会话默认在退出 DSH 时删除(每天定时清理默认关,可在本页开启并设时间)、设置与反馈都在本页(全部 8 项配置都有开关,改配置文件约 3 秒自动生效、免重启),强实用性;界面沿用宿主原生配色、圆角与间距,只加属性不重构宿主结构,克制又好看。|A plugin dedicated to a better DSH sidebar: one-click layouts, always-expanded sessions, temp sessions deleted when DSH quits by default (daily cleanup off by default and opt-in) and in-page settings/feedback — every config key has a switch and file edits hot-reload — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
4
+ "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、会话默认全展开、临时会话默认在退出 DSH 时删除(每天定时清理默认关,可在本页开启并设时间)、临时会话可右键保留为正式会话或立即删除、设置与反馈都在本页(全部 9 项配置都有开关,改配置文件约 3 秒自动生效、免重启),强实用性;界面沿用宿主原生配色、圆角与间距,只加属性不重构宿主结构,克制又好看。|A plugin dedicated to a better DSH sidebar: one-click layouts, always-expanded sessions, temp sessions deleted when DSH quits by default (daily cleanup off by default and opt-in) and a right-click menu to keep or delete one temp session, in-page settings/feedback — every config key has a switch and file edits hot-reload — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
5
5
  "detail": "DETAIL.md"
6
6
  }
7
7
  }
package/locale/zh.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "侧栏布局大师 · Sidebar Suite",
4
- "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、会话默认全展开、临时会话默认在退出 DSH 时删除(每天定时清理默认关,可在本页开启并设时间)、设置与反馈都在本页(全部 8 项配置都有开关,改配置文件约 3 秒自动生效、免重启),强实用性;界面沿用宿主原生配色、圆角与间距,只加属性不重构宿主结构,克制又好看。|A plugin dedicated to a better DSH sidebar: one-click layouts, always-expanded sessions, temp sessions deleted when DSH quits by default (daily cleanup off by default and opt-in) and in-page settings/feedback — every config key has a switch and file edits hot-reload — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
4
+ "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、会话默认全展开、临时会话默认在退出 DSH 时删除(每天定时清理默认关,可在本页开启并设时间)、临时会话可右键保留为正式会话或立即删除、设置与反馈都在本页(全部 9 项配置都有开关,改配置文件约 3 秒自动生效、免重启),强实用性;界面沿用宿主原生配色、圆角与间距,只加属性不重构宿主结构,克制又好看。|A plugin dedicated to a better DSH sidebar: one-click layouts, always-expanded sessions, temp sessions deleted when DSH quits by default (daily cleanup off by default and opt-in) and a right-click menu to keep or delete one temp session, in-page settings/feedback — every config key has a switch and file edits hot-reload — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
5
5
  "detail": "DETAIL.md"
6
6
  }
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-suite",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Yonan",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "publishConfig": {
15
15
  "access": "public"
16
16
  },
17
- "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、默认布局在详情页顶部直接设、会话列表默认全展开、「临时会话」开原生会话且默认在退出 DSH 时删除(DELETE_ON_EXIT 默认开;每天定时清理默认关,可在详情页开启并自设时间)、详情页三种反馈入口、全部 8 项配置都有图形开关且改文件免重启 —— 强实用性;界面沿用宿主原生配色、圆角与间距,只加属性与一条控制栏、不重构宿主结构 —— 克制美观。|A plugin dedicated to the DSH sidebar experience: one-click layouts, always-expanded sessions, native temp sessions deleted when DSH quits by default (DELETE_ON_EXIT on; daily cleanup off by default and opt-in) in-page settings/feedback, a control for every config key and hot-reloaded config edits — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
17
+ "description": "一款专门针对改进 DSH 左侧栏体验的插件:三种布局一键切换、默认布局在详情页顶部直接设、会话列表默认全展开、「临时会话」开原生会话且默认在退出 DSH 时删除(DELETE_ON_EXIT 默认开;每天定时清理默认关,可在详情页开启并自设时间)、详情页三种反馈入口、临时会话可右键「保留为正式会话」或「立即删除」、全部 9 项配置都有图形开关且改文件免重启 —— 强实用性;界面沿用宿主原生配色、圆角与间距,只加属性与一条控制栏、不重构宿主结构 —— 克制美观。|A plugin dedicated to the DSH sidebar experience: one-click layouts, always-expanded sessions, native temp sessions deleted when DSH quits by default (DELETE_ON_EXIT on; daily cleanup off by default and opt-in) in-page settings/feedback, a right-click menu to keep or delete one temp session, a control for every config key and hot-reloaded config edits — seriously practical; native colours, radii and spacing with zero host-DOM rewrites — restrained and good-looking.",
18
18
  "keywords": [
19
19
  "dsh-plugin",
20
20
  "dsh",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "type": "module",
29
29
  "scripts": {
30
- "test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs && node tests/test_030.mjs && node tests/test_031.mjs",
30
+ "test": "node tests/test_020.mjs && node tests/test_022.mjs && node tests/test_023.mjs && node tests/test_024.mjs && node tests/test_025.mjs && node tests/test_028.mjs && node tests/test_029.mjs && node tests/test_030.mjs && node tests/test_031.mjs && node tests/test_032.mjs && node tests/test_033.mjs",
31
31
  "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/temp-session-host.mjs && npm pack --dry-run"
32
32
  },
33
33
  "main": "lib/index.js",
@@ -33,6 +33,13 @@ TEMP_BUTTON_STYLE=pair
33
33
  # off = keep DSH's own folding and leave that button visible
34
34
  EXPAND_ALL_SESSIONS=on
35
35
 
36
+ # 右键临时会话时,是否弹出「保留为正式会话 / 立即删除」菜单 / Right-click menu
37
+ # on = 弹出(默认)。选「保留为正式会话」后,这个会话就不再算临时,退出时不会删。
38
+ # off = 不接管右键菜单(别的插件抢右键时用这个)
39
+ # on = show the menu (default). "Keep as a normal session" makes it permanent.
40
+ # off = leave the context menu alone (use this if another plugin owns it)
41
+ TEMP_ROW_MENU=on
42
+
36
43
  # 退出 DSH 时删除临时会话 / Delete temporary sessions when DSH quits
37
44
  # on = 退出时删除(默认);off = 不删除,临时会话就变成普通会话
38
45
  DELETE_ON_EXIT=on
@@ -183,9 +183,16 @@ DSH **没有公开的「删除会话」接口**(官方界面的「归档」只
183
183
 
184
184
  为什么必须这样:要用原生窗口、还要能中途切走再切回,它就只能是真会话。如果要求「连磁盘都不许写」,那就只能是不在侧栏、不能多任务的纯内存方案 —— 和你要的正好相反。
185
185
 
186
- ### 4.5 想让它别删
186
+ ### 4.5 想留住某一个 / 想全都不删
187
187
 
188
- 配置第 38 行 `DELETE_ON_EXIT=off`(见下一节)。关掉后不删除,临时会话就等于普通会话。
188
+ **只想留住某一个(推荐,0.4.0)**:在侧栏里**右键那个带「临时」标签的会话**,选「保留为正式会话」。
189
+
190
+ 它立刻变成普通会话 —— 退出不删、定时清理也不碰,**其它临时会话照旧按时删掉**。同一个菜单里还有「立即删除」,用来当场删掉这一个,不用等退出。
191
+
192
+ - 右键**普通会话**时插件完全不插手,你的其它右键菜单照常出现。
193
+ - 右键没反应?看配置第 41 行 `TEMP_ROW_MENU` 是不是被改成 `off` 了(详情页的「临时会话右键菜单」开关写的是同一行)。
194
+
195
+ **想全部保留**:把配置第 45 行 `DELETE_ON_EXIT=off`(见下一节)。关掉后所有临时会话都不再删除,等于普通会话。
189
196
 
190
197
  ---
191
198
 
@@ -201,18 +208,19 @@ DSH **没有公开的「删除会话」接口**(官方界面的「归档」只
201
208
  | **19** | `TEMP_IN_CHAT_MODE=hide` | `hide` / `show` | 装了 `dsh-deepseek-chat` 时,处于 CHAT 官网模式要不要显示按钮 |
202
209
  | **27** | `TEMP_BUTTON_STYLE=pair` | `pair` / `own` | `pair` = 和「新会话」并排;`own` = 单独占一整行,完全不改「新会话」 |
203
210
  | **34** | `EXPAND_ALL_SESSIONS=on` | `on` / `off` | `on` = 会话列表全部展开、隐藏「展开其余 N 个会话」;`off` = 恢复 DSH 原来的折叠 |
204
- | **38** | `DELETE_ON_EXIT=on` | `on` / `off` | **退出 DSH 时**删不删 |
205
- | **44** | `SCHEDULE_DELETE=off` | `on` / `off` | **每天定时清理**要不要开 |
206
- | **49** | `SCHEDULE_TIME=03:00` | `HH:MM`(如 `03:00`、`23:30`) | 定时清理的时间点,24 小时制、本机时间 |
207
- | **53** | `MAX_KEPT=20` | 1–200 | 最多同时留几个临时会话,超了先删最早的 |
211
+ | **41** | `TEMP_ROW_MENU=on` | `on` / `off` | `on` = 右键临时会话可「保留为正式会话 / 立即删除」;`off` = 不接管右键(别的插件抢右键时用) |
212
+ | **45** | `DELETE_ON_EXIT=on` | `on` / `off` | **退出 DSH 时**删不删 |
213
+ | **51** | `SCHEDULE_DELETE=off` | `on` / `off` | **每天定时清理**要不要开 |
214
+ | **56** | `SCHEDULE_TIME=03:00` | `HH:MM`(如 `03:00`、`23:30`) | 定时清理的时间点,24 小时制、本机时间 |
215
+ | **60** | `MAX_KEPT=20` | 1–200 | 最多同时留几个临时会话,超了先删最早的 |
208
216
 
209
- > 这一整节都可以不动手改文件 —— 0.2.8 起详情页有**全部 8 项的图形化开关**,见 5.1。两种改法写的是**同一份文件**,改完都在约 3 秒内生效,不会打架。
217
+ > 这一整节都可以不动手改文件 —— 0.2.8 起详情页有**全部 9 项的图形化开关**,见 5.1。两种改法写的是**同一份文件**,改完都在约 3 秒内生效,不会打架。
210
218
 
211
219
  ### 5.1 全部设置都在详情页里改(推荐,不用改文件)
212
220
 
213
221
  打开 DSH → 插件 → `dsh-sidebar-suite` → 详情页最上面那张卡。0.2.8 起这里是**两块设置**:
214
222
 
215
- **第一块「侧栏与临时会话」**(这一节表里 8 个键里的前 4 个 + 保留上限,全都能点):
223
+ **第一块「侧栏与临时会话」**(这一节表里 9 个键里的前 5 个 + 保留上限,全都能点):
216
224
 
217
225
  ```
218
226
  侧栏与临时会话 · Sidebar & temp session options
@@ -222,6 +230,7 @@ DSH **没有公开的「删除会话」接口**(官方界面的「归档」只
222
230
  CHAT 官网模式 [隐藏按钮] / [照常显示]
223
231
  按钮摆法 [并排] / [独占一行]
224
232
  会话默认全展开 [开] / [关]
233
+ 临时会话右键菜单 [开] / [关]
225
234
  最多保留 [ 20 ] 个临时会话(1–200)
226
235
  设置版本 12 · …/temp-session-config.txt
227
236
  ```