dsh-session-manager 0.4.6 → 0.4.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/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-session-manager)](https://www.npmjs.com/package/dsh-session-manager)
6
6
  [![GitHub](https://img.shields.io/badge/GitHub-仓库-blue)](https://github.com/hkkz9522/dsh-session-manager)
7
- [![CI](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
7
+ [![CI](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
8
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
9
 
10
10
  用于在 DeepSeek Harness(DSH)Web 中进行会话管理,包括:删除会话、归档会话、跨工作区移动会话、迁移会话的 Agent 预设。欢迎至 GitHub 提意见。
@@ -13,10 +13,11 @@
13
13
 
14
14
  - **归档 / 移出归档**会话。
15
15
  - **删除会话**:带不可逆操作的二次确认。
16
- - **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的工作目录更新为目标工作区。
17
- - **迁移 Agent 预设**:可按需修改。典型工况:当原预设被改名或删除,导致会话无法恢复时,可修复该会话。
18
- - **会话管理窗口**:在侧边栏中浏览未归档和已归档会话,并对每一行执行打开、归档 / 移出归档、移动、删除、迁移预设。
16
+ - **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的 `cwd` 更新为目标工作区。
17
+ - **迁移 Agent 预设**:按需修改。典型工况:当原预设被改名或删除,导致会话无法恢复时,可修复该会话。
18
+ - **会话管理窗口**:在侧边栏中浏览未归档和已归档会话,并对每一行执行打开、归档 / 移出归档、移动、迁移预设、删除。
19
19
  - 当前会话标题区域提供归档 / 移出归档、移动至工作区和红色的删除会话按钮。
20
+ - 弹窗按钮(移动、迁移预设、删除,以及"会话管理"入口)再次点击会关闭对应弹窗,与标题栏原生按钮行为一致。
20
21
 
21
22
  ## UI 入口
22
23
 
@@ -31,7 +32,7 @@
31
32
 
32
33
  例如:当会话无法恢复,报错表明原 Agent 预设不存在时(例如删掉了 `router-standard`),可以使用迁移功能。
33
34
 
34
- 插件会优先读取最后一条 `agent-preset/selected` 事件中的有效预设;若不存在该事件,则读取会话 header 中的预设。迁移时会安全改写对应的持久化记录、释放可能残留的 live persistence owner,并刷新会话列表。若该会话当前打开,请在迁移后重新打开再继续会话。
35
+ 插件会读取最后一条 `agent-preset/selected` 事件中的有效预设(若不存在则读取会话 header),然后就地重写该事件(若会话从未记录过选择事件则追加新事件)——这一做法是持久的,旧事件保留在日志中作为历史。对 live session,新事件通过 `Session.append()` 追加到内存,再通过 `SessionStore.flush()` 刷到磁盘;api-gateway 的聊天面板在下次事件折叠时即可看到新预设。
35
36
 
36
37
  > 迁移预设只会修改会话元数据,不会改写历史消息、文件或当前工作区。
37
38
 
@@ -59,46 +60,40 @@ dsh plugin --profile web add github:hkkz9522/dsh-session-manager
59
60
  dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
60
61
  ```
61
62
 
62
- ## HTTP API
63
-
64
- 以下本地接口供 Web UI 使用,也可用于集成和排查:
65
-
66
- ```text
67
- POST /session-manager/api/delete { sessionId }
68
- POST /session-manager/api/unarchive { sessionId }
69
- GET /session-manager/api/workspaces
70
- POST /session-manager/api/move { sessionId, targetWorkspaceId }
71
- GET /session-manager/api/preset-scan?sessionId=<sessionId>
72
- POST /session-manager/api/preset-migrate { sessionId, toPreset }
73
- ```
74
-
75
- 示例:将会话迁移到 `standard` 预设。
76
-
77
- ```bash
78
- curl -s -X POST http://127.0.0.1:3080/session-manager/api/preset-migrate \
79
- -H 'content-type: application/json' \
80
- -d '{"sessionId":"session-...","toPreset":"standard"}'
81
- ```
82
-
83
63
  ## 安全与行为说明
84
64
 
85
65
  - **删除不可恢复**,因此界面始终要求确认。
86
- - 移动正在运行的会话时,插件会先中断并关闭该会话,然后自动刷新侧边栏;请在目标工作区重新打开会话后继续。
66
+ - 移动和迁移预设会先 quiesce 该会话的 live agent(取消运行 + 释放 scope + 移出 SessionStore),即使聊天标签页还开着也能成功;侧边栏会自动刷新。
87
67
  - 移动会改写会话保存的 `cwd`,之后的工具调用将在目标工作区执行。
88
- - subagent 会话和临时空白会话占位不会参与删除、移动或预设迁移。
89
- - 文件改写使用临时文件和原子替换(环境支持时),避免产生部分写入的会话工件。
68
+ - subagent 会话和临时空白会话占位不会参与删除、移动或迁移预设。
69
+ - 持久化改写走 DSH 自身的 `open/create/append/flush` 接口,编解码链在内部处理 v2 → v3 格式迁移,写出的工件对当前 DSH 版本始终合法。
70
+
71
+ ## 兼容性
72
+
73
+ | 插件版本 | 已验证 DSH 版本 |
74
+ | --- | --- |
75
+ | 0.4.9 | v0.1.5-rc.1 |
76
+ | 0.4.7 | v0.1.5-rc.1 |
77
+ | 0.4.4 | 0.1.3-alpha.2 |
78
+ | 0.4.1 | 0.1.3-alpha.2 |
79
+ | 0.4.0 | v0.1.2-rc.1 |
80
+ | 0.1.2 | v0.1.0-rc.7 |
81
+ | 0.1.1 | v0.1.0-rc.7 |
82
+ | 0.1.0 | v0.1.0-rc.7 |
83
+
84
+ 本插件是 Cordis 插件,peer dependency 为 `cordis: ">=4.0.0-rc <5"`。
90
85
 
91
- ## 兼容性与开发
86
+ ## 开发
92
87
 
93
- - 本插件是 Cordis 插件,声明的 peer dependency 为 `cordis >=4.0.0-rc <5`。
94
88
  - `lib/index.js` 是 host 端 ESM 插件,`lib/client.js` 是 Web 客户端 bundle,无需构建步骤。
95
89
  - 提交修改前请运行:
96
90
 
97
91
  ```powershell
98
92
  node --check lib/client.js
99
93
  node --check lib/index.js
100
- git diff --check
94
+ node --test test/*.test.mjs
101
95
  node scripts/smoke-test.mjs
96
+ git diff --check
102
97
  npm pack --dry-run
103
98
  ```
104
99
 
@@ -108,10 +103,8 @@ npm pack --dry-run
108
103
 
109
104
  感谢每一位安装和使用 dsh-session-manager 的用户,也感谢提交 Issue 与 Pull Request 帮助改进本插件的朋友们。
110
105
 
111
- 本插件已被 [dsh-market](https://github.com/dsh-market/dsh-market) 和 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 收录。
112
- 欢迎提出修改意见。
106
+ 本插件已被 [dsh-market](https://github.com/dsh-market/dsh-market) 和 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 收录。欢迎提出修改意见。
113
107
 
114
108
  ## 开源许可
115
109
 
116
110
  [MIT](LICENSE)
117
-
package/lib/client.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * @dsh-session-manager — Client half.
3
- * v0.3.0 — replaces 0.2.1 client.js which had V8 strict-mode parse errors.
4
- * Functionally equivalent: header actions (open/archive/move/delete/manage),
5
- * footer action (open session manager panel), and the panel itself with
6
- * delete/move confirm dialogs. The "preset migration" feature is registered
7
- * as a separate slot in a sibling client bundle.
3
+ *
4
+ * Renders the title-bar action cluster (Archive / Unarchive, Move to
5
+ * workspace, Delete session) and the sidebar-foot Session manager panel
6
+ * (Open / Archive / Unarchive / Move / Migrate preset / Delete per row,
7
+ * plus the panel's own filter pills and toggle-close behaviour). No build
8
+ * step: DSH loads the file as a Cordis client bundle via its inject
9
+ * declaration in package.json.
8
10
  */
9
11
  window.__ModuleLoader__.load({
10
12
  id: "dsh-session-manager",
@@ -190,6 +192,7 @@ window.__ModuleLoader__.load({
190
192
  ".sm-moveItemMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}.sm-panelModal{width:760px;max-width:92vw}.sm-panelModal .sm-rowActions{flex-wrap:wrap;justify-content:flex-end}.sm-headerMenu{position:absolute;right:0;top:100%;z-index:50;display:flex;flex-direction:column;min-width:160px;padding:4px;background:var(--dsw-alias-surface-l1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12)}.sm-headerMenuItem{display:flex;align-items:center;width:100%;text-align:left;background:0 0;border:0;border-radius:6px;color:var(--dsw-alias-label-primary);cursor:pointer;padding:6px 10px;font-size:13px;line-height:18px}.sm-headerMenuItem:hover{background:var(--dsw-alias-interactive-bg-hover)}.sm-headerMenuItemDanger{color:var(--dsw-alias-state-error-primary)}.sm-headerBtn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;height:auto;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;padding:4px 12px;font-size:13px;line-height:18px;white-space:nowrap;transition:background .15s,color .15s,border-color .15s}.sm-headerBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}.sm-headerBtn:focus-visible{outline:2px solid var(--dsw-alias-accent-primary);outline-offset:1px}.sm-headerBtn svg{width:16px;height:16px}.sm-migratePanel{display:flex;flex-direction:column;gap:14px;min-width:480px}.sm-migrateRow{display:flex;flex-direction:column;gap:8px}.sm-migrateRow>label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:16px;font-weight:500}.sm-migrateChips{display:flex;flex-wrap:wrap;gap:6px}.sm-migrateChip{padding:6px 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:transparent;cursor:pointer;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap}.sm-migrateChip:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.sm-migrateChip:disabled{opacity:.5;cursor:not-allowed}.sm-migrateChipOn{background:var(--dsw-alias-accent-primary);color:#fff;border-color:var(--dsw-alias-accent-primary)}.sm-migrateEmpty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.sm-migratePreview{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px}.sm-migrateWarn{padding:10px 12px;border:1px solid rgba(217,119,6,.25);border-radius:8px;background:rgba(217,119,6,.08);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px}.sm-migrateList{max-height:200px;overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}.sm-headerBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1)}.sm-headerBtnDanger{background:var(--dsw-alias-state-error-tertiary);color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);font-weight:500}.sm-headerBtnDanger:hover:not(:disabled){background:#d92d20!important;color:#fff!important;border-color:#d92d20!important}.sm-header{display:inline-flex;align-items:center;gap:6px}.sm-headerBtnActive{background:var(--dsw-alias-fill-l3);color:var(--dsw-alias-label-primary)}",
191
193
  ".sm-panelDialog{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:560px;max-width:calc(100vw - 32px);max-height:540px;height:auto;box-sizing:border-box;display:flex;flex-direction:column;gap:0;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.25);overflow:hidden;z-index:9999;opacity:1}.sm-panelDialog .sm-nativeDialogHeader{padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#eee);flex:none}.sm-panelDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600}.sm-panelDialog .sm-nativeDialogFooter{padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:0}.sm-panelDialog .sm-panel{display:flex;flex-direction:column;gap:6px;min-height:0;flex:1 1 auto;overflow:auto;padding:8px 12px}.sm-panelDialog .sm-list{display:flex;flex-direction:column;gap:2px;flex:1 1 auto;min-height:0;overflow:auto}.sm-panelDialog .sm-row{display:flex;align-items:center;gap:8px;padding:0 10px;height:36px;min-height:36px;border:1px solid transparent;border-radius:8px;background:transparent}.sm-panelDialog .sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}.sm-panelDialog .sm-rowCurrent{background:var(--dsw-alias-fill-l2);border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-panelDialog .sm-rowMain{display:flex;align-items:stretch!important;gap:8px;min-width:0;flex:1 1 auto;overflow:hidden;text-align:left!important}.sm-panelDialog .sm-rowTitle{min-width:0;flex:1 1 0%;width:100%;align-self:stretch;display:flex;align-items:center;justify-content:flex-start;gap:6px;font-size:12px;line-height:16px;overflow:hidden;text-align:left!important;direction:ltr!important}.sm-confirmDialog{z-index:10000}.sm-confirmDialog.sm-nativeDialogLayer{position:fixed;left:calc(50vw + 184px);top:calc(50vh - 90px);width:300px;max-width:calc(100vw - 32px);max-height:none;display:block;align-items:initial;justify-content:initial;box-sizing:border-box;background:transparent;border:0;box-shadow:none;padding:0;inset:calc(50vh - 90px) auto auto calc(50vw + 308px)}.sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialogBackdrop{display:none}.sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog{position:relative;left:auto;top:auto;transform:none;width:auto;box-sizing:border-box;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.25);padding:0;overflow:hidden;animation:sm-confirmPop .14s ease-out}.sm-confirmDialog .sm-nativeDialogHeader{padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#eee)}.sm-confirmDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600}.sm-confirmDialog .sm-nativeDialogBody{padding:10px 16px;display:flex;flex-direction:column;gap:8px;min-height:0}.sm-confirmDialog .sm-nativeDialogDescription{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:pre-wrap}.sm-confirmDialog .sm-warn{padding:6px 10px;font-size:12px;line-height:16px;margin-top:0}.sm-confirmDialog .sm-nativeDialogFooter{padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:0}.sm-confirmDialog .sm-nativeDialogButton{min-height:28px;padding:3px 14px;font-size:12px;line-height:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-primary)}.sm-confirmDialog .sm-nativeDialogCancel:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.sm-confirmDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-state-error-primary);color:#fff;border-color:var(--dsw-alias-state-error-primary)}.sm-confirmDialog .sm-nativeDialogConfirm:hover:not(:disabled){filter:brightness(.94)}@keyframes sm-confirmPop{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}.sm-confirmDialog.sm-nativeDialog{padding:0;width:auto;max-width:none}.sm-panelDialog .sm-rowTitle>span:first-child{display:block;min-width:0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;flex:1 1 auto;width:auto;text-align:left!important;direction:ltr!important;unicode-bidi:plaintext}.sm-panelDialog .sm-rowMeta{display:none}.sm-panelDialog .sm-rowActions{display:flex;gap:2px;flex:none;justify-content:flex-end}.sm-panelDialog .sm-rowActions .sm-rowBtn{min-height:22px;height:22px;padding:0 8px;font-size:11px;line-height:14px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.sm-panelDialog .sm-rowActions .sm-rowBtn:hover{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}.sm-panelDialog .sm-rowActions .sm-rowBtnDanger{color:var(--dsw-alias-state-error-primary)}.sm-panelDialog .sm-filter{flex:none;padding:6px 12px 4px;gap:4px;display:flex;flex-wrap:wrap}.sm-panelDialog .sm-filterBtn{min-height:24px;padding:2px 10px;font-size:11px;line-height:16px;border-radius:4px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}.sm-panelDialog .sm-filterBtn.sm-on{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}.sm-panelDialog .sm-badge{font-size:10px;line-height:14px;padding:0 5px;border-radius:6px;flex:none}.sm-panelDialog .sm-error{padding:6px 10px;margin:0;font-size:12px;line-height:16px}.sm-panelDialog .sm-nativeDialogButton{min-height:28px;padding:3px 12px;font-size:12px;line-height:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}.sm-panelDialog .sm-nativeDialogCancel{background:transparent}.sm-panelDialog .sm-nativeDialogGhost{background:transparent;border-color:var(--dsw-alias-border-l2)}.sm-panelDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-panelDialog .sm-empty{padding:18px 0;font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:center}.sm-panelDialog .sm-footError{font-size:11px;color:var(--dsw-alias-state-error-primary);margin-right:auto;line-height:20px}.sm-panelDialog .sm-moveLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}.sm-migrateDialog .sm-nativeDialogFooter{flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:14px}.sm-migrateDialog .sm-migrateChip{cursor:pointer;pointer-events:auto;position:relative;z-index:1}.sm-migrateDialog .sm-migrateChip[disabled]{opacity:.5;cursor:not-allowed}.sm-migrateDialog.sm-nativeDialogLayer{position:fixed;left:50vw;top:auto;bottom:48px;transform:translateX(-50%);width:min(560px,calc(100vw - 32px));max-height:none;display:block;align-items:initial;justify-content:initial;box-sizing:border-box;background:transparent;border:0;box-shadow:none;padding:0;inset:auto;z-index:10001}.sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialogBackdrop{display:none}.sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog{position:relative;left:auto;top:auto;transform:none;width:auto;max-height:min(70vh,520px);box-sizing:border-box;background-color:#ffffff;background-color:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.45);overflow:hidden;padding:0;display:flex;flex-direction:column;gap:0;opacity:1}.sm-migrateDialog{position:fixed;left:50vw;top:auto;bottom:48px;transform:translateX(-50%);width:min(560px,calc(100vw - 32px));max-height:min(70vh,520px);box-sizing:border-box;display:flex;flex-direction:column;gap:0;background-color:#ffffff;background-color:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.45);overflow:hidden;opacity:1;z-index:10001;backdrop-filter:none;-webkit-backdrop-filter:none}.sm-migrateDialog .sm-nativeDialogHeader{padding:12px 18px;border-bottom:1px solid #eee;border-bottom:1px solid var(--dsw-alias-border-l2,#eee);flex:none;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogTitle{margin:0;font-size:15px;line-height:22px;font-weight:600;color:#111;color:var(--dsw-alias-label-primary,#111)}.sm-migrateDialog .sm-migratePanel{padding:12px 18px;display:flex;flex-direction:column;gap:14px;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogBody{padding:0;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-nativeDialogFooter{padding:12px 18px;border-top:1px solid #eee;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:flex-end;gap:8px;background-color:#fff;background-color:var(--dsw-alias-surface-l1,#fff)}.sm-migrateDialog .sm-migrateRow>label{color:#444;color:var(--dsw-alias-label-secondary,#444)}.sm-migrateDialog .sm-migrateChip{padding:6px 12px;border-radius:999px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);background-color:#f5f5f5;background-color:var(--dsw-alias-fill-l2,#f5f5f5);color:#111;color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:18px;cursor:pointer;pointer-events:auto;position:relative;z-index:1;white-space:nowrap}.sm-migrateDialog .sm-migrateChip:hover:not(:disabled){background-color:#e8e8e8;background-color:var(--dsw-alias-interactive-bg-hover,#e8e8e8)}.sm-migrateDialog .sm-migrateChipOn{background-color:#356ae6;background-color:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:#356ae6;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-migrateDialog .sm-migrateChip.sm-migrateChipOn:hover:not(:disabled){background-color:#4576f0;background-color:var(--dsw-alias-accent-primary-hover,#4576f0);color:#fff;border-color:#4576f0;border-color:var(--dsw-alias-accent-primary-hover,#4576f0);filter:none}.sm-migrateDialog .sm-migrateChipOn:hover:not(:disabled){filter:brightness(.92)}.sm-migrateDialog .sm-migrateChip[disabled]{opacity:.5;cursor:not-allowed}.sm-migrateDialog .sm-migrateEmpty{color:#888;color:var(--dsw-alias-label-tertiary,#888)}.sm-migrateDialog .sm-migratePreview{padding:10px 12px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;background-color:#f5f5f5;background-color:var(--dsw-alias-fill-l2,#f5f5f5);color:#111;color:var(--dsw-alias-label-primary,#111);font-size:13px;line-height:18px}.sm-migrateDialog .sm-migrateWarn{padding:10px 12px;border:1px solid rgba(217,119,6,.25);border-radius:8px;background-color:rgba(217,119,6,.08);color:#444;color:var(--dsw-alias-label-secondary,#444);font-size:13px;line-height:18px}.sm-migrateDialog .sm-nativeDialogButton{min-height:32px;padding:4px 14px;font-size:13px;line-height:20px;border:1px solid #d4d4d8;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;cursor:pointer;background-color:#fff;background-color:var(--dsw-alias-fill-l2,#fff);color:#111;color:var(--dsw-alias-label-primary,#111)}.sm-migrateDialog .sm-nativeDialogButton:hover:not(:disabled){background-color:#e8e8e8;background-color:var(--dsw-alias-interactive-bg-hover,#e8e8e8)}.sm-migrateDialog .sm-nativeDialogConfirm{background-color:#356ae6;background-color:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:#356ae6;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-migrateDialog .sm-nativeDialogConfirm:hover:not(:disabled){filter:brightness(.94)}",
192
194
  ".sm-panelDialog .sm-nativeDialogHeader{display:flex;align-items:center;gap:8px}.sm-panelHeaderClose{min-height:26px;padding:2px 9px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#555);cursor:pointer;font-size:12px;line-height:18px}.sm-panelHeaderClose:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#eee);color:var(--dsw-alias-label-primary,#111)}.sm-panelHeaderClose:disabled{opacity:.5;cursor:not-allowed}.sm-migrateCurrent{padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;background:var(--dsw-alias-fill-l2,#f5f5f5);font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary,#111)}.sm-migrateConfirmLayer.sm-confirmDialog.sm-nativeDialogLayer{width:300px;inset:calc(50vh - 90px) auto auto calc(50vw + 308px)}.sm-migrateConfirmLayer.sm-confirmDialog.sm-nativeDialogLayer .sm-migrateDialog{position:relative;left:auto;top:auto;bottom:auto;transform:none;width:100%;max-width:none;max-height:min(70vh,520px);z-index:auto}.sm-migrateConfirmLayer .sm-migratePanel{min-width:0}.sm-migrateConfirmLayer .sm-nativeDialogButton.sm-nativeDialogConfirm:hover:not(:disabled){background-color:#4576f0;background-color:var(--dsw-alias-accent-primary-hover,#4576f0);color:#fff;border-color:#4576f0;border-color:var(--dsw-alias-accent-primary-hover,#4576f0);filter:none}",
195
+
193
196
  // Theme colors are scoped to the plugin dialogs. DSH exposes its
194
197
  // actual theme through `body[data-ds-dark-theme]` and color-scheme;
195
198
  // keeping these values local avoids changing DSH's global variables.
@@ -352,6 +355,33 @@ window.__ModuleLoader__.load({
352
355
  const warning = props.warning || "";
353
356
  const confirmLabel = props.confirmLabel || "确认";
354
357
  const cancelLabel = props.cancelLabel || "取消";
358
+ // Esc is handled at window level (issue #7): the layer-bound
359
+ // onKeyDown was unreachable when the row button that opened
360
+ // the dialog stayed focused. ConfirmDialog's portal to body
361
+ // makes it a separate subtree from the panel section, so
362
+ // keydown never bubbles through it. Enter was previously on
363
+ // the layer too, but that races with the focused Cancel /
364
+ // Confirm button's native Enter handler -- removing the layer
365
+ // Enter leaves Enter handling purely to the focused button.
366
+ const escStateRef = useRef({});
367
+ escStateRef.current = { busy, onCancel };
368
+ useEffect(() => {
369
+ if (!open) return;
370
+ const handler = (e) => {
371
+ if (e.key !== "Escape") return;
372
+ if (e.defaultPrevented) return;
373
+ const s = escStateRef.current;
374
+ if (s.busy) return;
375
+ e.preventDefault();
376
+ e.stopPropagation();
377
+ s.onCancel();
378
+ // See SessionManagerPanel for the rationale.
379
+ const ae = document.activeElement;
380
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
381
+ };
382
+ window.addEventListener("keydown", handler);
383
+ return () => window.removeEventListener("keydown", handler);
384
+ }, [open]);
355
385
  const R = (typeof window !== "undefined") ? window.__smReact : null;
356
386
  const createPortal = (R && typeof R.createPortal === "function") ? R.createPortal : null;
357
387
  const body = (typeof document === "undefined") ? null : document.body;
@@ -362,23 +392,14 @@ window.__ModuleLoader__.load({
362
392
  style: props.anchor && typeof props.anchor.top === "number"
363
393
  ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
364
394
  : null,
365
- role: "presentation",
366
- onKeyDown: (e) => {
367
- if (e.key === "Escape" && !busy) { e.preventDefault(); onCancel(); }
368
- else if (e.key === "Enter" && !busy) { e.preventDefault(); onConfirm(); }
369
- },
370
- tabIndex: -1
395
+ role: "presentation"
371
396
  },
372
- h("div", {
373
- className: "sm-nativeDialogBackdrop",
374
- onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
375
- }),
397
+
376
398
  h("section", {
377
399
  className: "sm-nativeDialog",
378
400
  role: "dialog",
379
401
  "aria-modal": "true",
380
- "aria-labelledby": "sm-confirm-dialog-title",
381
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
402
+ "aria-labelledby": "sm-confirm-dialog-title"
382
403
  },
383
404
  h("div", { className: "sm-nativeDialogHeader" },
384
405
  h("h2", { id: "sm-confirm-dialog-title", className: "sm-nativeDialogTitle" }, title)
@@ -426,6 +447,33 @@ window.__ModuleLoader__.load({
426
447
  const cancelLabel = props.cancelLabel || t("confirm.cancel");
427
448
  const error = props.error || "";
428
449
  const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
450
+ // Esc handled at window level (issue #7) -- Enter stays on the
451
+ // layer because (a) it gates on selectedWorkspaceId which is set
452
+ // only by clicking a listbox item, and (b) racing Enter on the
453
+ // Cancel / Confirm buttons is benign since the layer Enter
454
+ // handler only fires when focus is inside the dialog subtree,
455
+ // which is exactly when the focused button's native Enter has
456
+ // already triggered its own click -- and in MoveDialog that
457
+ // click just toggles selection, never cancels or confirms.
458
+ const escStateRef = useRef({});
459
+ escStateRef.current = { busy, onCancel };
460
+ useEffect(() => {
461
+ if (!open) return;
462
+ const handler = (e) => {
463
+ if (e.key !== "Escape") return;
464
+ if (e.defaultPrevented) return;
465
+ const s = escStateRef.current;
466
+ if (s.busy) return;
467
+ e.preventDefault();
468
+ e.stopPropagation();
469
+ s.onCancel();
470
+ // See SessionManagerPanel for the rationale.
471
+ const ae = document.activeElement;
472
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
473
+ };
474
+ window.addEventListener("keydown", handler);
475
+ return () => window.removeEventListener("keydown", handler);
476
+ }, [open]);
429
477
  if (!open) return null;
430
478
 
431
479
  const list = (Array.isArray(props.workspaces) ? props.workspaces : [])
@@ -456,10 +504,8 @@ window.__ModuleLoader__.load({
456
504
  // Single cancel button (footer) + ESC handler. No "x" close button so the
457
505
  // user only sees one cancel entry point.
458
506
  const onKeyDown = (e) => {
459
- if (e.key === "Escape" && !busy) {
460
- e.preventDefault();
461
- onCancel();
462
- } else if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
507
+ // Enter only -- Esc moved to a window-level listener (see above).
508
+ if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
463
509
  e.preventDefault();
464
510
  onConfirm(selectedWorkspaceId);
465
511
  }
@@ -470,19 +516,14 @@ window.__ModuleLoader__.load({
470
516
  ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
471
517
  : null,
472
518
  role: "presentation",
473
- onKeyDown,
474
- tabIndex: -1
519
+ onKeyDown
475
520
  },
476
- h("div", {
477
- className: "sm-nativeDialogBackdrop",
478
- onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
479
- }),
521
+
480
522
  h("section", {
481
523
  className: "sm-nativeDialog",
482
524
  role: "dialog",
483
525
  "aria-modal": "true",
484
- "aria-labelledby": "sm-move-dialog-title",
485
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
526
+ "aria-labelledby": "sm-move-dialog-title"
486
527
  },
487
528
  h("div", { className: "sm-nativeDialogHeader" },
488
529
  h("h2", { id: "sm-move-dialog-title", className: "sm-nativeDialogTitle" }, title)
@@ -541,6 +582,30 @@ window.__ModuleLoader__.load({
541
582
  });
542
583
  return () => { alive = false; };
543
584
  }, [open, sessionId]);
585
+ // Esc handled at window level (issue #7). MigratePresetDialog is
586
+ // a sibling of the panel layer in the panel's render tree, so
587
+ // the layer-bound onKeyDown (which the dialog relied on for
588
+ // Esc) was dead code -- focus never reached the dialog's own
589
+ // subtree because the row Migrate button stayed focused.
590
+ const escStateRef = useRef({});
591
+ escStateRef.current = { busy, onCancel };
592
+ useEffect(() => {
593
+ if (!open) return;
594
+ const handler = (e) => {
595
+ if (e.key !== "Escape") return;
596
+ if (e.defaultPrevented) return;
597
+ const s = escStateRef.current;
598
+ if (s.busy) return;
599
+ e.preventDefault();
600
+ e.stopPropagation();
601
+ s.onCancel();
602
+ // See SessionManagerPanel for the rationale.
603
+ const ae = document.activeElement;
604
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
605
+ };
606
+ window.addEventListener("keydown", handler);
607
+ return () => window.removeEventListener("keydown", handler);
608
+ }, [open]);
544
609
  if (!open) return null;
545
610
 
546
611
  const currentPreset = (scan && scan.ok && scan.row && (scan.row.finalPreset || scan.row.headerPreset)) || fallbackPreset || "—";
@@ -576,13 +641,9 @@ window.__ModuleLoader__.load({
576
641
  );
577
642
  return h("div", {
578
643
  className: "sm-nativeDialogLayer sm-confirmDialog sm-migrateConfirmLayer",
579
- role: "presentation",
580
- onKeyDown: (event) => { if (event.key === "Escape" && !busy) { event.preventDefault(); onCancel(); } }
644
+ role: "presentation"
581
645
  },
582
- h("div", {
583
- className: "sm-nativeDialogBackdrop",
584
- onMouseDown: (event) => { if (event.target === event.currentTarget && !busy) onCancel(); }
585
- }),
646
+
586
647
  h("section", {
587
648
  className: "sm-nativeDialog sm-migrateDialog",
588
649
  role: "dialog",
@@ -617,6 +678,7 @@ window.__ModuleLoader__.load({
617
678
  getSnapshot() { return this.open; },
618
679
  subscribe(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); },
619
680
  set(v) { if (this.open === v) return; this.open = v; for (const fn of [...this.listeners]) fn(); },
681
+ toggle() { this.open = !this.open; for (const fn of [...this.listeners]) fn(); },
620
682
  // Pending action queued by the title-bar buttons (e.g. 移动至工作区).
621
683
  // The SessionManagerPanel consumes this on mount so it can pre-open
622
684
  // the matching inner dialog (move / delete / migrate) next to itself.
@@ -727,9 +789,9 @@ window.__ModuleLoader__.load({
727
789
  h("div", { className: "sm-rowActions" },
728
790
  h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
729
791
  h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => runRow(s.id, () => isArchived ? onUnarchive(s.id) : onArchive(s.id)) }, t(isArchived ? "row.unarchive" : "row.archive")),
730
- h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => setMoveFor(s) }, t("row.move")),
731
- h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => setMigrateFor(s) }, t("row.migrate")),
732
- h("button", { type: "button", className: "sm-rowBtn sm-rowBtnDanger", disabled: busy, onClick: () => setConfirmFor(s) }, t("row.delete"))
792
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (moveFor && moveFor.id === s.id) setMoveFor(null); else setMoveFor(s); } }, t("row.move")),
793
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (migrateFor && migrateFor.id === s.id) setMigrateFor(null); else setMigrateFor(s); } }, t("row.migrate")),
794
+ h("button", { type: "button", className: "sm-rowBtn sm-rowBtnDanger", disabled: busy, onClick: () => { if (confirmFor && confirmFor.id === s.id) setConfirmFor(null); else setConfirmFor(s); } }, t("row.delete"))
733
795
  )
734
796
  );
735
797
  };
@@ -743,30 +805,48 @@ window.__ModuleLoader__.load({
743
805
  setMigrateFor(null);
744
806
  onClose();
745
807
  };
746
- // Single cancel button in the panel footer (no native "x").
747
- const onPanelKey = (e) => {
748
- if (e.key === "Escape" && busyId === null) {
749
- if (confirmFor || moveFor || migrateFor) return;
808
+ // Esc is handled at window level (issue #7): the original layer-
809
+ // bound onKeyDown was unreachable when focus stayed on the sidebar
810
+ // toggle button that opened the panel -- that button is a sibling
811
+ // of the panel layer, so keydown never bubbles into the panel
812
+ // subtree. The state ref lets the listener see the latest values
813
+ // without re-subscribing on every render. The panel listener
814
+ // returns early when any inner dialog is open so the child
815
+ // dialog's own window listener gets the first shot at Esc.
816
+ const escStateRef = useRef({});
817
+ escStateRef.current = { busyId, confirmFor, moveFor, migrateFor, onPanelClose };
818
+ useEffect(() => {
819
+ if (!open) return;
820
+ const handler = (e) => {
821
+ if (e.key !== "Escape") return;
822
+ if (e.defaultPrevented) return;
823
+ const s = escStateRef.current;
824
+ if (s.busyId !== null) return;
825
+ if (s.confirmFor || s.moveFor || s.migrateFor) return;
750
826
  e.preventDefault();
751
- onPanelClose();
752
- }
753
- };
827
+ e.stopPropagation();
828
+ s.onPanelClose();
829
+ // Esc after a keyboard-driven close leaves the originally-
830
+ // focused trigger button (sidebar toggle / row button) with
831
+ // :focus-visible still active -- blur it so no focus ring
832
+ // lingers after the panel closes. Mouse-driven closes are
833
+ // not affected: :focus-visible only activates for keyboard
834
+ // focus, so the ring never shows in that path.
835
+ const ae = document.activeElement;
836
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
837
+ };
838
+ window.addEventListener("keydown", handler);
839
+ return () => window.removeEventListener("keydown", handler);
840
+ }, [open]);
754
841
  const panelModal = h("div", {
755
842
  className: "sm-nativeDialogLayer",
756
- role: "presentation",
757
- onKeyDown: onPanelKey,
758
- tabIndex: -1
843
+ role: "presentation"
759
844
  },
760
- h("div", {
761
- className: "sm-nativeDialogBackdrop",
762
- onMouseDown: (e) => { if (e.target === e.currentTarget && busyId === null) onPanelClose(); }
763
- }),
764
845
  h("section", {
765
846
  className: "sm-nativeDialog sm-panelDialog",
766
847
  role: "dialog",
767
848
  "aria-modal": "true",
768
- "aria-labelledby": "sm-panel-dialog-title",
769
- ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
849
+ "aria-labelledby": "sm-panel-dialog-title"
770
850
  },
771
851
  h("div", { className: "sm-nativeDialogHeader" },
772
852
  h("h2", { id: "sm-panel-dialog-title", className: "sm-nativeDialogTitle" }, t("panel.title")),
@@ -1062,10 +1142,11 @@ window.__ModuleLoader__.load({
1062
1142
  ctx.sessions.open(sessionId);
1063
1143
  }
1064
1144
  };
1065
- // /move retires a live session, so host/session-removed can arrive on
1066
- // the streaming channel slightly after the HTTP success response.
1067
- // Pull both projections now and once more after that frame to make the
1068
- // destination row appear without a browser refresh.
1145
+ // The 0.4.6+ move path keeps the live agent/session intact (no
1146
+ // session/disposed, no half-resumed Session object), so the chat
1147
+ // panel keeps the in-memory session as active. We still refresh
1148
+ // the workspace + session-list projections so the destination
1149
+ // workspace and the moved row appear without a manual refresh.
1069
1150
  await refreshMovedSession();
1070
1151
  setTimeout(() => { void refreshMovedSession(); }, 250);
1071
1152
  setTimeout(() => { void refreshMovedSession(); }, 900);
@@ -1079,16 +1160,16 @@ window.__ModuleLoader__.load({
1079
1160
  // but calling it unconditionally breaks migration on newer builds.
1080
1161
  await ctx.sessions.refresh();
1081
1162
  if (wasCurrent) ctx.sessions.open(sessionId);
1082
- // Host/session-removed and the HTTP response travel on different
1083
- // streams. One delayed baseline closes that ordering race without a
1084
- // page refresh and keeps the migrated conversation selectable.
1163
+ // The migrate rewrite happens in-process on the live session, so
1164
+ // we just need a delayed baseline to let the api-gateway's projection
1165
+ // settle before re-activating the in-memory session.
1085
1166
  setTimeout(() => {
1086
1167
  void ctx.sessions.refresh().then(() => {
1087
1168
  if (wasCurrent) ctx.sessions.open(sessionId);
1088
1169
  }).catch(() => {});
1089
1170
  }, 250);
1090
1171
  },
1091
- onOpenPanel: () => panelStore.set(true)
1172
+ onOpenPanel: () => panelStore.toggle()
1092
1173
  });
1093
1174
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1094
1175
  name: "conversation.session.header.actions",