dsh-session-manager 0.1.1 → 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/lib/client.js CHANGED
@@ -1,20 +1,10 @@
1
1
  /**
2
2
  * @dsh-session-manager — Client half.
3
- *
4
- * Adds two UI surfaces to the web app:
5
- *
6
- * 1. conversation.session.header.actions — a per-session menu on the open
7
- * conversation's header: 归档 / 移出归档 / 删除(二次确认)/ 打开会话管理。
8
- * 2. sidebar.footer.action — a "会话管理" footer button that opens the full
9
- * management panel (all sessions, archived filter, per-row open / archive /
10
- * unarchive / delete with confirmation).
11
- *
12
- * Archive uses the built-in `workspaces.archiveSession` wire API; unarchive and
13
- * delete go through this plugin's own host HTTP endpoints
14
- * (/session-manager/api/unarchive, /session-manager/api/delete).
15
- *
16
- * Written as the loader's factory-form bundle by hand (no build step), using
17
- * React.createElement instead of JSX so the file ships as-is.
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.
18
8
  */
19
9
  window.__ModuleLoader__.load({
20
10
  id: "dsh-session-manager",
@@ -24,79 +14,39 @@ window.__ModuleLoader__.load({
24
14
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
25
15
 
26
16
  const React = require("react");
27
- const {
28
- useState,
29
- useEffect,
30
- useMemo,
31
- useRef,
32
- useSyncExternalStore
33
- } = React;
17
+ const { useState, useEffect, useMemo, useRef, useSyncExternalStore } = React;
34
18
  const P = require("@deepseek-ai/dsh-client-ui-primitives");
19
+ if (typeof window !== "undefined") {
20
+ window.__smReact = React;
21
+ window.__smReactDOM = React.createPortal ? React : null;
22
+ // Tiny imperative portal helper: takes a React element + a host
23
+ // DOM node, creates a React root on the host (lazily), and re-renders
24
+ // the element into it on each call. Used by ConfirmDialog to escape
25
+ // the sidebar overflow:hidden ancestor.
26
+ try {
27
+ const RDOM = require("react-dom/client");
28
+ if (RDOM && typeof RDOM.createRoot === "function") {
29
+ window.__smRenderInto = (tree, host) => {
30
+ if (!tree || !host) return;
31
+ try {
32
+ if (!host.__smRoot) host.__smRoot = RDOM.createRoot(host);
33
+ host.__smRoot.render(tree);
34
+ } catch (_) { /* ignore render failure */ }
35
+ };
36
+ }
37
+ } catch (_) { /* react-dom/client not available */ }
38
+ }
35
39
 
36
40
  const NS = "session-manager";
37
41
  const API = "/session-manager/api";
38
42
 
39
- // ---------------------------------------------------------------------
40
- // Styles (theme-token driven; matches the dsh web design language)
41
- // ---------------------------------------------------------------------
42
- const css = [
43
- ".sm-header{display:flex;align-items:center;gap:6px}",
44
- ".sm-headerBtn{box-sizing:border-box;min-height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;align-items:center;gap:4px;padding:3px 10px;font-size:12px;line-height:18px;display:inline-flex;white-space:nowrap}",
45
- ".sm-headerBtn:hover:not(:disabled),.sm-headerBtn:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}",
46
- ".sm-headerBtn:disabled{opacity:.5;cursor:not-allowed}",
47
- ".sm-headerBtn svg{flex:none}",
48
- ".sm-headerDanger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}",
49
- ".sm-headerDanger:hover:not(:disabled){background:rgba(217,78,68,.1)}",
50
- ".sm-busy{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:28px;margin-left:4px}",
51
- ".sm-footer{display:flex}",
52
- ".sm-footerBtn{min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;align-items:center;gap:6px;padding:3px 8px;font-size:12px;line-height:18px;display:inline-flex}",
53
- ".sm-footerBtn:hover,.sm-footerBtn:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
54
- ".sm-footerBtn svg{flex:none}",
55
- ".sm-panel{display:flex;flex-direction:column;gap:10px;min-height:280px;max-height:min(60vh,560px)}",
56
- ".sm-filter{display:flex;gap:4px;flex:none}",
57
- ".sm-filterBtn{cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 12px;font-size:12px;line-height:18px}",
58
- ".sm-filterBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
59
- ".sm-filterBtn.sm-on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2);border-color:transparent}",
60
- ".sm-list{display:flex;flex-direction:column;gap:4px;min-height:0;overflow:auto;flex:1}",
61
- ".sm-row{box-sizing:border-box;display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}",
62
- ".sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
63
- ".sm-rowCurrent{border-color:var(--dsw-alias-accent-primary,var(--dsw-alias-label-secondary))}",
64
- ".sm-rowMain{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1}",
65
- ".sm-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;display:flex;align-items:center;gap:6px;min-width:0}",
66
- ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}",
67
- ".sm-rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}",
68
- ".sm-rowActions{display:flex;gap:4px;flex:none}",
69
- ".sm-badge{flex:none;border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}",
70
- ".sm-badgeArchived{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2)}",
71
- ".sm-badgeRunning{color:var(--dsw-alias-state-warning-primary,#d97706);background:rgba(217,119,6,.12)}",
72
- ".sm-badgeCurrent{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2)}",
73
- ".sm-danger{color:var(--dsw-alias-state-error-primary) !important;border-color:var(--dsw-alias-state-error-primary) !important}",
74
- ".sm-dangerText{color:var(--dsw-alias-state-error-primary) !important}",
75
- ".sm-warn{display:flex;align-items:flex-start;gap:8px;color:var(--dsw-alias-label-secondary);background:rgba(217,119,6,.1);border:1px solid rgba(217,119,6,.35);border-radius:8px;padding:8px 10px;font-size:12px;line-height:18px}",
76
- ".sm-warn svg{flex:none;margin-top:1px}",
77
- ".sm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;min-height:18px}",
78
- ".sm-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;width:100%}",
79
- ".sm-footError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;margin-right:auto}",
80
- ".sm-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;text-align:center;padding:24px 0}"
81
- ].join("");
82
-
83
- const CSS_ID = "dsh-session-manager/session-manager";
84
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_ID) + "]") === null) {
85
- const tag = document.createElement("style");
86
- tag.dataset.plugin = "dsh-session-manager";
87
- tag.dataset.pluginCss = CSS_ID;
88
- tag.textContent = css;
89
- document.head.appendChild(tag);
90
- }
91
-
92
- // ---------------------------------------------------------------------
93
- // Locales
94
- // ---------------------------------------------------------------------
95
43
  const zh = {
96
44
  "header.aria": "会话操作",
45
+ "menu.open": "打开",
97
46
  "menu.archive": "归档会话",
98
47
  "menu.unarchive": "移出归档",
99
48
  "menu.delete": "删除会话…",
49
+ "menu.move": "移动到工作区…",
100
50
  "menu.manage": "打开会话管理…",
101
51
  "footer.aria": "会话管理",
102
52
  "footer.label": "会话管理",
@@ -109,23 +59,45 @@ window.__ModuleLoader__.load({
109
59
  "panel.archived": "已归档",
110
60
  "panel.running": "运行中",
111
61
  "panel.current": "当前",
62
+ "panel.session.new": "新建会话",
63
+ "panel.close": "关闭",
112
64
  "row.open": "打开",
113
65
  "row.archive": "归档",
114
66
  "row.unarchive": "移出归档",
115
- "row.delete": "删除",
67
+ "row.move": "移动至工作区",
68
+ "row.delete": "删除会话",
69
+ "row.migrate": "迁移预设",
70
+ "migrate.title": "迁移 Agent 预设",
71
+ "migrate.current": "当前预设",
72
+ "migrate.target": "目标预设",
73
+ "migrate.noPresets": "没有可用的目标预设",
74
+ "migrate.same": "请选择与当前预设不同的目标预设",
75
+ "migrate.confirm": "确认迁移",
76
+ "migrate.completed": "Agent 预设已迁移为 {preset}",
116
77
  "confirm.delete.title": "删除会话",
117
78
  "confirm.delete.desc": "会话「{title}」将被永久删除,包括其全部消息记录与磁盘文件,此操作不可撤销。",
118
79
  "confirm.delete.running": "该会话正在运行,删除将立即中断它。",
119
80
  "confirm.delete.confirm": "确认删除",
81
+ "confirm.move.title": "移动会话到工作区",
82
+ "confirm.move.desc": "将会话「{title}」移动到目标工作区。",
83
+ "confirm.move.running": "该会话正在运行,移动会中断并关闭它;之后可在目标工作区重新打开继续。",
84
+ "confirm.move.empty": "没有可移动到的其他工作区。",
85
+ "confirm.move.select": "选择目标工作区",
86
+ "confirm.move.current": "当前工作区:{name}",
87
+ "confirm.move.confirm": "确认移动",
120
88
  "confirm.cancel": "取消",
121
89
  "busy.processing": "处理中…",
122
- "error.operation": "操作失败:{message}"
90
+ "error.operation": "操作失败:{message}",
91
+ "workspace.default": "默认工作区",
92
+ "workspace.sessions": "{count} 个会话"
123
93
  };
124
94
  const en = {
125
95
  "header.aria": "Session actions",
96
+ "menu.open": "Open",
126
97
  "menu.archive": "Archive session",
127
98
  "menu.unarchive": "Unarchive session",
128
99
  "menu.delete": "Delete session…",
100
+ "menu.move": "Move to workspace…",
129
101
  "menu.manage": "Open session manager…",
130
102
  "footer.aria": "Session manager",
131
103
  "footer.label": "Sessions",
@@ -134,65 +106,145 @@ window.__ModuleLoader__.load({
134
106
  "panel.filter.active": "Active",
135
107
  "panel.filter.archived": "Archived",
136
108
  "panel.empty": "No sessions",
137
- "panel.empty.archived": "Archive is empty",
109
+ "panel.empty.archived": "No archived sessions",
138
110
  "panel.archived": "Archived",
139
111
  "panel.running": "Running",
140
112
  "panel.current": "Current",
113
+ "panel.session.new": "New session",
114
+ "panel.close": "Close",
141
115
  "row.open": "Open",
142
116
  "row.archive": "Archive",
143
117
  "row.unarchive": "Unarchive",
144
- "row.delete": "Delete",
118
+ "row.move": "Move to workspace",
119
+ "row.delete": "Delete session",
120
+ "row.migrate": "Migrate preset",
121
+ "migrate.title": "Migrate Agent preset",
122
+ "migrate.current": "Current preset",
123
+ "migrate.target": "Target preset",
124
+ "migrate.noPresets": "No target presets are available",
125
+ "migrate.same": "Choose a target preset different from the current preset",
126
+ "migrate.confirm": "Migrate",
127
+ "migrate.completed": "Agent preset migrated to {preset}",
145
128
  "confirm.delete.title": "Delete session",
146
129
  "confirm.delete.desc": "Session \"{title}\" will be permanently deleted, including all its messages and files on disk. This cannot be undone.",
147
130
  "confirm.delete.running": "This session is running; deleting will interrupt it immediately.",
148
131
  "confirm.delete.confirm": "Delete",
132
+ "confirm.move.title": "Move session to workspace",
133
+ "confirm.move.desc": "Move session \"{title}\" to the target workspace.",
134
+ "confirm.move.running": "This session is running; moving will interrupt and close it; you can reopen it from the target workspace to continue.",
135
+ "confirm.move.empty": "No other workspaces to move to.",
136
+ "confirm.move.select": "Select target workspace",
137
+ "confirm.move.current": "Current workspace: {name}",
138
+ "confirm.move.confirm": "Move",
149
139
  "confirm.cancel": "Cancel",
150
140
  "busy.processing": "Working…",
151
- "error.operation": "Operation failed: {message}"
141
+ "error.operation": "Operation failed: {message}",
142
+ "workspace.default": "Default workspace",
143
+ "workspace.sessions": "{count} sessions"
152
144
  };
153
145
 
154
- // ---------------------------------------------------------------------
155
- // Shared panel-open store (footer owns the modal; header opens it)
156
- // ---------------------------------------------------------------------
157
- const panelStore = {
158
- open: false,
159
- listeners: new Set(),
160
- getSnapshot: () => panelStore.open,
161
- subscribe: (fn) => {
162
- panelStore.listeners.add(fn);
163
- return () => panelStore.listeners.delete(fn);
164
- },
165
- set: (open) => {
166
- if (panelStore.open === open) return;
167
- panelStore.open = open;
168
- for (const fn of [...panelStore.listeners]) fn();
169
- }
170
- };
146
+ const css = [
147
+ ".sm-header{display:flex;align-items:center;gap:6px}",
148
+ ".sm-headerBtn{box-sizing:border-box;min-height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;align-items:center;gap:4px;padding:3px 10px;font-size:12px;line-height:18px;display:inline-flex;white-space:nowrap}",
149
+ ".sm-headerBtn:hover:not(:disabled),.sm-headerBtn:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}",
150
+ ".sm-headerBtn:disabled{opacity:.5;cursor:not-allowed}",
151
+ ".sm-headerBtn svg{flex:none}",
152
+ ".sm-headerDanger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}",
153
+ ".sm-headerDanger:hover:not(:disabled){background:rgba(217,78,68,.1)}",
154
+ ".sm-busy{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:28px;margin-left:4px}",
155
+ ".sm-footer{display:flex}",
156
+ ".sm-footerBtn{min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;align-items:center;gap:6px;padding:3px 8px;font-size:12px;line-height:18px;display:inline-flex}",
157
+ ".sm-footerBtn:hover,.sm-footerBtn:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
158
+ ".sm-footerBtn svg{flex:none}",
159
+ ".sm-panel{display:flex;flex-direction:column;gap:10px;min-height:280px;max-height:min(70vh,640px);min-width:480px}",
160
+ ".sm-filter{display:flex;gap:4px;flex-wrap:wrap}",
161
+ ".sm-filterBtn{cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 12px;font-size:12px;line-height:18px}",
162
+ ".sm-filterBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
163
+ ".sm-filterBtn.sm-on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2);border-color:transparent}",
164
+ ".sm-list{display:flex;flex-direction:column;gap:4px;min-height:0;overflow:auto;flex:1}",
165
+ ".sm-row{box-sizing:border-box;display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}",
166
+ ".sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
167
+ ".sm-rowCurrent{border-color:var(--dsw-alias-accent-primary,var(--dsw-alias-label-secondary))}",
168
+ ".sm-rowMain{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1 1 280px}",
169
+ ".sm-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;display:flex;align-items:center;gap:6px;min-width:0}",
170
+ ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}",
171
+ ".sm-rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}",
172
+ ".sm-rowActions{display:flex;gap:4px;flex:none;flex-wrap:wrap;justify-content:flex-end}",
173
+ ".sm-badge{flex:none;border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}",
174
+ ".sm-badgeArchived{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2)}",
175
+ ".sm-badgeRunning{color:var(--dsw-alias-state-warning-primary,#d97706);background:rgba(217,119,6,.12)}",
176
+ ".sm-badgeCurrent{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2)}",
177
+ ".sm-danger{color:var(--dsw-alias-state-error-primary) !important;border-color:var(--dsw-alias-state-error-primary) !important}",
178
+ ".sm-dangerText{color:var(--dsw-alias-state-error-primary) !important}",
179
+ ".sm-warn{display:flex;align-items:flex-start;gap:8px;color:var(--dsw-alias-label-secondary);background:rgba(217,119,6,.08);border:1px solid rgba(217,119,6,.25);border-radius:8px;padding:8px 10px;margin-top:4px}",
180
+ ".sm-warn svg{flex:none;margin-top:1px}",
181
+ ".sm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;min-height:0;margin-top:4px}",
182
+ ".sm-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;width:100%}",
183
+ ".sm-footError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;margin-right:auto}",
184
+ ".sm-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;text-align:center;padding:24px 0}",
185
+ ".sm-moveSection{display:flex;flex-direction:column;gap:8px}",
186
+ ".sm-moveLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}",
187
+ ".sm-moveList{display:flex;flex-direction:column;gap:4px;max-height:200px;overflow:auto}",
188
+ ".sm-moveItem{box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;transition:all .15s}",
189
+ ".sm-moveItem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
190
+ ".sm-moveItemSelected{border-color:var(--dsw-alias-accent-primary);background:var(--dsw-alias-accent-primary-bg)}",
191
+ ".sm-moveItemName{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px}",
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{background:var(--dsw-alias-state-error-secondary);color:#fff;border-color:var(--dsw-alias-state-error-primary)}.sm-header{display:inline-flex;align-items:center;gap:6px}.sm-headerBtnActive{background:var(--dsw-alias-fill-l3);color:var(--dsw-alias-label-primary)}",
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:center;gap:8px;min-width:0;flex:1 1 auto;overflow:hidden}.sm-panelDialog .sm-rowTitle{min-width:0;flex:1 1 auto;display:flex;align-items:center;gap:6px;font-size:12px;line-height:16px;overflow:hidden}.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{text-overflow:ellipsis;white-space:nowrap;overflow:hidden;flex:1 1 auto}.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)}",
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
+ ].join("");
171
196
 
172
- // ---------------------------------------------------------------------
173
- // Host API
174
- // ---------------------------------------------------------------------
175
- async function callApi(path, payload) {
176
- const response = await fetch(API + path, {
177
- method: "POST",
178
- headers: { "content-type": "application/json" },
179
- body: JSON.stringify(payload || {})
180
- });
181
- let data;
182
- try {
183
- data = await response.json();
184
- } catch {
185
- data = { ok: false, error: "HTTP " + response.status };
186
- }
187
- return data;
197
+ const CSS_ID = "dsh-session-manager/session-manager";
198
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_ID) + "]") === null) {
199
+ const tag = document.createElement("style");
200
+ tag.dataset.plugin = "dsh-session-manager";
201
+ tag.dataset.pluginCss = CSS_ID;
202
+ tag.textContent = css;
203
+ document.head.appendChild(tag);
188
204
  }
189
205
 
190
- // ---------------------------------------------------------------------
191
- // Small helpers
192
- // ---------------------------------------------------------------------
193
- const h = (type, props, ...children) => React.createElement(type, props, ...children);
194
206
 
195
- function formatRelative(ts, now) {
207
+ // Anchor the session manager panel to the sidebar right edge so it
208
+ // always fits within the sidebar area, regardless of viewport size.
209
+ const SM_PANEL_CSS_ID = "dsh-session-manager/session-manager-anchor";
210
+ const installAnchor = () => {
211
+ if (typeof document === "undefined") return;
212
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(SM_PANEL_CSS_ID) + "]") !== null) return;
213
+ const tag = document.createElement("style");
214
+ tag.dataset.plugin = "dsh-session-manager";
215
+ tag.dataset.pluginCss = SM_PANEL_CSS_ID;
216
+ tag.textContent = ":root{--sm-panel-left:8px}";
217
+ document.head.appendChild(tag);
218
+ const updateLeft = () => {
219
+ let right = 8;
220
+ const candidates = [
221
+ "[data-dsh-sidebar]",
222
+ "[data-testid=sidebar]",
223
+ "[aria-label=会话][role=navigation]",
224
+ "aside",
225
+ ".dsw-sidebar"
226
+ ];
227
+ for (const sel of candidates) {
228
+ const el = document.querySelector(sel);
229
+ if (el !== null) {
230
+ const rect = el.getBoundingClientRect();
231
+ if (rect.right > 100 && rect.right < window.innerWidth - 40) {
232
+ right = Math.round(rect.right) + 8;
233
+ break;
234
+ }
235
+ }
236
+ }
237
+ document.documentElement.style.setProperty("--sm-panel-left", right + "px");
238
+ };
239
+ updateLeft();
240
+ window.addEventListener("resize", updateLeft);
241
+ };
242
+ try { installAnchor(); } catch (_) {}
243
+
244
+ const h = (type, props, ...children) => React.createElement(type, props, ...children);
245
+ const callApi = (path, payload) => fetch(API + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload || {}) }).then((r) => r.json());
246
+ const fetchApi = (path) => fetch(API + path).then((r) => r.json());
247
+ const formatRelative = (ts, now) => {
196
248
  const diff = Math.max(0, now - ts);
197
249
  const s = Math.floor(diff / 1000);
198
250
  if (s < 60) return s + "s";
@@ -205,147 +257,361 @@ window.__ModuleLoader__.load({
205
257
  const mo = Math.floor(d / 30);
206
258
  if (mo < 12) return mo + "mo";
207
259
  return Math.floor(mo / 12) + "y";
208
- }
209
-
210
- function shortPath(cwd) {
260
+ };
261
+ const shortPath = (cwd) => {
211
262
  if (!cwd) return "";
212
- const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
263
+ const parts = cwd.replace(/[\\\/]+$/, "").split(/[\\\/]/);
213
264
  const last = parts[parts.length - 1] || "";
214
- return last.length > 26 ? last.slice(0, 24) + "…" : last;
215
- }
265
+ return last.length > 26 ? last.slice(0, 24) + "\u2026" : last;
266
+ };
216
267
 
217
- // ---------------------------------------------------------------------
218
- // Confirm dialog (delete needs explicit confirmation)
219
- // ---------------------------------------------------------------------
220
- function ConfirmDialog({ open, title, description, warning, confirmLabel, cancelLabel, busy, error, onCancel, onConfirm }) {
221
- return h(P.Modal, {
222
- open,
223
- onClose: () => {
224
- if (!busy) onCancel();
268
+ function ConfirmDialog(props) {
269
+ const open = props.open === true;
270
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
271
+ const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
272
+ const busy = props.busy === true;
273
+ const title = props.title || "";
274
+ const description = props.description || "";
275
+ const warning = props.warning || "";
276
+ const confirmLabel = props.confirmLabel || "确认";
277
+ const cancelLabel = props.cancelLabel || "取消";
278
+ const R = (typeof window !== "undefined") ? window.__smReact : null;
279
+ const createPortal = (R && typeof R.createPortal === "function") ? R.createPortal : null;
280
+ const body = (typeof document === "undefined") ? null : document.body;
281
+ if (!open) return null;
282
+ if (!body) return tree || null;
283
+ const tree = h("div", {
284
+ className: "sm-nativeDialogLayer sm-confirmDialog",
285
+ style: props.anchor && typeof props.anchor.top === "number"
286
+ ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
287
+ : null,
288
+ role: "presentation",
289
+ onKeyDown: (e) => {
290
+ if (e.key === "Escape" && !busy) { e.preventDefault(); onCancel(); }
291
+ else if (e.key === "Enter" && !busy) { e.preventDefault(); onConfirm(); }
225
292
  },
226
- title,
227
- description,
228
- closeLabel: cancelLabel,
229
- children: h("div", { className: "sm-panel" },
230
- warning ? h("div", { className: "sm-warn" },
231
- h(P.IconWarningOutline16, {}),
232
- h("span", null, warning)
233
- ) : null
234
- ),
235
- footer: h("div", { className: "sm-foot" },
236
- h("span", { className: "sm-footError", role: "alert" }, error || null),
237
- h(P.Button, { variant: "ghost", onClick: onCancel, disabled: busy }, cancelLabel),
238
- h(P.Button, { variant: "primary", className: "sm-danger", onClick: onConfirm, disabled: busy }, confirmLabel)
293
+ tabIndex: -1
294
+ },
295
+ h("div", {
296
+ className: "sm-nativeDialogBackdrop",
297
+ onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
298
+ }),
299
+ h("section", {
300
+ className: "sm-nativeDialog",
301
+ role: "dialog",
302
+ "aria-modal": "true",
303
+ "aria-labelledby": "sm-confirm-dialog-title",
304
+ ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
305
+ },
306
+ h("div", { className: "sm-nativeDialogHeader" },
307
+ h("h2", { id: "sm-confirm-dialog-title", className: "sm-nativeDialogTitle" }, title)
308
+ ),
309
+ h("div", { className: "sm-nativeDialogBody" },
310
+ description ? h("div", { className: "sm-nativeDialogDescription" }, description) : null,
311
+ warning ? h("div", { className: "sm-warn" }, h("span", null, warning)) : null
312
+ ),
313
+ h("div", { className: "sm-nativeDialogFooter" },
314
+ h("button", {
315
+ type: "button",
316
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
317
+ disabled: busy,
318
+ onClick: () => { if (!busy) onCancel(); }
319
+ }, cancelLabel),
320
+ h("button", {
321
+ type: "button",
322
+ className: "sm-nativeDialogButton sm-nativeDialogConfirm",
323
+ disabled: busy,
324
+ onClick: () => { if (!busy) onConfirm(); }
325
+ }, confirmLabel)
326
+ )
239
327
  )
240
- });
328
+ );
329
+ if (createPortal) return createPortal(tree, body);
330
+ // Default: render inline. position:fixed escapes the sidebar
331
+ // overflow:hidden ancestor at the DOM/CSS level, so the dialog
332
+ // appears next to the panel. Keeping the dialog in the same
333
+ // React tree as the panel preserves the onClick wiring (cancel /
334
+ // confirm call setConfirmFor, which only works when the click
335
+ // handler lives in the same root).
336
+ return tree;
241
337
  }
242
338
 
243
- // ---------------------------------------------------------------------
244
- // Header actions (per open session): archive toggle + delete, as text
245
- // pills. Uses only primitives proven to render in the shipped shell
246
- // (Modal / IconArchiveOutline20 / IconWarningOutline16); no Menu, no
247
- // uncertain icons — React #130 (undefined element type) killed the old
248
- // icon/menu variant in the real browser.
249
- // ---------------------------------------------------------------------
250
- function HeaderAction({ sessionId, useSessions, useWorkspaces, t, onArchive, onUnarchive, onRemove }) {
251
- const archivedIds = useWorkspaces((s) => s.archivedSessionIds);
252
- const summary = useSessions((s) => s.byId[sessionId]);
253
- const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
254
- const isArchived = archived.has(sessionId);
255
- const [confirmOpen, setConfirmOpen] = useState(false);
256
- const [busy, setBusy] = useState(false);
257
- const [error, setError] = useState(null);
339
+ function MoveDialog(props) {
340
+ const t = typeof props.t === "function" ? props.t : (key) => key;
341
+ const open = props.open === true;
342
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
343
+ const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
344
+ const busy = props.busy === true;
345
+ const title = props.title || t("confirm.move.title");
346
+ const description = props.description || "";
347
+ const currentWorkspaceId = props.currentWorkspaceId || "";
348
+ const confirmLabel = props.confirmLabel || t("confirm.move.confirm");
349
+ const cancelLabel = props.cancelLabel || t("confirm.cancel");
350
+ const error = props.error || "";
351
+ const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
352
+ if (!open) return null;
258
353
 
259
- const run = async (fn) => {
260
- if (busy) return;
261
- setBusy(true);
262
- setError(null);
263
- try {
264
- await fn();
265
- } catch (e) {
266
- setError(e instanceof Error ? e.message : String(e));
267
- } finally {
268
- setBusy(false);
354
+ const list = (Array.isArray(props.workspaces) ? props.workspaces : [])
355
+ .filter((ws) => ws && typeof ws === "object" && typeof ws.id === "string" && ws.id !== currentWorkspaceId);
356
+ const body = h("div", { className: "sm-nativeDialogBody" },
357
+ h("div", { className: "sm-nativeDialogDescription" }, description),
358
+ list.length === 0
359
+ ? h("div", { className: "sm-moveLabel" }, t("confirm.move.empty"))
360
+ : h("div", { className: "sm-moveSection" },
361
+ h("div", { className: "sm-moveLabel" }, t("confirm.move.select")),
362
+ h("div", { className: "sm-moveList", role: "listbox", "aria-label": t("confirm.move.select") },
363
+ list.map((ws) => h("button", {
364
+ key: ws.id,
365
+ type: "button",
366
+ role: "option",
367
+ "aria-selected": selectedWorkspaceId === ws.id,
368
+ className: "sm-moveItem" + (selectedWorkspaceId === ws.id ? " sm-moveItemSelected" : ""),
369
+ disabled: busy,
370
+ onClick: () => setSelectedWorkspaceId(ws.id)
371
+ },
372
+ h("span", { className: "sm-moveItemName" }, String(ws.title || ws.name || ws.id)),
373
+ h("span", { className: "sm-moveItemMeta" }, ws.path ? String(ws.path) : "")
374
+ ))
375
+ )
376
+ ),
377
+ error ? h("div", { className: "sm-error", role: "alert" }, String(error)) : null
378
+ );
379
+ // Single cancel button (footer) + ESC handler. No "x" close button so the
380
+ // user only sees one cancel entry point.
381
+ const onKeyDown = (e) => {
382
+ if (e.key === "Escape" && !busy) {
383
+ e.preventDefault();
384
+ onCancel();
385
+ } else if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
386
+ e.preventDefault();
387
+ onConfirm(selectedWorkspaceId);
269
388
  }
270
389
  };
271
-
272
- return h("div", { className: "sm-header" },
273
- h("button", {
274
- type: "button",
275
- className: "sm-headerBtn",
276
- disabled: busy,
277
- title: t(isArchived ? "menu.unarchive" : "menu.archive"),
278
- onClick: (e) => {
279
- e.stopPropagation();
280
- run(() => isArchived ? onUnarchive(sessionId) : onArchive(sessionId));
281
- }
390
+ return h("div", {
391
+ className: "sm-nativeDialogLayer sm-confirmDialog",
392
+ style: props.anchor && typeof props.anchor.top === "number"
393
+ ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
394
+ : null,
395
+ role: "presentation",
396
+ onKeyDown,
397
+ tabIndex: -1
398
+ },
399
+ h("div", {
400
+ className: "sm-nativeDialogBackdrop",
401
+ onMouseDown: (e) => { if (e.target === e.currentTarget && !busy) onCancel(); }
402
+ }),
403
+ h("section", {
404
+ className: "sm-nativeDialog",
405
+ role: "dialog",
406
+ "aria-modal": "true",
407
+ "aria-labelledby": "sm-move-dialog-title",
408
+ ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
282
409
  },
283
- h(P.IconArchiveOutline20, { size: 14 }),
284
- h("span", null, t(isArchived ? "menu.unarchive" : "menu.archive"))
285
- ),
286
- h("button", {
287
- type: "button",
288
- className: "sm-headerBtn sm-headerDanger",
289
- disabled: busy,
290
- title: t("menu.delete"),
291
- onClick: (e) => {
292
- e.stopPropagation();
293
- setConfirmOpen(true);
410
+ h("div", { className: "sm-nativeDialogHeader" },
411
+ h("h2", { id: "sm-move-dialog-title", className: "sm-nativeDialogTitle" }, title)
412
+ ),
413
+ body,
414
+ h("div", { className: "sm-nativeDialogFooter" },
415
+ h("button", {
416
+ type: "button",
417
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
418
+ disabled: busy,
419
+ onClick: onCancel
420
+ }, cancelLabel),
421
+ h("button", {
422
+ type: "button",
423
+ className: "sm-nativeDialogButton sm-nativeDialogConfirm",
424
+ disabled: busy || !selectedWorkspaceId || list.length === 0,
425
+ onClick: () => { if (!busy && selectedWorkspaceId) onConfirm(selectedWorkspaceId); }
426
+ }, confirmLabel)
427
+ )
428
+ )
429
+ );
430
+ }
431
+
432
+ function MigratePresetDialog(props) {
433
+ const t = typeof props.t === "function" ? props.t : (key) => key;
434
+ const open = props.open === true;
435
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
436
+ const onRun = typeof props.onRun === "function" ? props.onRun : () => {};
437
+ const busy = props.busy === true;
438
+ const sessionId = props.sessionId || "";
439
+ const fallbackPreset = props.currentPreset || "";
440
+ const [scan, setScan] = useState(null);
441
+ const [target, setTarget] = useState("");
442
+ useEffect(() => {
443
+ if (!open || !sessionId) return;
444
+ let alive = true;
445
+ setScan(null);
446
+ setTarget("");
447
+ fetchApi("/preset-scan?sessionId=" + encodeURIComponent(sessionId)).then((d) => {
448
+ if (!alive) return;
449
+ if (d && d.ok) {
450
+ const result = d.result || {};
451
+ const rows = Array.isArray(result.rows)
452
+ ? result.rows
453
+ : (result.rows && Array.isArray(result.rows.rows) ? result.rows.rows : []);
454
+ const availablePresets = Array.isArray(result.availablePresets)
455
+ ? result.availablePresets
456
+ : (result.rows && Array.isArray(result.rows.availablePresets) ? result.rows.availablePresets : []);
457
+ const row = rows.find((item) => item && item.sessionId === sessionId) || null;
458
+ setScan({ ok: true, row, availablePresets });
459
+ } else {
460
+ setScan({ ok: false, error: (d && d.error) || "scan failed" });
294
461
  }
462
+ }).catch((error) => {
463
+ if (alive) setScan({ ok: false, error: error instanceof Error ? error.message : String(error) });
464
+ });
465
+ return () => { alive = false; };
466
+ }, [open, sessionId]);
467
+ if (!open) return null;
468
+
469
+ const currentPreset = (scan && scan.ok && scan.row && (scan.row.finalPreset || scan.row.headerPreset)) || fallbackPreset || "—";
470
+ const presets = scan && scan.ok
471
+ ? Array.from(new Set((scan.availablePresets || []).filter((preset) => typeof preset === "string" && preset.length > 0))).sort()
472
+ : [];
473
+ const targets = presets.filter((preset) => preset !== currentPreset);
474
+ const ready = target !== "" && target !== currentPreset;
475
+ const body = h("div", { className: "sm-migratePanel" },
476
+ scan === null
477
+ ? h("div", { className: "sm-empty" }, t("busy.processing"))
478
+ : !scan.ok
479
+ ? h("div", { className: "sm-error", role: "alert" }, scan.error || "scan failed")
480
+ : h(React.Fragment, null,
481
+ h("div", { className: "sm-migrateRow" },
482
+ h("label", null, t("migrate.current")),
483
+ h("div", { className: "sm-migrateCurrent" }, currentPreset)
484
+ ),
485
+ h("div", { className: "sm-migrateRow" },
486
+ h("label", null, t("migrate.target")),
487
+ targets.length === 0
488
+ ? h("div", { className: "sm-migrateEmpty" }, t("migrate.noPresets"))
489
+ : h("div", { className: "sm-migrateChips" }, targets.map((preset) => h("button", {
490
+ key: preset,
491
+ type: "button",
492
+ className: "sm-migrateChip" + (target === preset ? " sm-migrateChipOn" : ""),
493
+ disabled: busy,
494
+ onClick: () => setTarget(preset)
495
+ }, preset)))
496
+ ),
497
+ target === currentPreset ? h("div", { className: "sm-migrateWarn" }, t("migrate.same")) : null
498
+ )
499
+ );
500
+ return h("div", {
501
+ className: "sm-nativeDialogLayer sm-confirmDialog sm-migrateConfirmLayer",
502
+ role: "presentation",
503
+ onKeyDown: (event) => { if (event.key === "Escape" && !busy) { event.preventDefault(); onCancel(); } }
504
+ },
505
+ h("div", {
506
+ className: "sm-nativeDialogBackdrop",
507
+ onMouseDown: (event) => { if (event.target === event.currentTarget && !busy) onCancel(); }
508
+ }),
509
+ h("section", {
510
+ className: "sm-nativeDialog sm-migrateDialog",
511
+ role: "dialog",
512
+ "aria-modal": "true",
513
+ "aria-labelledby": "sm-migrate-dialog-title"
295
514
  },
296
- h("span", null, t("menu.delete"))
297
- ),
298
- busy ? h("span", { className: "sm-busy" }, t("busy.processing")) : null,
299
- h(ConfirmDialog, {
300
- open: confirmOpen,
301
- title: t("confirm.delete.title"),
302
- description: t("confirm.delete.desc", { title: summary?.displayTitle ?? sessionId }),
303
- warning: summary?.running ? t("confirm.delete.running") : null,
304
- confirmLabel: t("confirm.delete.confirm"),
305
- cancelLabel: t("confirm.cancel"),
306
- busy,
307
- error: error ? t("error.operation", { message: error }) : null,
308
- onCancel: () => setConfirmOpen(false),
309
- onConfirm: () => run(async () => {
310
- await onRemove(sessionId);
311
- setConfirmOpen(false);
312
- })
313
- })
515
+ h("div", { className: "sm-nativeDialogHeader" },
516
+ h("h2", { id: "sm-migrate-dialog-title", className: "sm-nativeDialogTitle" }, t("migrate.title"))
517
+ ),
518
+ body,
519
+ h("div", { className: "sm-nativeDialogFooter" },
520
+ h("button", {
521
+ type: "button",
522
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
523
+ disabled: busy,
524
+ onClick: onCancel
525
+ }, t("confirm.cancel")),
526
+ h("button", {
527
+ type: "button",
528
+ className: "sm-nativeDialogButton sm-nativeDialogConfirm",
529
+ disabled: busy || !ready,
530
+ onClick: () => { if (ready) onRun(target); }
531
+ }, busy ? t("busy.processing") : t("migrate.confirm"))
532
+ )
533
+ )
314
534
  );
315
535
  }
316
536
 
317
- // ---------------------------------------------------------------------
318
- // Session manager panel (full management surface)
319
- // ---------------------------------------------------------------------
320
- function SessionManagerPanel({ open, onClose, useSessions, useWorkspaces, t, onOpen, onArchive, onUnarchive, onRemove }) {
537
+ const panelStore = {
538
+ open: false,
539
+ listeners: new Set(),
540
+ getSnapshot() { return this.open; },
541
+ subscribe(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); },
542
+ set(v) { if (this.open === v) return; this.open = v; for (const fn of [...this.listeners]) fn(); },
543
+ // Pending action queued by the title-bar buttons (e.g. 移动至工作区).
544
+ // The SessionManagerPanel consumes this on mount so it can pre-open
545
+ // the matching inner dialog (move / delete / migrate) next to itself.
546
+ pendingMove: null,
547
+ pendingListeners: new Set(),
548
+ _emitPending() { for (const fn of [...this.pendingListeners]) fn(); },
549
+ subscribePending(cb) { this.pendingListeners.add(cb); return () => this.pendingListeners.delete(cb); },
550
+ setPendingMove(v) { this.pendingMove = v; this._emitPending(); },
551
+ consumePendingMove() { const v = this.pendingMove; this.pendingMove = null; return v; }
552
+ };
553
+
554
+ function SessionManagerPanel(props) {
555
+ const t = props.t;
556
+ const open = props.open;
557
+ const onClose = props.onClose;
558
+ const useSessions = props.useSessions;
559
+ const useWorkspaces = props.useWorkspaces;
560
+ const onOpen = props.onOpen;
561
+ const onArchive = props.onArchive;
562
+ const onUnarchive = props.onUnarchive;
563
+ const onRemove = props.onRemove;
564
+ const onMove = props.onMove;
565
+ const onMigrate = props.onMigrate;
321
566
  const list = useSessions((s) => s);
322
567
  const archivedIds = useWorkspaces((s) => s.archivedSessionIds);
323
568
  const [filter, setFilter] = useState("all");
324
- const [confirm, setConfirm] = useState(null);
325
569
  const [busyId, setBusyId] = useState(null);
326
570
  const [error, setError] = useState(null);
327
571
  const [now, setNow] = useState(() => Date.now());
328
-
572
+ const [moveFor, setMoveFor] = useState(null);
573
+ const [confirmFor, setConfirmFor] = useState(null);
574
+ const [workspaces, setWorkspaces] = useState([]);
575
+ const [migrating, setMigrating] = useState(false);
576
+ const [migrateFor, setMigrateFor] = useState(null);
329
577
  useEffect(() => {
330
578
  const timer = setInterval(() => setNow(Date.now()), 30000);
331
579
  return () => clearInterval(timer);
332
580
  }, []);
333
-
581
+ useEffect(() => {
582
+ if (!open) return;
583
+ let alive = true;
584
+ fetchApi("/workspaces").then((data) => {
585
+ if (alive && data.ok) setWorkspaces(data.result.workspaces || []);
586
+ });
587
+ return () => { alive = false; };
588
+ }, [open]);
589
+ // Drain any pending action queued by a header button. The panel is
590
+ // the single owner of every inner dialog, so opening it with a
591
+ // pending action pre-populates the corresponding dialog state.
592
+ useEffect(() => {
593
+ if (!open) return;
594
+ const pending = panelStore.consumePendingMove();
595
+ if (pending && pending.sessionId) {
596
+ setMoveFor({ id: pending.sessionId, displayTitle: pending.displayTitle || pending.sessionId, workspaceId: pending.workspaceId || "" });
597
+ }
598
+ }, [open]);
599
+ useEffect(() => {
600
+ if (moveFor) {
601
+ fetchApi("/workspaces").then((data) => {
602
+ if (data.ok) setWorkspaces(data.result.workspaces || []);
603
+ });
604
+ }
605
+ }, [moveFor]);
334
606
  const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
335
-
336
607
  const rows = useMemo(() => {
337
- // Hide subagent sessions and blank workspace placeholders (the
338
- // "New Session" seat DSH regenerates per workspace on reconnect) —
339
- // mirror the sidebar's sessionVisible rule: blank rows show only
340
- // when they are the current session.
341
608
  const all = list.ids
342
609
  .map((id) => list.byId[id])
343
- .filter((s) => s !== void 0 && s.origin !== "subagent" && (!s.blank || s.id === list.current));
610
+ .filter((s) => s !== undefined && s.origin !== "subagent" && (!s.blank || s.id === list.current));
344
611
  if (filter === "active") return all.filter((s) => !archived.has(s.id));
345
612
  if (filter === "archived") return all.filter((s) => archived.has(s.id));
346
613
  return all;
347
614
  }, [list, archived, filter]);
348
-
349
615
  const runRow = async (sessionId, fn) => {
350
616
  if (busyId !== null) return;
351
617
  setBusyId(sessionId);
@@ -358,139 +624,344 @@ window.__ModuleLoader__.load({
358
624
  setBusyId(null);
359
625
  }
360
626
  };
361
-
362
627
  const filterBtn = (id, label) => h("button", {
363
628
  type: "button",
364
629
  key: id,
365
630
  className: "sm-filterBtn" + (filter === id ? " sm-on" : ""),
366
631
  onClick: () => setFilter(id)
367
632
  }, label);
368
-
369
- return h(P.Modal, {
370
- open,
371
- onClose,
372
- title: t("panel.title"),
373
- closeLabel: t("confirm.cancel"),
374
- contentClassName: "sm-panel",
375
- children: [
376
- h("div", { className: "sm-filter", key: "filter" },
377
- filterBtn("all", t("panel.filter.all")),
378
- filterBtn("active", t("panel.filter.active")),
379
- filterBtn("archived", t("panel.filter.archived"))
633
+ const renderRow = (s) => {
634
+ const isArchived = archived.has(s.id);
635
+ const isCurrent = s.id === list.current;
636
+ const busy = busyId === s.id;
637
+ return h("div", {
638
+ key: s.id,
639
+ className: "sm-row" + (isCurrent ? " sm-rowCurrent" : "")
640
+ },
641
+ h("div", { className: "sm-rowMain" },
642
+ h("div", { className: "sm-rowTitle" },
643
+ h("span", null, s.blank ? t("panel.session.new") : (s.displayTitle || s.id)),
644
+ isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
645
+ s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
646
+ isCurrent ? h("span", { className: "sm-badge sm-badgeCurrent" }, t("panel.current")) : null
647
+ ),
648
+ h("div", { className: "sm-rowMeta" }, shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now))
380
649
  ),
381
- error ? h("div", { className: "sm-error", role: "alert", key: "error" },
382
- t("error.operation", { message: error })
383
- ) : null,
384
- rows.length === 0
385
- ? h("div", { className: "sm-empty", key: "empty" },
386
- filter === "archived" ? t("panel.empty.archived") : t("panel.empty")
387
- )
388
- : h("div", { className: "sm-list", key: "list" },
389
- rows.map((s) => {
390
- const isArchived = archived.has(s.id);
391
- const isCurrent = s.id === list.current;
392
- const busy = busyId === s.id;
393
- return h("div", {
394
- key: s.id,
395
- className: "sm-row" + (isCurrent ? " sm-rowCurrent" : "")
396
- },
397
- h("div", { className: "sm-rowMain" },
398
- h("div", { className: "sm-rowTitle" },
399
- h("span", null, s.displayTitle || s.id),
400
- isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
401
- s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
402
- isCurrent ? h("span", { className: "sm-badge sm-badgeCurrent" }, t("panel.current")) : null
403
- ),
404
- h("div", { className: "sm-rowMeta" },
405
- shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now)
406
- )
407
- ),
408
- h("div", { className: "sm-rowActions" },
409
- h(P.Button, { variant: "ghost", size: "sm", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
410
- h(P.Button, {
411
- variant: "ghost",
412
- size: "sm",
413
- disabled: busy,
414
- onClick: () => runRow(s.id, () => isArchived ? onUnarchive(s.id) : onArchive(s.id))
415
- }, t(isArchived ? "row.unarchive" : "row.archive")),
416
- h(P.Button, {
417
- variant: "ghost",
418
- size: "sm",
419
- className: "sm-dangerText",
420
- disabled: busy,
421
- onClick: () => setConfirm(s)
422
- }, t("row.delete"))
423
- )
424
- );
425
- })
426
- )
427
- ],
428
- footer: h(ConfirmDialog, {
429
- open: confirm !== null,
430
- title: t("confirm.delete.title"),
431
- description: t("confirm.delete.desc", { title: confirm?.displayTitle ?? confirm?.id ?? "" }),
432
- warning: confirm?.running ? t("confirm.delete.running") : null,
433
- confirmLabel: t("confirm.delete.confirm"),
434
- cancelLabel: t("confirm.cancel"),
435
- busy: busyId !== null,
436
- error: null,
437
- onCancel: () => setConfirm(null),
438
- onConfirm: () => runRow(confirm.id, async () => {
439
- await onRemove(confirm.id);
440
- setConfirm(null);
441
- })
442
- })
650
+ h("div", { className: "sm-rowActions" },
651
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
652
+ 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")),
653
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => setMoveFor(s) }, t("row.move")),
654
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => setMigrateFor(s) }, t("row.migrate")),
655
+ h("button", { type: "button", className: "sm-rowBtn sm-rowBtnDanger", disabled: busy, onClick: () => setConfirmFor(s) }, t("row.delete"))
656
+ )
657
+ );
658
+ };
659
+ const rowsList = rows.length === 0
660
+ ? h("div", { className: "sm-empty" }, filter === "archived" ? t("panel.empty.archived") : t("panel.empty"))
661
+ : h("div", { className: "sm-list" }, rows.map(renderRow));
662
+ const onPanelClose = () => {
663
+ if (busyId !== null) return;
664
+ setConfirmFor(null);
665
+ setMoveFor(null);
666
+ setMigrateFor(null);
667
+ onClose();
668
+ };
669
+ // Single cancel button in the panel footer (no native "x").
670
+ const onPanelKey = (e) => {
671
+ if (e.key === "Escape" && busyId === null) {
672
+ if (confirmFor || moveFor || migrateFor) return;
673
+ e.preventDefault();
674
+ onPanelClose();
675
+ }
676
+ };
677
+ const panelModal = h("div", {
678
+ className: "sm-nativeDialogLayer",
679
+ role: "presentation",
680
+ onKeyDown: onPanelKey,
681
+ tabIndex: -1
682
+ },
683
+ h("div", {
684
+ className: "sm-nativeDialogBackdrop",
685
+ onMouseDown: (e) => { if (e.target === e.currentTarget && busyId === null) onPanelClose(); }
686
+ }),
687
+ h("section", {
688
+ className: "sm-nativeDialog sm-panelDialog",
689
+ role: "dialog",
690
+ "aria-modal": "true",
691
+ "aria-labelledby": "sm-panel-dialog-title",
692
+ ref: (el) => { if (el && typeof el.focus === "function") el.focus(); }
693
+ },
694
+ h("div", { className: "sm-nativeDialogHeader" },
695
+ h("h2", { id: "sm-panel-dialog-title", className: "sm-nativeDialogTitle" }, t("panel.title")),
696
+ h("button", {
697
+ type: "button",
698
+ className: "sm-panelHeaderClose",
699
+ disabled: busyId !== null || migrating,
700
+ onClick: onPanelClose
701
+ }, t("panel.close"))
702
+ ),
703
+ h("div", { className: "sm-panel" },
704
+ h("div", { className: "sm-filter" },
705
+ filterBtn("all", t("panel.filter.all")),
706
+ filterBtn("active", t("panel.filter.active")),
707
+ filterBtn("archived", t("panel.filter.archived"))
708
+ ),
709
+ error ? h("div", { className: "sm-error", role: "alert" }, t("error.operation", { message: error })) : null,
710
+ rowsList
711
+ )
712
+ )
713
+ );
714
+ const deleteDialog = confirmFor === null ? null : h(ConfirmDialog, {
715
+ open: true,
716
+ onClose: () => { if (busyId === null) setConfirmFor(null); },
717
+ title: t("confirm.delete.title"),
718
+ description: t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }),
719
+ warning: confirmFor.running ? t("confirm.delete.running") : null,
720
+ confirmLabel: t("confirm.delete.confirm"),
721
+ cancelLabel: t("confirm.cancel"),
722
+ busy: busyId !== null,
723
+ onCancel: () => { if (busyId === null) setConfirmFor(null); },
724
+ onConfirm: () => {
725
+ const target = confirmFor;
726
+ setConfirmFor(null);
727
+ runRow(target.id, () => onRemove(target.id));
728
+ }
443
729
  });
730
+ const moveDialog = moveFor === null ? null : h(MoveDialog, {
731
+ open: true,
732
+ onClose: () => { if (busyId === null) setMoveFor(null); },
733
+ title: t("confirm.move.title"),
734
+ description: t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }),
735
+ sessionTitle: moveFor.displayTitle || moveFor.id,
736
+ currentWorkspaceId: moveFor.workspaceId,
737
+ workspaces: workspaces,
738
+ confirmLabel: t("confirm.move.confirm"),
739
+ cancelLabel: t("confirm.cancel"),
740
+ busy: busyId !== null,
741
+ onCancel: () => { if (busyId === null) setMoveFor(null); },
742
+ onConfirm: (targetWorkspaceId) => {
743
+ const target = moveFor;
744
+ setMoveFor(null);
745
+ runRow(target.id, () => onMove(target.id, targetWorkspaceId));
746
+ }
747
+ });
748
+ const migrateDialog = migrateFor === null ? null : h(MigratePresetDialog, {
749
+ open: true,
750
+ t: t,
751
+ sessionId: migrateFor.id,
752
+ currentPreset: migrateFor.agentPreset || "",
753
+ onCancel: () => { if (!migrating) setMigrateFor(null); },
754
+ busy: migrating,
755
+ onRun: async (targetPreset) => {
756
+ const target = migrateFor;
757
+ setMigrating(true);
758
+ setError(null);
759
+ try {
760
+ await onMigrate(target.id, targetPreset);
761
+ setMigrateFor(null);
762
+ try { window.alert(t("migrate.completed", { preset: targetPreset })); } catch (_) {}
763
+ } catch (error) {
764
+ setError(error instanceof Error ? error.message : String(error));
765
+ setMigrateFor(null);
766
+ } finally {
767
+ setMigrating(false);
768
+ }
769
+ }
770
+ });
771
+ // Render the panel AND the inner dialog as siblings so React keeps
772
+ // both mounted. CSS z-index keeps the right ordering: the inner
773
+ // dialog (.sm-confirmDialog / .sm-moveDialog / .sm-migrateDialog)
774
+ // carries z-index 10000 and therefore sits ON TOP OF the panel
775
+ // (z-index 9999). Clicking inside the panel that the inner dialog
776
+ // blocks is not possible because the inner backdrop covers the
777
+ // panel area entirely (the inner modal is centered).
778
+ const primary = moveDialog || deleteDialog || migrateDialog;
779
+ return h(React.Fragment, null, panelModal, primary);
444
780
  }
445
781
 
446
- // ---------------------------------------------------------------------
447
- // Footer action (owns the management panel modal)
448
- // ---------------------------------------------------------------------
449
- function FooterAction({ wide, useSessions, useWorkspaces, t, onOpen, onArchive, onUnarchive, onRemove, onOpenPanel }) {
450
- const open = useSyncExternalStore(panelStore.subscribe, panelStore.getSnapshot);
451
- return h("div", { className: "sm-footer" },
452
- h("button", {
453
- type: "button",
454
- className: "sm-footerBtn",
455
- "aria-label": t("footer.aria"),
456
- onClick: () => onOpenPanel()
457
- },
458
- h(P.IconArchiveOutline20, { size: 14 }),
459
- wide ? h("span", null, t("footer.label")) : null
782
+ function HeaderAction(props) {
783
+ const t = props.t;
784
+ const sessionId = props.sessionId;
785
+ // Slot framework spreads inject-factory return into top-level props,
786
+ // so the action callbacks live directly on props (not under .actions).
787
+ const onArchive = props.onArchive, onUnarchive = props.onUnarchive;
788
+ const onMove = props.onMove, onRemove = props.onRemove;
789
+ // Archive state lives in the workspace registry's archivedSessionIds
790
+ // (exposed through the global standard kit's useWorkspaces selector).
791
+ const useWorkspaces = props.useWorkspaces;
792
+ const archivedIds = (useWorkspaces ? useWorkspaces((s) => s.archivedSessionIds) : null) || [];
793
+ const isArchived = archivedIds.includes(sessionId);
794
+
795
+ const [confirmFor, setConfirmFor] = useState(null);
796
+ const [confirmAnchor, setConfirmAnchor] = useState(null);
797
+ const [moveFor, setMoveFor] = useState(null);
798
+ const [moveAnchor, setMoveAnchor] = useState(null);
799
+ const [workspaces, setWorkspaces] = useState([]);
800
+ const runWithAlert = (label, fn) => {
801
+ return Promise.resolve()
802
+ .then(() => fn())
803
+ .catch((e) => {
804
+ try {
805
+ const msg = e instanceof Error ? e.message : String(e);
806
+ window.alert(((t && t("error.operation")) || "操作失败:{message}").replace("{message}", msg));
807
+ } catch (_) {}
808
+ });
809
+ };
810
+ useEffect(() => {
811
+ if (!moveFor) { setWorkspaces([]); return; }
812
+ let alive = true;
813
+ fetchApi("/workspaces").then((d) => {
814
+ if (alive && d && d.ok) setWorkspaces(d.result.workspaces || []);
815
+ });
816
+ return () => { alive = false; };
817
+ }, [moveFor ? (moveFor.id || "x") : null]);
818
+ const anchorFor = (el) => {
819
+ if (!el || typeof el.getBoundingClientRect !== "function") return null;
820
+ const r = el.getBoundingClientRect();
821
+ return {
822
+ top: Math.round(r.top + r.height + 6),
823
+ left: Math.max(8, Math.round(r.right - 320))
824
+ };
825
+ };
826
+ const archiveLabelKey = isArchived ? "row.unarchive" : "row.archive";
827
+ return h(React.Fragment, null,
828
+ h("div", { className: "sm-header" },
829
+ h("button", {
830
+ type: "button",
831
+ className: "sm-headerBtn" + (isArchived ? " sm-headerBtnActive" : ""),
832
+ "aria-label": t ? t(archiveLabelKey) : (isArchived ? "移出归档" : "归档会话"),
833
+ onClick: () => runWithAlert("archive", () =>
834
+ isArchived ? onUnarchive(sessionId) : onArchive(sessionId)
835
+ )
836
+ }, isArchived ? (t ? t("row.unarchive") : "移出归档") : (t ? t("row.archive") : "归档")),
837
+ h("button", {
838
+ type: "button",
839
+ className: "sm-headerBtn" + (moveFor ? " sm-headerBtnActive" : ""),
840
+ "aria-label": t ? t("row.move") : "移动到工作区",
841
+ onClick: (e) => {
842
+ e.stopPropagation();
843
+ setConfirmFor(null);
844
+ setConfirmAnchor(null);
845
+ if (moveFor && moveFor.id === sessionId) {
846
+ setMoveFor(null);
847
+ setMoveAnchor(null);
848
+ } else {
849
+ setMoveAnchor(anchorFor(e.currentTarget));
850
+ setMoveFor({ id: sessionId, displayTitle: sessionId, workspaceId: "" });
851
+ }
852
+ }
853
+ }, t ? t("row.move") : "移动"),
854
+ h("button", {
855
+ type: "button",
856
+ className: "sm-headerBtn sm-headerBtnDanger" + (confirmFor ? " sm-headerBtnActive" : ""),
857
+ "aria-label": t ? t("row.delete") : "删除会话",
858
+ onClick: (e) => {
859
+ e.stopPropagation();
860
+ setMoveFor(null);
861
+ setMoveAnchor(null);
862
+ if (confirmFor && confirmFor.id === sessionId) {
863
+ setConfirmFor(null);
864
+ setConfirmAnchor(null);
865
+ } else {
866
+ setConfirmAnchor(anchorFor(e.currentTarget));
867
+ setConfirmFor({ id: sessionId, displayTitle: sessionId });
868
+ }
869
+ }
870
+ }, t ? t("row.delete") : "删除")
460
871
  ),
461
- h(SessionManagerPanel, {
462
- open,
463
- onClose: () => panelStore.set(false),
464
- useSessions,
465
- useWorkspaces,
466
- t,
467
- onOpen,
468
- onArchive,
469
- onUnarchive,
470
- onRemove
872
+ confirmFor === null ? null : h(ConfirmDialog, {
873
+ open: true,
874
+ anchor: confirmAnchor,
875
+ onClose: () => setConfirmFor(null),
876
+ onCancel: () => setConfirmFor(null),
877
+ onConfirm: () => {
878
+ const target = confirmFor;
879
+ setConfirmFor(null);
880
+ runWithAlert("delete", () => onRemove(target.id));
881
+ },
882
+ title: t ? t("confirm.delete.title") : "删除会话",
883
+ description: (confirmFor && (t ? t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }) : ("将删除 " + (confirmFor.displayTitle || confirmFor.id)))) || "",
884
+ confirmLabel: t ? t("confirm.delete.confirm") : "确认删除",
885
+ cancelLabel: t ? t("confirm.cancel") : "取消",
886
+ busy: false
887
+ }),
888
+ moveFor === null ? null : h(MoveDialog, {
889
+ open: true,
890
+ anchor: moveAnchor,
891
+ t: t,
892
+ workspaces: workspaces,
893
+ currentWorkspaceId: moveFor.workspaceId || "",
894
+ onClose: () => { setMoveFor(null); setMoveAnchor(null); },
895
+ onCancel: () => { setMoveFor(null); setMoveAnchor(null); },
896
+ title: t ? t("confirm.move.title") : "移动会话到工作区",
897
+ description: (moveFor && (t ? t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }) : "")) || "",
898
+ sessionTitle: moveFor.displayTitle || moveFor.id,
899
+ confirmLabel: t ? t("confirm.move.confirm") : "确认移动",
900
+ cancelLabel: t ? t("confirm.cancel") : "取消",
901
+ busy: false,
902
+ onConfirm: (targetWorkspaceId) => {
903
+ const target = moveFor;
904
+ setMoveFor(null);
905
+ setMoveAnchor(null);
906
+ runWithAlert("move", () => onMove(target.id, targetWorkspaceId));
907
+ }
471
908
  })
472
909
  );
473
910
  }
474
911
 
475
- // ---------------------------------------------------------------------
476
- // Plugin entry
477
- // ---------------------------------------------------------------------
478
- const inject = ["slots", "sessions", "workspaces", "locale"];
912
+ class SafePanel extends React.Component {
913
+ constructor(p) { super(p); this.state = { failed: false }; }
914
+ getDerivedStateFromError() { return { failed: true }; }
915
+ componentDidCatch(error) { try { console.error("[dsh-session-manager] SessionManagerPanel crashed:", error); } catch (_) {} }
916
+ render() {
917
+ if (this.state.failed) return null;
918
+ return h(SessionManagerPanel, {
919
+ open: true,
920
+ onClose: () => { try { this.props.panelStore.set(false); } catch (_) {} },
921
+ useSessions: this.props.useSessions,
922
+ useWorkspaces: this.props.useWorkspaces,
923
+ t: this.props.t,
924
+ onOpen: (id) => { try { this.props.panelStore.set(false); this.props.props.onOpen(id); } catch (_) {} },
925
+ onArchive: this.props.props.onArchive,
926
+ onUnarchive: this.props.props.onUnarchive,
927
+ onRemove: this.props.props.onRemove,
928
+ onMove: this.props.props.onMove,
929
+ onMigrate: this.props.props.onMigrate
930
+ });
931
+ }
932
+ }
933
+
934
+ function FooterAction(props) {
935
+ const t = props.t;
936
+ const onOpenPanel = props.onOpenPanel;
937
+ const open = useSyncExternalStore(
938
+ (cb) => panelStore.subscribe(cb),
939
+ () => panelStore.getSnapshot(),
940
+ () => panelStore.getSnapshot()
941
+ );
942
+ return h("div", { className: "sm-footer" },
943
+ h("button", { type: "button", className: "sm-footerBtn", "aria-label": t("footer.aria"), onClick: () => onOpenPanel() },
944
+ h(P.IconArchiveOutline20, { size: 14 }),
945
+ h("span", null, t("footer.label"))
946
+ ),
947
+ // The panel is mounted ONLY while the user has the modal open. When
948
+ // The wrap guards against an in-panel crash by returning null on its
949
+ // own error boundary rather than letting it bubble up to the slot.
950
+ open ? h(SafePanel, {
951
+ panelStore: panelStore,
952
+ props: props,
953
+ t: t,
954
+ useSessions: props.useSessions,
955
+ useWorkspaces: props.useWorkspaces,
956
+ }) : null
957
+ );
958
+ }
479
959
 
480
960
  function apply(ctx) {
481
961
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-manager: dictionaries");
482
-
483
- // NOTE: the slot renderer SPREADS the inject factory's return value into
484
- // the component's props as flat members (see renderEntry: ...injected).
485
- // So these are delivered as onOpen/onArchive/... props, not under an
486
- // `actions` namespace.
487
962
  const actions = () => ({
488
- onOpen: (sessionId) => {
489
- ctx.sessions.open(sessionId);
490
- },
491
- onArchive: async (sessionId) => {
492
- await ctx.workspaces.archiveSession(sessionId);
493
- },
963
+ onOpen: (sessionId) => { ctx.sessions.open(sessionId); },
964
+ onArchive: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId); },
494
965
  onUnarchive: async (sessionId) => {
495
966
  const response = await callApi("/unarchive", { sessionId });
496
967
  if (!response.ok) throw new Error(response.error || "unarchive failed");
@@ -501,11 +972,46 @@ window.__ModuleLoader__.load({
501
972
  const current = ctx.sessions.list.getSnapshot().current;
502
973
  if (current === sessionId) ctx.sessions.clear();
503
974
  },
504
- onOpenPanel: () => {
505
- panelStore.set(true);
506
- }
975
+ onMove: async (sessionId, targetWorkspaceId) => {
976
+ const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
977
+ const response = await callApi("/move", { sessionId, targetWorkspaceId });
978
+ if (!response.ok) throw new Error(response.error || "move failed");
979
+ const refreshMovedSession = async () => {
980
+ await Promise.allSettled([
981
+ typeof ctx.workspaces?.refresh === "function" ? ctx.workspaces.refresh() : Promise.resolve(),
982
+ ctx.sessions.refresh()
983
+ ]);
984
+ if (wasCurrent && ctx.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
985
+ ctx.sessions.open(sessionId);
986
+ }
987
+ };
988
+ // /move retires a live session, so host/session-removed can arrive on
989
+ // the streaming channel slightly after the HTTP success response.
990
+ // Pull both projections now and once more after that frame to make the
991
+ // destination row appear without a browser refresh.
992
+ await refreshMovedSession();
993
+ setTimeout(() => { void refreshMovedSession(); }, 250);
994
+ setTimeout(() => { void refreshMovedSession(); }, 900);
995
+ },
996
+ onMigrate: async (sessionId, toPreset) => {
997
+ const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
998
+ const response = await callApi("/preset-migrate", { sessionId, toPreset });
999
+ if (!response.ok) throw new Error(response.error || "preset migration failed");
1000
+ ctx.sessions.noteAgentPreset(sessionId, toPreset);
1001
+ await ctx.sessions.refresh();
1002
+ if (wasCurrent) ctx.sessions.open(sessionId);
1003
+ // Host/session-removed and the HTTP response travel on different
1004
+ // streams. One delayed baseline closes that ordering race without a
1005
+ // page refresh and keeps the migrated conversation selectable.
1006
+ setTimeout(() => {
1007
+ void ctx.sessions.refresh().then(() => {
1008
+ ctx.sessions.noteAgentPreset(sessionId, toPreset);
1009
+ if (wasCurrent) ctx.sessions.open(sessionId);
1010
+ }).catch(() => {});
1011
+ }, 250);
1012
+ },
1013
+ onOpenPanel: () => panelStore.set(true)
507
1014
  });
508
-
509
1015
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
510
1016
  name: "conversation.session.header.actions",
511
1017
  id: "session-manager-header",
@@ -513,7 +1019,6 @@ window.__ModuleLoader__.load({
513
1019
  locale: NS,
514
1020
  inject: () => actions()
515
1021
  }, HeaderAction));
516
-
517
1022
  ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
518
1023
  name: "sidebar.footer.action",
519
1024
  id: "session-manager-footer",
@@ -523,8 +1028,10 @@ window.__ModuleLoader__.load({
523
1028
  }, FooterAction));
524
1029
  }
525
1030
 
1031
+ const inject = ["slots", "sessions", "workspaces", "locale"];
1032
+
526
1033
  exports.apply = apply;
527
1034
  exports.inject = inject;
528
1035
  return module.exports;
529
1036
  }
530
- });
1037
+ });