dsh-session-manager 0.4.11 → 0.5.2

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,1196 +1,2772 @@
1
- /**
2
- * @dsh-session-manager — Client half.
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.
10
- */
11
- window.__ModuleLoader__.load({
12
- id: "dsh-session-manager",
13
- factory: (require) => {
14
- var module = { exports: {} };
15
- var exports = module.exports;
16
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
-
18
- const React = require("react");
19
- const { useState, useEffect, useMemo, useRef, useSyncExternalStore } = React;
20
- const P = require("@deepseek-ai/dsh-client-ui-primitives");
21
- if (typeof window !== "undefined") {
22
- window.__smReact = React;
23
- window.__smReactDOM = React.createPortal ? React : null;
24
- // Tiny imperative portal helper: takes a React element + a host
25
- // DOM node, creates a React root on the host (lazily), and re-renders
26
- // the element into it on each call. Used by ConfirmDialog to escape
27
- // the sidebar overflow:hidden ancestor.
28
- try {
29
- const RDOM = require("react-dom/client");
30
- if (RDOM && typeof RDOM.createRoot === "function") {
31
- window.__smRenderInto = (tree, host) => {
32
- if (!tree || !host) return;
33
- try {
34
- if (!host.__smRoot) host.__smRoot = RDOM.createRoot(host);
35
- host.__smRoot.render(tree);
36
- } catch (_) { /* ignore render failure */ }
37
- };
38
- }
39
- } catch (_) { /* react-dom/client not available */ }
40
- }
41
-
42
- const NS = "session-manager";
43
- const API = "/session-manager/api";
44
-
45
- const zh = {
46
- "header.aria": "会话操作",
47
- "menu.open": "打开",
48
- "menu.archive": "归档会话",
49
- "menu.unarchive": "移出归档",
50
- "menu.delete": "删除会话…",
51
- "menu.move": "移动到工作区…",
52
- "menu.manage": "打开会话管理…",
53
- "footer.aria": "会话管理",
54
- "footer.label": "会话管理",
55
- "panel.title": "会话管理",
56
- "panel.filter.all": "全部",
57
- "panel.filter.active": "未归档",
58
- "panel.filter.archived": "已归档",
59
- "panel.empty": "暂无会话",
60
- "panel.empty.archived": "归档为空",
61
- "panel.archived": "已归档",
62
- "panel.running": "运行中",
63
- "panel.current": "当前",
64
- "panel.session.new": "新建会话",
65
- "panel.close": "关闭",
66
- "row.open": "打开",
67
- "row.archive": "归档",
68
- "row.unarchive": "移出归档",
69
- "row.move": "移动至工作区",
70
- "row.delete": "删除会话",
71
- "row.migrate": "迁移预设",
72
- "migrate.title": "迁移 Agent 预设",
73
- "migrate.current": "当前预设",
74
- "migrate.target": "目标预设",
75
- "migrate.noPresets": "没有可用的目标预设",
76
- "migrate.same": "请选择与当前预设不同的目标预设",
77
- "migrate.confirm": "确认迁移",
78
- "migrate.completed": "Agent 预设已迁移为 {preset}",
79
- "confirm.delete.title": "删除会话",
80
- "confirm.delete.desc": "会话「{title}」将被永久删除,包括其全部消息记录与磁盘文件,此操作不可撤销。",
81
- "confirm.delete.running": "该会话正在运行,删除将立即中断它。",
82
- "confirm.delete.confirm": "确认删除",
83
- "confirm.move.title": "移动会话到工作区",
84
- "confirm.move.desc": "将会话「{title}」移动到目标工作区。",
85
- "confirm.move.running": "该会话正在运行,移动会中断并关闭它;之后可在目标工作区重新打开继续。",
86
- "confirm.move.empty": "没有可移动到的其他工作区。",
87
- "confirm.move.select": "选择目标工作区",
88
- "confirm.move.current": "当前工作区:{name}",
89
- "confirm.move.confirm": "确认移动",
90
- "confirm.cancel": "取消",
91
- "busy.processing": "处理中…",
92
- "error.operation": "操作失败:{message}",
93
- "workspace.default": "默认工作区",
94
- "workspace.sessions": "{count} 个会话"
95
- };
96
- const en = {
97
- "header.aria": "Session actions",
98
- "menu.open": "Open",
99
- "menu.archive": "Archive session",
100
- "menu.unarchive": "Unarchive session",
101
- "menu.delete": "Delete session…",
102
- "menu.move": "Move to workspace…",
103
- "menu.manage": "Open session manager…",
104
- "footer.aria": "Session manager",
105
- "footer.label": "Sessions",
106
- "panel.title": "Session manager",
107
- "panel.filter.all": "All",
108
- "panel.filter.active": "Active",
109
- "panel.filter.archived": "Archived",
110
- "panel.empty": "No sessions",
111
- "panel.empty.archived": "No archived sessions",
112
- "panel.archived": "Archived",
113
- "panel.running": "Running",
114
- "panel.current": "Current",
115
- "panel.session.new": "New session",
116
- "panel.close": "Close",
117
- "row.open": "Open",
118
- "row.archive": "Archive",
119
- "row.unarchive": "Unarchive",
120
- "row.move": "Move to workspace",
121
- "row.delete": "Delete session",
122
- "row.migrate": "Migrate preset",
123
- "migrate.title": "Migrate Agent preset",
124
- "migrate.current": "Current preset",
125
- "migrate.target": "Target preset",
126
- "migrate.noPresets": "No target presets are available",
127
- "migrate.same": "Choose a target preset different from the current preset",
128
- "migrate.confirm": "Migrate",
129
- "migrate.completed": "Agent preset migrated to {preset}",
130
- "confirm.delete.title": "Delete session",
131
- "confirm.delete.desc": "Session \"{title}\" will be permanently deleted, including all its messages and files on disk. This cannot be undone.",
132
- "confirm.delete.running": "This session is running; deleting will interrupt it immediately.",
133
- "confirm.delete.confirm": "Delete",
134
- "confirm.move.title": "Move session to workspace",
135
- "confirm.move.desc": "Move session \"{title}\" to the target workspace.",
136
- "confirm.move.running": "This session is running; moving will interrupt and close it; you can reopen it from the target workspace to continue.",
137
- "confirm.move.empty": "No other workspaces to move to.",
138
- "confirm.move.select": "Select target workspace",
139
- "confirm.move.current": "Current workspace: {name}",
140
- "confirm.move.confirm": "Move",
141
- "confirm.cancel": "Cancel",
142
- "busy.processing": "Working…",
143
- "error.operation": "Operation failed: {message}",
144
- "workspace.default": "Default workspace",
145
- "workspace.sessions": "{count} sessions"
146
- };
147
-
148
- const css = [
149
- ".sm-header{display:flex;align-items:center;gap:6px}",
150
- ".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}",
151
- ".sm-headerBtn:hover:not(:disabled),.sm-headerBtn:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}",
152
- ".sm-headerBtn:disabled{opacity:.5;cursor:not-allowed}",
153
- ".sm-headerBtn svg{flex:none}",
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: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)}",
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)}",
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
-
196
- // Theme colors are scoped to the plugin dialogs. DSH exposes its
197
- // actual theme through `body[data-ds-dark-theme]` and color-scheme;
198
- // keeping these values local avoids changing DSH's global variables.
199
- "[data-sm-theme=light] .sm-panelDialog,[data-sm-theme=light] .sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog,[data-sm-theme=light] .sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog{--dsw-alias-surface-l1:#fff;--dsw-alias-label-primary:#1f1f1f;--dsw-alias-label-secondary:#555;--dsw-alias-label-tertiary:#888;--dsw-alias-border-l1:#d4d4d8;--dsw-alias-border-l2:#e5e5e5;--dsw-alias-fill-l2:#f5f5f5;--dsw-alias-interactive-bg-hover:#eee;--dsw-alias-state-error-primary:#d92d20;--dsw-alias-state-warning-primary:#d97706;--dsw-alias-accent-primary:#356ae6;--dsw-alias-accent-primary-bg:rgba(53,106,230,.12);background:#fff!important;background-color:#fff!important;color:#1f1f1f!important;border-color:#e5e5e5!important}",
200
- "[data-sm-theme=dark] .sm-panelDialog,[data-sm-theme=dark] .sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog,[data-sm-theme=dark] .sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog{--dsw-alias-surface-l1:#1f1f23;--dsw-alias-label-primary:#ececec;--dsw-alias-label-secondary:#b3b3b3;--dsw-alias-label-tertiary:#999;--dsw-alias-border-l1:#555;--dsw-alias-border-l2:#3a3a40;--dsw-alias-fill-l2:#2a2a30;--dsw-alias-interactive-bg-hover:rgba(255,255,255,.06);--dsw-alias-state-error-primary:#f47171;--dsw-alias-state-warning-primary:#f0a040;--dsw-alias-accent-primary:#5d8aff;--dsw-alias-accent-primary-bg:rgba(93,138,255,.18);background:#1f1f23!important;background-color:#1f1f23!important;color:#ececec!important;border-color:#3a3a40!important}",
201
- "[data-sm-theme=light] .sm-panelDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-panelDialog .sm-nativeDialogFooter,[data-sm-theme=light] .sm-confirmDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-confirmDialog .sm-nativeDialogFooter,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogBody,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogFooter{background:#fff!important;background-color:#fff!important;border-color:#e5e5e5!important}",
202
- "[data-sm-theme=dark] .sm-panelDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-panelDialog .sm-nativeDialogFooter,[data-sm-theme=dark] .sm-confirmDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-confirmDialog .sm-nativeDialogFooter,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogBody,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogFooter{background:#1f1f23!important;background-color:#1f1f23!important;border-color:#3a3a40!important}",
203
- ].join("");
204
-
205
- // Auto-follow DSH's theme. We detect DSH's current theme from
206
- // body[data-ds-dark-theme] / color-scheme / data-theme / class tokens,
207
- // mirror it onto <html data-sm-theme>, and apply inline dialog colors.
208
- // The dialogs use that attribute via the scoped CSS rules above, and
209
- // inline styles guarantee an opaque background even when DSH's tokens
210
- // resolve to `unset` / `transparent`.
211
- const SM_THEME_ATTR = "data-sm-theme";
212
- function smReadDshTheme() {
213
- try {
214
- const html = document.documentElement;
215
- const body = document.body;
216
- if (body && body.hasAttribute("data-ds-dark-theme")) return "dark";
217
- const elements = [html, body].filter(Boolean);
218
- const dark = /\b(dark|dim)\b/i;
219
- const light = /\b(light|bright)\b/i;
220
- for (const el of elements) {
221
- const value = ["theme", "data-theme", "data-bs-theme", "color-scheme", "data-color-mode", "data-mode"]
222
- .map((name) => String(el.getAttribute(name) || ""))
223
- .join(" ");
224
- if (dark.test(value)) return "dark";
225
- if (light.test(value)) return "light";
226
- }
227
- for (const el of elements) {
228
- const scheme = String(getComputedStyle(el).colorScheme || "").toLowerCase();
229
- if (scheme.includes("dark")) return "dark";
230
- if (scheme.includes("light")) return "light";
231
- }
232
- const bg = body ? getComputedStyle(body).backgroundColor : "";
233
- const match = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
234
- if (match) {
235
- const luminance = (Number(match[1]) * 299 + Number(match[2]) * 587 + Number(match[3]) * 114) / 1000;
236
- return luminance < 128 ? "dark" : "light";
237
- }
238
- } catch (_) { /* ignore */ }
239
- return "light";
240
- }
241
- function smApplyInlineTheme(theme) {
242
- try {
243
- const values = theme === "dark"
244
- ? { background: "#1f1f23", color: "#ececec", border: "#3a3a40" }
245
- : { background: "#fff", color: "#1f1f1f", border: "#e5e5e5" };
246
- const roots = document.querySelectorAll(
247
- ".sm-panelDialog," +
248
- ".sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog," +
249
- ".sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog"
250
- );
251
- for (const el of roots) {
252
- el.style.setProperty("background", values.background, "important");
253
- el.style.setProperty("background-color", values.background, "important");
254
- el.style.setProperty("color", values.color, "important");
255
- el.style.setProperty("border-color", values.border, "important");
256
- }
257
- } catch (_) { /* ignore */ }
258
- }
259
- function smApplyTheme() {
260
- try {
261
- const theme = smReadDshTheme();
262
- document.documentElement.setAttribute(SM_THEME_ATTR, theme);
263
- smApplyInlineTheme(theme);
264
- } catch (_) { /* ignore */ }
265
- }
266
- smApplyTheme();
267
- if (typeof MutationObserver === "function") {
268
- try {
269
- const observer = new MutationObserver(() => smApplyTheme());
270
- const options = { attributes: true, attributeFilter: ["class", "theme", "data-theme", "data-bs-theme", "data-ds-dark-theme", "data-color-mode", "data-mode", "color-scheme", "style"] };
271
- if (document.documentElement) observer.observe(document.documentElement, options);
272
- if (document.body) observer.observe(document.body, options);
273
- observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
274
- } catch (_) { /* ignore */ }
275
- }
276
-
277
- const CSS_ID = "dsh-session-manager/session-manager";
278
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_ID) + "]") === null) {
279
- const tag = document.createElement("style");
280
- tag.dataset.plugin = "dsh-session-manager";
281
- tag.dataset.pluginCss = CSS_ID;
282
- tag.textContent = css;
283
- document.head.appendChild(tag);
284
- }
285
-
286
-
287
- // Anchor the session manager panel to the sidebar right edge so it
288
- // always fits within the sidebar area, regardless of viewport size.
289
- const SM_PANEL_CSS_ID = "dsh-session-manager/session-manager-anchor";
290
- const installAnchor = () => {
291
- if (typeof document === "undefined") return;
292
- if (document.querySelector("style[data-plugin-css=" + JSON.stringify(SM_PANEL_CSS_ID) + "]") !== null) return;
293
- const tag = document.createElement("style");
294
- tag.dataset.plugin = "dsh-session-manager";
295
- tag.dataset.pluginCss = SM_PANEL_CSS_ID;
296
- tag.textContent = ":root{--sm-panel-left:8px}";
297
- document.head.appendChild(tag);
298
- const updateLeft = () => {
299
- let right = 8;
300
- const candidates = [
301
- "[data-dsh-sidebar]",
302
- "[data-testid=sidebar]",
303
- "[aria-label=会话][role=navigation]",
304
- "aside",
305
- ".dsw-sidebar"
306
- ];
307
- for (const sel of candidates) {
308
- const el = document.querySelector(sel);
309
- if (el !== null) {
310
- const rect = el.getBoundingClientRect();
311
- if (rect.right > 100 && rect.right < window.innerWidth - 40) {
312
- right = Math.round(rect.right) + 8;
313
- break;
314
- }
315
- }
316
- }
317
- document.documentElement.style.setProperty("--sm-panel-left", right + "px");
318
- };
319
- updateLeft();
320
- window.addEventListener("resize", updateLeft);
321
- };
322
- try { installAnchor(); } catch (_) {}
323
-
324
- const h = (type, props, ...children) => React.createElement(type, props, ...children);
325
- const callApi = (path, payload) => fetch(API + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload || {}) }).then((r) => r.json());
326
- const fetchApi = (path) => fetch(API + path).then((r) => r.json());
327
- const formatRelative = (ts, now) => {
328
- const diff = Math.max(0, now - ts);
329
- const s = Math.floor(diff / 1000);
330
- if (s < 60) return s + "s";
331
- const m = Math.floor(s / 60);
332
- if (m < 60) return m + "m";
333
- const hr = Math.floor(m / 60);
334
- if (hr < 24) return hr + "h";
335
- const d = Math.floor(hr / 24);
336
- if (d < 30) return d + "d";
337
- const mo = Math.floor(d / 30);
338
- if (mo < 12) return mo + "mo";
339
- return Math.floor(mo / 12) + "y";
340
- };
341
- const shortPath = (cwd) => {
342
- if (!cwd) return "";
343
- const parts = cwd.replace(/[\\\/]+$/, "").split(/[\\\/]/);
344
- const last = parts[parts.length - 1] || "";
345
- return last.length > 26 ? last.slice(0, 24) + "\u2026" : last;
346
- };
347
-
348
- function ConfirmDialog(props) {
349
- const open = props.open === true;
350
- const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
351
- const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
352
- const busy = props.busy === true;
353
- const title = props.title || "";
354
- const description = props.description || "";
355
- const warning = props.warning || "";
356
- const confirmLabel = props.confirmLabel || "确认";
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]);
385
- const R = (typeof window !== "undefined") ? window.__smReact : null;
386
- const createPortal = (R && typeof R.createPortal === "function") ? R.createPortal : null;
387
- const body = (typeof document === "undefined") ? null : document.body;
388
- if (!open) return null;
389
- if (!body) return tree || null;
390
- const tree = 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
- },
397
-
398
- h("section", {
399
- className: "sm-nativeDialog",
400
- role: "dialog",
401
- "aria-modal": "true",
402
- "aria-labelledby": "sm-confirm-dialog-title"
403
- },
404
- h("div", { className: "sm-nativeDialogHeader" },
405
- h("h2", { id: "sm-confirm-dialog-title", className: "sm-nativeDialogTitle" }, title)
406
- ),
407
- h("div", { className: "sm-nativeDialogBody" },
408
- description ? h("div", { className: "sm-nativeDialogDescription" }, description) : null,
409
- warning ? h("div", { className: "sm-warn" }, h("span", null, warning)) : null
410
- ),
411
- h("div", { className: "sm-nativeDialogFooter" },
412
- h("button", {
413
- type: "button",
414
- className: "sm-nativeDialogButton sm-nativeDialogCancel",
415
- disabled: busy,
416
- onClick: () => { if (!busy) onCancel(); }
417
- }, cancelLabel),
418
- h("button", {
419
- type: "button",
420
- className: "sm-nativeDialogButton sm-nativeDialogConfirm",
421
- disabled: busy,
422
- onClick: () => { if (!busy) onConfirm(); }
423
- }, confirmLabel)
424
- )
425
- )
426
- );
427
- if (createPortal) return createPortal(tree, body);
428
- // Default: render inline. position:fixed escapes the sidebar
429
- // overflow:hidden ancestor at the DOM/CSS level, so the dialog
430
- // appears next to the panel. Keeping the dialog in the same
431
- // React tree as the panel preserves the onClick wiring (cancel /
432
- // confirm call setConfirmFor, which only works when the click
433
- // handler lives in the same root).
434
- return tree;
435
- }
436
-
437
- function MoveDialog(props) {
438
- const t = typeof props.t === "function" ? props.t : (key) => key;
439
- const open = props.open === true;
440
- const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
441
- const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
442
- const busy = props.busy === true;
443
- const title = props.title || t("confirm.move.title");
444
- const description = props.description || "";
445
- const currentWorkspaceId = props.currentWorkspaceId || "";
446
- const confirmLabel = props.confirmLabel || t("confirm.move.confirm");
447
- const cancelLabel = props.cancelLabel || t("confirm.cancel");
448
- const error = props.error || "";
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]);
477
- if (!open) return null;
478
-
479
- const list = (Array.isArray(props.workspaces) ? props.workspaces : [])
480
- .filter((ws) => ws && typeof ws === "object" && typeof ws.id === "string" && ws.id !== currentWorkspaceId);
481
- const body = h("div", { className: "sm-nativeDialogBody" },
482
- h("div", { className: "sm-nativeDialogDescription" }, description),
483
- list.length === 0
484
- ? h("div", { className: "sm-moveLabel" }, t("confirm.move.empty"))
485
- : h("div", { className: "sm-moveSection" },
486
- h("div", { className: "sm-moveLabel" }, t("confirm.move.select")),
487
- h("div", { className: "sm-moveList", role: "listbox", "aria-label": t("confirm.move.select") },
488
- list.map((ws) => h("button", {
489
- key: ws.id,
490
- type: "button",
491
- role: "option",
492
- "aria-selected": selectedWorkspaceId === ws.id,
493
- className: "sm-moveItem" + (selectedWorkspaceId === ws.id ? " sm-moveItemSelected" : ""),
494
- disabled: busy,
495
- onClick: () => setSelectedWorkspaceId(ws.id)
496
- },
497
- h("span", { className: "sm-moveItemName" }, String(ws.title || ws.name || ws.id)),
498
- h("span", { className: "sm-moveItemMeta" }, ws.path ? String(ws.path) : "")
499
- ))
500
- )
501
- ),
502
- error ? h("div", { className: "sm-error", role: "alert" }, String(error)) : null
503
- );
504
- // Single cancel button (footer) + ESC handler. No "x" close button so the
505
- // user only sees one cancel entry point.
506
- const onKeyDown = (e) => {
507
- // Enter only -- Esc moved to a window-level listener (see above).
508
- if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
509
- e.preventDefault();
510
- onConfirm(selectedWorkspaceId);
511
- }
512
- };
513
- return h("div", {
514
- className: "sm-nativeDialogLayer sm-confirmDialog",
515
- style: props.anchor && typeof props.anchor.top === "number"
516
- ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px", zIndex: 10001 }
517
- : null,
518
- role: "presentation",
519
- onKeyDown
520
- },
521
-
522
- h("section", {
523
- className: "sm-nativeDialog",
524
- role: "dialog",
525
- "aria-modal": "true",
526
- "aria-labelledby": "sm-move-dialog-title"
527
- },
528
- h("div", { className: "sm-nativeDialogHeader" },
529
- h("h2", { id: "sm-move-dialog-title", className: "sm-nativeDialogTitle" }, title)
530
- ),
531
- body,
532
- h("div", { className: "sm-nativeDialogFooter" },
533
- h("button", {
534
- type: "button",
535
- className: "sm-nativeDialogButton sm-nativeDialogCancel",
536
- disabled: busy,
537
- onClick: onCancel
538
- }, cancelLabel),
539
- h("button", {
540
- type: "button",
541
- className: "sm-nativeDialogButton sm-nativeDialogConfirm",
542
- disabled: busy || !selectedWorkspaceId || list.length === 0,
543
- onClick: () => { if (!busy && selectedWorkspaceId) onConfirm(selectedWorkspaceId); }
544
- }, confirmLabel)
545
- )
546
- )
547
- );
548
- }
549
-
550
- function MigratePresetDialog(props) {
551
- const t = typeof props.t === "function" ? props.t : (key) => key;
552
- const open = props.open === true;
553
- const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
554
- const onRun = typeof props.onRun === "function" ? props.onRun : () => {};
555
- const busy = props.busy === true;
556
- const sessionId = props.sessionId || "";
557
- const fallbackPreset = props.currentPreset || "";
558
- const [scan, setScan] = useState(null);
559
- const [target, setTarget] = useState("");
560
- useEffect(() => {
561
- if (!open || !sessionId) return;
562
- let alive = true;
563
- setScan(null);
564
- setTarget("");
565
- fetchApi("/preset-scan?sessionId=" + encodeURIComponent(sessionId)).then((d) => {
566
- if (!alive) return;
567
- if (d && d.ok) {
568
- const result = d.result || {};
569
- const rows = Array.isArray(result.rows)
570
- ? result.rows
571
- : (result.rows && Array.isArray(result.rows.rows) ? result.rows.rows : []);
572
- const availablePresets = Array.isArray(result.availablePresets)
573
- ? result.availablePresets
574
- : (result.rows && Array.isArray(result.rows.availablePresets) ? result.rows.availablePresets : []);
575
- const row = rows.find((item) => item && item.sessionId === sessionId) || null;
576
- setScan({ ok: true, row, availablePresets });
577
- } else {
578
- setScan({ ok: false, error: (d && d.error) || "scan failed" });
579
- }
580
- }).catch((error) => {
581
- if (alive) setScan({ ok: false, error: error instanceof Error ? error.message : String(error) });
582
- });
583
- return () => { alive = false; };
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]);
609
- if (!open) return null;
610
-
611
- const currentPreset = (scan && scan.ok && scan.row && (scan.row.finalPreset || scan.row.headerPreset)) || fallbackPreset || "—";
612
- const presets = scan && scan.ok
613
- ? Array.from(new Set((scan.availablePresets || []).filter((preset) => typeof preset === "string" && preset.length > 0))).sort()
614
- : [];
615
- const targets = presets.filter((preset) => preset !== currentPreset);
616
- const ready = target !== "" && target !== currentPreset;
617
- const body = h("div", { className: "sm-migratePanel" },
618
- scan === null
619
- ? h("div", { className: "sm-empty" }, t("busy.processing"))
620
- : !scan.ok
621
- ? h("div", { className: "sm-error", role: "alert" }, scan.error || "scan failed")
622
- : h(React.Fragment, null,
623
- h("div", { className: "sm-migrateRow" },
624
- h("label", null, t("migrate.current")),
625
- h("div", { className: "sm-migrateCurrent" }, currentPreset)
626
- ),
627
- h("div", { className: "sm-migrateRow" },
628
- h("label", null, t("migrate.target")),
629
- targets.length === 0
630
- ? h("div", { className: "sm-migrateEmpty" }, t("migrate.noPresets"))
631
- : h("div", { className: "sm-migrateChips" }, targets.map((preset) => h("button", {
632
- key: preset,
633
- type: "button",
634
- className: "sm-migrateChip" + (target === preset ? " sm-migrateChipOn" : ""),
635
- disabled: busy,
636
- onClick: () => setTarget(preset)
637
- }, preset)))
638
- ),
639
- target === currentPreset ? h("div", { className: "sm-migrateWarn" }, t("migrate.same")) : null
640
- )
641
- );
642
- return h("div", {
643
- className: "sm-nativeDialogLayer sm-confirmDialog sm-migrateConfirmLayer",
644
- role: "presentation"
645
- },
646
-
647
- h("section", {
648
- className: "sm-nativeDialog sm-migrateDialog",
649
- role: "dialog",
650
- "aria-modal": "true",
651
- "aria-labelledby": "sm-migrate-dialog-title"
652
- },
653
- h("div", { className: "sm-nativeDialogHeader" },
654
- h("h2", { id: "sm-migrate-dialog-title", className: "sm-nativeDialogTitle" }, t("migrate.title"))
655
- ),
656
- body,
657
- h("div", { className: "sm-nativeDialogFooter" },
658
- h("button", {
659
- type: "button",
660
- className: "sm-nativeDialogButton sm-nativeDialogCancel",
661
- disabled: busy,
662
- onClick: onCancel
663
- }, t("confirm.cancel")),
664
- h("button", {
665
- type: "button",
666
- className: "sm-nativeDialogButton sm-nativeDialogConfirm",
667
- disabled: busy || !ready,
668
- onClick: () => { if (ready) onRun(target); }
669
- }, busy ? t("busy.processing") : t("migrate.confirm"))
670
- )
671
- )
672
- );
673
- }
674
-
675
- const panelStore = {
676
- open: false,
677
- listeners: new Set(),
678
- getSnapshot() { return this.open; },
679
- subscribe(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); },
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(); },
682
- // Pending action queued by the title-bar buttons (e.g. 移动至工作区).
683
- // The SessionManagerPanel consumes this on mount so it can pre-open
684
- // the matching inner dialog (move / delete / migrate) next to itself.
685
- pendingMove: null,
686
- pendingListeners: new Set(),
687
- _emitPending() { for (const fn of [...this.pendingListeners]) fn(); },
688
- subscribePending(cb) { this.pendingListeners.add(cb); return () => this.pendingListeners.delete(cb); },
689
- setPendingMove(v) { this.pendingMove = v; this._emitPending(); },
690
- consumePendingMove() { const v = this.pendingMove; this.pendingMove = null; return v; }
691
- };
692
-
693
- function SessionManagerPanel(props) {
694
- const t = props.t;
695
- const open = props.open;
696
- const onClose = props.onClose;
697
- const useSessions = props.useSessions;
698
- const useWorkspaces = props.useWorkspaces;
699
- const onOpen = props.onOpen;
700
- const onArchive = props.onArchive;
701
- const onUnarchive = props.onUnarchive;
702
- const onRemove = props.onRemove;
703
- const onMove = props.onMove;
704
- const onMigrate = props.onMigrate;
705
- const list = useSessions((s) => s);
706
- const archivedIds = useWorkspaces((s) => s.archivedSessionIds);
707
- const [filter, setFilter] = useState("all");
708
- const [busyId, setBusyId] = useState(null);
709
- const [error, setError] = useState(null);
710
- const [now, setNow] = useState(() => Date.now());
711
- const [moveFor, setMoveFor] = useState(null);
712
- const [confirmFor, setConfirmFor] = useState(null);
713
- const [workspaces, setWorkspaces] = useState([]);
714
- const [migrating, setMigrating] = useState(false);
715
- const [migrateFor, setMigrateFor] = useState(null);
716
- useEffect(() => {
717
- const timer = setInterval(() => setNow(Date.now()), 30000);
718
- return () => clearInterval(timer);
719
- }, []);
720
- useEffect(() => {
721
- if (!open) return;
722
- let alive = true;
723
- fetchApi("/workspaces").then((data) => {
724
- if (alive && data.ok) setWorkspaces(data.result.workspaces || []);
725
- });
726
- return () => { alive = false; };
727
- }, [open]);
728
- // Drain any pending action queued by a header button. The panel is
729
- // the single owner of every inner dialog, so opening it with a
730
- // pending action pre-populates the corresponding dialog state.
731
- useEffect(() => {
732
- if (!open) return;
733
- const pending = panelStore.consumePendingMove();
734
- if (pending && pending.sessionId) {
735
- setMoveFor({ id: pending.sessionId, displayTitle: pending.displayTitle || pending.sessionId, workspaceId: pending.workspaceId || "" });
736
- }
737
- }, [open]);
738
- useEffect(() => {
739
- if (moveFor) {
740
- fetchApi("/workspaces").then((data) => {
741
- if (data.ok) setWorkspaces(data.result.workspaces || []);
742
- });
743
- }
744
- }, [moveFor]);
745
- const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
746
- const rows = useMemo(() => {
747
- const all = list.ids
748
- .map((id) => list.byId[id])
749
- .filter((s) => s !== undefined && s.origin !== "subagent" && (!s.blank || s.id === list.current));
750
- if (filter === "active") return all.filter((s) => !archived.has(s.id));
751
- if (filter === "archived") return all.filter((s) => archived.has(s.id));
752
- return all;
753
- }, [list, archived, filter]);
754
- const runRow = async (sessionId, fn) => {
755
- if (busyId !== null) return;
756
- setBusyId(sessionId);
757
- setError(null);
758
- try {
759
- await fn();
760
- } catch (e) {
761
- setError(e instanceof Error ? e.message : String(e));
762
- } finally {
763
- setBusyId(null);
764
- }
765
- };
766
- const filterBtn = (id, label) => h("button", {
767
- type: "button",
768
- key: id,
769
- className: "sm-filterBtn" + (filter === id ? " sm-on" : ""),
770
- onClick: () => setFilter(id)
771
- }, label);
772
- const renderRow = (s) => {
773
- const isArchived = archived.has(s.id);
774
- const isCurrent = s.id === list.current;
775
- const busy = busyId === s.id;
776
- return h("div", {
777
- key: s.id,
778
- className: "sm-row" + (isCurrent ? " sm-rowCurrent" : "")
779
- },
780
- h("div", { className: "sm-rowMain" },
781
- h("div", { className: "sm-rowTitle" },
782
- h("span", null, s.blank ? t("panel.session.new") : (s.displayTitle || s.id)),
783
- isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
784
- s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
785
- isCurrent ? h("span", { className: "sm-badge sm-badgeCurrent" }, t("panel.current")) : null
786
- ),
787
- h("div", { className: "sm-rowMeta" }, shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now))
788
- ),
789
- h("div", { className: "sm-rowActions" },
790
- h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
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")),
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"))
795
- )
796
- );
797
- };
798
- const rowsList = rows.length === 0
799
- ? h("div", { className: "sm-empty" }, filter === "archived" ? t("panel.empty.archived") : t("panel.empty"))
800
- : h("div", { className: "sm-list" }, rows.map(renderRow));
801
- const onPanelClose = () => {
802
- if (busyId !== null) return;
803
- setConfirmFor(null);
804
- setMoveFor(null);
805
- setMigrateFor(null);
806
- onClose();
807
- };
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;
826
- e.preventDefault();
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]);
841
- const panelModal = h("div", {
842
- className: "sm-nativeDialogLayer",
843
- role: "presentation"
844
- },
845
- h("section", {
846
- className: "sm-nativeDialog sm-panelDialog",
847
- role: "dialog",
848
- "aria-modal": "true",
849
- "aria-labelledby": "sm-panel-dialog-title"
850
- },
851
- h("div", { className: "sm-nativeDialogHeader" },
852
- h("h2", { id: "sm-panel-dialog-title", className: "sm-nativeDialogTitle" }, t("panel.title")),
853
- h("button", {
854
- type: "button",
855
- className: "sm-panelHeaderClose",
856
- disabled: busyId !== null || migrating,
857
- onClick: onPanelClose
858
- }, t("panel.close"))
859
- ),
860
- h("div", { className: "sm-panel" },
861
- h("div", { className: "sm-filter" },
862
- filterBtn("all", t("panel.filter.all")),
863
- filterBtn("active", t("panel.filter.active")),
864
- filterBtn("archived", t("panel.filter.archived"))
865
- ),
866
- error ? h("div", { className: "sm-error", role: "alert" }, t("error.operation", { message: error })) : null,
867
- rowsList
868
- )
869
- )
870
- );
871
- const deleteDialog = confirmFor === null ? null : h(ConfirmDialog, {
872
- open: true,
873
- onClose: () => { if (busyId === null) setConfirmFor(null); },
874
- title: t("confirm.delete.title"),
875
- description: t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }),
876
- warning: confirmFor.running ? t("confirm.delete.running") : null,
877
- confirmLabel: t("confirm.delete.confirm"),
878
- cancelLabel: t("confirm.cancel"),
879
- busy: busyId !== null,
880
- onCancel: () => { if (busyId === null) setConfirmFor(null); },
881
- onConfirm: () => {
882
- const target = confirmFor;
883
- setConfirmFor(null);
884
- runRow(target.id, () => onRemove(target.id));
885
- }
886
- });
887
- const moveDialog = moveFor === null ? null : h(MoveDialog, {
888
- open: true,
889
- onClose: () => { if (busyId === null) setMoveFor(null); },
890
- title: t("confirm.move.title"),
891
- description: t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }),
892
- sessionTitle: moveFor.displayTitle || moveFor.id,
893
- currentWorkspaceId: moveFor.workspaceId,
894
- workspaces: workspaces,
895
- confirmLabel: t("confirm.move.confirm"),
896
- cancelLabel: t("confirm.cancel"),
897
- busy: busyId !== null,
898
- onCancel: () => { if (busyId === null) setMoveFor(null); },
899
- onConfirm: (targetWorkspaceId) => {
900
- const target = moveFor;
901
- setMoveFor(null);
902
- runRow(target.id, () => onMove(target.id, targetWorkspaceId));
903
- }
904
- });
905
- const migrateDialog = migrateFor === null ? null : h(MigratePresetDialog, {
906
- open: true,
907
- t: t,
908
- sessionId: migrateFor.id,
909
- currentPreset: migrateFor.agentPreset || "",
910
- onCancel: () => { if (!migrating) setMigrateFor(null); },
911
- busy: migrating,
912
- onRun: async (targetPreset) => {
913
- const target = migrateFor;
914
- setMigrating(true);
915
- setError(null);
916
- try {
917
- await onMigrate(target.id, targetPreset);
918
- setMigrateFor(null);
919
- try { window.alert(t("migrate.completed", { preset: targetPreset })); } catch (_) {}
920
- } catch (error) {
921
- setError(error instanceof Error ? error.message : String(error));
922
- setMigrateFor(null);
923
- } finally {
924
- setMigrating(false);
925
- }
926
- }
927
- });
928
- // Render the panel AND the inner dialog as siblings so React keeps
929
- // both mounted. CSS z-index keeps the right ordering: the inner
930
- // dialog (.sm-confirmDialog / .sm-moveDialog / .sm-migrateDialog)
931
- // carries z-index 10000 and therefore sits ON TOP OF the panel
932
- // (z-index 9999). Clicking inside the panel that the inner dialog
933
- // blocks is not possible because the inner backdrop covers the
934
- // panel area entirely (the inner modal is centered).
935
- const primary = moveDialog || deleteDialog || migrateDialog;
936
- return h(React.Fragment, null, panelModal, primary);
937
- }
938
-
939
- function HeaderAction(props) {
940
- const t = props.t;
941
- const sessionId = props.sessionId;
942
- // Slot framework spreads inject-factory return into top-level props,
943
- // so the action callbacks live directly on props (not under .actions).
944
- const onArchive = props.onArchive, onUnarchive = props.onUnarchive;
945
- const onMove = props.onMove, onRemove = props.onRemove;
946
- // Archive state lives in the workspace registry's archivedSessionIds
947
- // (exposed through the global standard kit's useWorkspaces selector).
948
- const useWorkspaces = props.useWorkspaces;
949
- const archivedIds = (useWorkspaces ? useWorkspaces((s) => s.archivedSessionIds) : null) || [];
950
- const isArchived = archivedIds.includes(sessionId);
951
-
952
- const [confirmFor, setConfirmFor] = useState(null);
953
- const [confirmAnchor, setConfirmAnchor] = useState(null);
954
- const [moveFor, setMoveFor] = useState(null);
955
- const [moveAnchor, setMoveAnchor] = useState(null);
956
- const [workspaces, setWorkspaces] = useState([]);
957
- const runWithAlert = (label, fn) => {
958
- return Promise.resolve()
959
- .then(() => fn())
960
- .catch((e) => {
961
- try {
962
- const msg = e instanceof Error ? e.message : String(e);
963
- window.alert(((t && t("error.operation")) || "操作失败:{message}").replace("{message}", msg));
964
- } catch (_) {}
965
- });
966
- };
967
- useEffect(() => {
968
- if (!moveFor) { setWorkspaces([]); return; }
969
- let alive = true;
970
- fetchApi("/workspaces").then((d) => {
971
- if (alive && d && d.ok) setWorkspaces(d.result.workspaces || []);
972
- });
973
- return () => { alive = false; };
974
- }, [moveFor ? (moveFor.id || "x") : null]);
975
- const anchorFor = (el) => {
976
- if (!el || typeof el.getBoundingClientRect !== "function") return null;
977
- const r = el.getBoundingClientRect();
978
- return {
979
- top: Math.round(r.top + r.height + 6),
980
- left: Math.max(8, Math.round(r.right - 320))
981
- };
982
- };
983
- const archiveLabelKey = isArchived ? "row.unarchive" : "row.archive";
984
- return h(React.Fragment, null,
985
- h("div", { className: "sm-header" },
986
- h("button", {
987
- type: "button",
988
- className: "sm-headerBtn" + (isArchived ? " sm-headerBtnActive" : ""),
989
- "aria-label": t ? t(archiveLabelKey) : (isArchived ? "移出归档" : "归档会话"),
990
- onClick: () => runWithAlert("archive", () =>
991
- isArchived ? onUnarchive(sessionId) : onArchive(sessionId)
992
- )
993
- }, isArchived ? (t ? t("row.unarchive") : "移出归档") : (t ? t("row.archive") : "归档")),
994
- h("button", {
995
- type: "button",
996
- className: "sm-headerBtn" + (moveFor ? " sm-headerBtnActive" : ""),
997
- "aria-label": t ? t("row.move") : "移动到工作区",
998
- onClick: (e) => {
999
- e.stopPropagation();
1000
- setConfirmFor(null);
1001
- setConfirmAnchor(null);
1002
- if (moveFor && moveFor.id === sessionId) {
1003
- setMoveFor(null);
1004
- setMoveAnchor(null);
1005
- } else {
1006
- setMoveAnchor(anchorFor(e.currentTarget));
1007
- setMoveFor({ id: sessionId, displayTitle: sessionId, workspaceId: "" });
1008
- }
1009
- }
1010
- }, t ? t("row.move") : "移动"),
1011
- h("button", {
1012
- type: "button",
1013
- className: "sm-headerBtn sm-headerBtnDanger" + (confirmFor ? " sm-headerBtnActive" : ""),
1014
- "aria-label": t ? t("row.delete") : "删除会话",
1015
- onClick: (e) => {
1016
- e.stopPropagation();
1017
- setMoveFor(null);
1018
- setMoveAnchor(null);
1019
- if (confirmFor && confirmFor.id === sessionId) {
1020
- setConfirmFor(null);
1021
- setConfirmAnchor(null);
1022
- } else {
1023
- setConfirmAnchor(anchorFor(e.currentTarget));
1024
- setConfirmFor({ id: sessionId, displayTitle: sessionId });
1025
- }
1026
- }
1027
- }, t ? t("row.delete") : "删除")
1028
- ),
1029
- confirmFor === null ? null : h(ConfirmDialog, {
1030
- open: true,
1031
- anchor: confirmAnchor,
1032
- onClose: () => setConfirmFor(null),
1033
- onCancel: () => setConfirmFor(null),
1034
- onConfirm: () => {
1035
- const target = confirmFor;
1036
- setConfirmFor(null);
1037
- runWithAlert("delete", () => onRemove(target.id));
1038
- },
1039
- title: t ? t("confirm.delete.title") : "删除会话",
1040
- description: (confirmFor && (t ? t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }) : ("将删除 " + (confirmFor.displayTitle || confirmFor.id)))) || "",
1041
- confirmLabel: t ? t("confirm.delete.confirm") : "确认删除",
1042
- cancelLabel: t ? t("confirm.cancel") : "取消",
1043
- busy: false
1044
- }),
1045
- moveFor === null ? null : h(MoveDialog, {
1046
- open: true,
1047
- anchor: moveAnchor,
1048
- t: t,
1049
- workspaces: workspaces,
1050
- currentWorkspaceId: moveFor.workspaceId || "",
1051
- onClose: () => { setMoveFor(null); setMoveAnchor(null); },
1052
- onCancel: () => { setMoveFor(null); setMoveAnchor(null); },
1053
- title: t ? t("confirm.move.title") : "移动会话到工作区",
1054
- description: (moveFor && (t ? t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }) : "")) || "",
1055
- sessionTitle: moveFor.displayTitle || moveFor.id,
1056
- confirmLabel: t ? t("confirm.move.confirm") : "确认移动",
1057
- cancelLabel: t ? t("confirm.cancel") : "取消",
1058
- busy: false,
1059
- onConfirm: (targetWorkspaceId) => {
1060
- const target = moveFor;
1061
- setMoveFor(null);
1062
- setMoveAnchor(null);
1063
- runWithAlert("move", () => onMove(target.id, targetWorkspaceId));
1064
- }
1065
- })
1066
- );
1067
- }
1068
-
1069
- class SafePanel extends React.Component {
1070
- constructor(p) { super(p); this.state = { failed: false }; }
1071
- getDerivedStateFromError() { return { failed: true }; }
1072
- componentDidCatch(error) { try { console.error("[dsh-session-manager] SessionManagerPanel crashed:", error); } catch (_) {} }
1073
- render() {
1074
- if (this.state.failed) return null;
1075
- return h(SessionManagerPanel, {
1076
- open: true,
1077
- onClose: () => { try { this.props.panelStore.set(false); } catch (_) {} },
1078
- useSessions: this.props.useSessions,
1079
- useWorkspaces: this.props.useWorkspaces,
1080
- t: this.props.t,
1081
- onOpen: (id) => { try { this.props.panelStore.set(false); this.props.props.onOpen(id); } catch (_) {} },
1082
- onArchive: this.props.props.onArchive,
1083
- onUnarchive: this.props.props.onUnarchive,
1084
- onRemove: this.props.props.onRemove,
1085
- onMove: this.props.props.onMove,
1086
- onMigrate: this.props.props.onMigrate
1087
- });
1088
- }
1089
- }
1090
-
1091
- function FooterAction(props) {
1092
- const t = props.t;
1093
- const onOpenPanel = props.onOpenPanel;
1094
- const open = useSyncExternalStore(
1095
- (cb) => panelStore.subscribe(cb),
1096
- () => panelStore.getSnapshot(),
1097
- () => panelStore.getSnapshot()
1098
- );
1099
- return h("div", { className: "sm-footer" },
1100
- h("button", { type: "button", className: "sm-footerBtn", "aria-label": t("footer.aria"), onClick: () => onOpenPanel() },
1101
- h(P.IconArchiveOutline20, { size: 14 }),
1102
- h("span", null, t("footer.label"))
1103
- ),
1104
- // The panel is mounted ONLY while the user has the modal open. When
1105
- // The wrap guards against an in-panel crash by returning null on its
1106
- // own error boundary rather than letting it bubble up to the slot.
1107
- open ? h(SafePanel, {
1108
- panelStore: panelStore,
1109
- props: props,
1110
- t: t,
1111
- useSessions: props.useSessions,
1112
- useWorkspaces: props.useWorkspaces,
1113
- }) : null
1114
- );
1115
- }
1116
-
1117
- function apply(ctx) {
1118
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-manager: dictionaries");
1119
- const actions = () => ({
1120
- onOpen: (sessionId) => { ctx.sessions.open(sessionId); },
1121
- onArchive: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId); },
1122
- onUnarchive: async (sessionId) => {
1123
- const response = await callApi("/unarchive", { sessionId });
1124
- if (!response.ok) throw new Error(response.error || "unarchive failed");
1125
- },
1126
- onRemove: async (sessionId) => {
1127
- const response = await callApi("/delete", { sessionId });
1128
- if (!response.ok) throw new Error(response.error || "delete failed");
1129
- const current = ctx.sessions.list.getSnapshot().current;
1130
- if (current === sessionId) ctx.sessions.clear();
1131
- },
1132
- onMove: async (sessionId, targetWorkspaceId) => {
1133
- const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
1134
- const response = await callApi("/move", { sessionId, targetWorkspaceId });
1135
- if (!response.ok) throw new Error(response.error || "move failed");
1136
- const refreshMovedSession = async () => {
1137
- await Promise.allSettled([
1138
- typeof ctx.workspaces?.refresh === "function" ? ctx.workspaces.refresh() : Promise.resolve(),
1139
- ctx.sessions.refresh()
1140
- ]);
1141
- if (wasCurrent && ctx.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
1142
- ctx.sessions.open(sessionId);
1143
- }
1144
- };
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.
1150
- await refreshMovedSession();
1151
- setTimeout(() => { void refreshMovedSession(); }, 250);
1152
- setTimeout(() => { void refreshMovedSession(); }, 900);
1153
- },
1154
- onMigrate: async (sessionId, toPreset) => {
1155
- const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
1156
- const response = await callApi("/preset-migrate", { sessionId, toPreset });
1157
- if (!response.ok) throw new Error(response.error || "preset migration failed");
1158
- // The current DSH client session store derives agentPreset from
1159
- // the refreshed projection; older builds exposed noteAgentPreset,
1160
- // but calling it unconditionally breaks migration on newer builds.
1161
- await ctx.sessions.refresh();
1162
- if (wasCurrent) ctx.sessions.open(sessionId);
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.
1166
- setTimeout(() => {
1167
- void ctx.sessions.refresh().then(() => {
1168
- if (wasCurrent) ctx.sessions.open(sessionId);
1169
- }).catch(() => {});
1170
- }, 250);
1171
- },
1172
- onOpenPanel: () => panelStore.toggle()
1173
- });
1174
- ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1175
- name: "conversation.session.header.actions",
1176
- id: "session-manager-header",
1177
- order: 40,
1178
- locale: NS,
1179
- inject: () => actions()
1180
- }, HeaderAction));
1181
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
1182
- name: "sidebar.footer.action",
1183
- id: "session-manager-footer",
1184
- order: 30,
1185
- locale: NS,
1186
- inject: () => actions()
1187
- }, FooterAction));
1188
- }
1189
-
1190
- const inject = ["slots", "sessions", "workspaces", "locale"];
1191
-
1192
- exports.apply = apply;
1193
- exports.inject = inject;
1194
- return module.exports;
1195
- }
1196
- });
1
+ /**
2
+ * @dsh-session-manager — Client half.
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 search, workspace/archive/annotation filters, sorting and toggle-close behaviour).
8
+ * Favorites, review-later flags, tags, notes and priority share a separate metadata store. No build
9
+ * step: DSH loads the file as a Cordis client bundle via its inject
10
+ * declaration in package.json.
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: "dsh-session-manager",
14
+ factory: (require) => {
15
+ var module = { exports: {} };
16
+ var exports = module.exports;
17
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
18
+
19
+ const React = require("react");
20
+ const { useState, useEffect, useMemo, useRef, useSyncExternalStore } = React;
21
+ const P = require("@deepseek-ai/dsh-client-ui-primitives");
22
+ if (typeof window !== "undefined") {
23
+ window.__smReact = React;
24
+ window.__smReactDOM = React.createPortal ? React : null;
25
+ // Tiny imperative portal helper: takes a React element + a host
26
+ // DOM node, creates a React root on the host (lazily), and re-renders
27
+ // the element into it on each call. Used by ConfirmDialog to escape
28
+ // the sidebar overflow:hidden ancestor.
29
+ try {
30
+ const RDOM = require("react-dom/client");
31
+ if (RDOM && typeof RDOM.createRoot === "function") {
32
+ window.__smRenderInto = (tree, host) => {
33
+ if (!tree || !host) return;
34
+ try {
35
+ if (!host.__smRoot) host.__smRoot = RDOM.createRoot(host);
36
+ host.__smRoot.render(tree);
37
+ } catch (_) { /* ignore render failure */ }
38
+ };
39
+ }
40
+ } catch (_) { /* react-dom/client not available */ }
41
+ }
42
+ // Monotonically-increasing z-index for popup dialogs so the most-recently
43
+ // opened popup always sits on top of any previously opened popup. The
44
+ // CSS-defined z-index values (panel 9999 / confirm 10000 / annotation +
45
+ // bulk 10002) are static; without a counter the user can open an
46
+ // annotation popup (10002) after a confirm popup (10000) and the older
47
+ // confirm stays below. Each dialog component claims a fresh slot from
48
+ // this counter on mount, writes it to inline style.zIndex, and never
49
+ // recycles within a session, so the most-recently opened popup is
50
+ // always the topmost one.
51
+ let dialogZStackCounter = 11000;
52
+ function nextDialogZ() { return ++dialogZStackCounter; }
53
+
54
+
55
+ const NS = "session-manager";
56
+ const API = "/session-manager/api";
57
+
58
+ const zh = {
59
+ "marks.filter.favorite": "收藏",
60
+ "marks.filter.favorite.aria": "收藏筛选",
61
+ "marks.filter.review": "待回看",
62
+ "marks.filter.review.aria": "待回看筛选",
63
+ "marks.filter.tags": "按标签筛选",
64
+ "marks.filter.priority": "按优先级筛选",
65
+ "marks.favorite.add": "收藏会话",
66
+ "marks.favorite.remove": "取消收藏",
67
+ "marks.review.add": "标记待回看",
68
+ "marks.review.remove": "取消待回看",
69
+ "marks.edit": "标签/备注",
70
+ "marks.header.edit": "标签/备注",
71
+ "marks.edit.aria": "编辑标签、备注和优先级",
72
+ "marks.title": "会话标记",
73
+ "marks.favorite": "收藏",
74
+ "marks.review": "待回看",
75
+ "marks.tags": "标签",
76
+ "marks.tags.placeholder": "用逗号分隔,最多 20 个标签,每个 32 字符(重复自动合并)。例如:排障,方案",
77
+ "marks.tags.help": "最多 20 个标签,每个 32 字符;重复标签会自动合并",
78
+ "marks.note": "备注",
79
+ "marks.note.placeholder": "例如:等待 Windows 环境验证;下次继续检查回滚逻辑(最多 2000 字符)",
80
+ "marks.privacy": "仅用于会话整理,不写入聊天记录,也不会自动发送给模型。",
81
+ "marks.priority": "优先级",
82
+ "marks.priority.all": "全部优先级",
83
+ "marks.priority.1": "1 · 最高",
84
+ "marks.priority.2": "2 · 高",
85
+ "marks.priority.3": "3 · 普通",
86
+ "marks.priority.4": "4 · 低",
87
+ "marks.priority.5": "5 · 最低",
88
+ "marks.priority.help": "1 最高,5 最低,默认 3(普通)",
89
+ "marks.tags.all": "全部标签",
90
+ "marks.tags.none": "无标签",
91
+ "marks.tags.missing": "{tag}(无匹配)",
92
+ "marks.save": "保存标记",
93
+ "marks.saving": "保存中…",
94
+ "marks.cancel": "取消",
95
+ "marks.loading": "正在加载会话标记…",
96
+ "marks.error": "会话标记加载失败:{message}",
97
+ "marks.retry": "重试标记加载",
98
+ "marks.conflict": "标记已在其他窗口更新。当前输入仍保留;请载入最新内容后再编辑。",
99
+ "marks.reload": "载入最新内容",
100
+ "marks.discard": "放弃当前未保存的标记修改?",
101
+ "marks.note.exists": "有备注",
102
+ "marks.sort": "优先级(1 → 5)",
103
+ "marks.note.exists": "有备注",
104
+ "marks.import.copy": "复制 Prompt",
105
+ "marks.import.copy.copied": "已复制,请粘贴到当前对话生成结果",
106
+ "marks.import.copy.failed": "复制失败:{message}",
107
+ "marks.import.paste": "导入",
108
+ "marks.import.paste.label": "AI 返回结果",
109
+ "marks.import.paste.help": "把模型返回的 JSON 粘贴到下方输入框,再点击“导入”",
110
+ "marks.import.paste.aria": "AI 返回的 JSON",
111
+ "marks.import.paste.placeholder": "支持 json 代码块或纯 JSON,例如: { \"tags\": [\"标签1\", \"标签2\"], \"note\": \"会话备注\" }",
112
+ "marks.import.paste.clear": "清空输入框",
113
+ "marks.import.empty": "请先把 AI 返回的 JSON 粘贴到上方输入框",
114
+ "marks.import.unknown": "未知字段",
115
+ "marks.import.discard": "导入将覆盖当前未保存的修改,是否继续?",
116
+ "marks.import.failure": "导入失败:{message}",
117
+ "marks.import.warnings": "已导入,但有以下调整:",
118
+ "marks.import.success": "已从剪贴板导入 AI 结果",
119
+ "marks.tags.clear": "清空标签",
120
+ "marks.note.clear": "清空备注",
121
+ "header.aria": "会话操作",
122
+ "menu.open": "打开",
123
+ "menu.archive": "归档会话",
124
+ "menu.unarchive": "移出归档",
125
+ "menu.delete": "删除会话…",
126
+ "menu.move": "移动到工作区…",
127
+ "menu.manage": "打开会话管理…",
128
+ "footer.aria": "会话管理",
129
+ "footer.label": "会话管理",
130
+ "panel.title": "会话管理",
131
+ "panel.github.title": "欢迎Star和Issue",
132
+ "panel.search": "搜索标题、ID、标签或备注",
133
+ "panel.workspace": "工作区",
134
+ "panel.workspace.all": "全部工作区",
135
+ "panel.workspace.ungrouped": "未分组",
136
+ "panel.workspace.loading": "正在加载工作区…",
137
+ "panel.workspace.error": "工作区加载失败:{message}",
138
+ "panel.workspace.retry": "重试",
139
+ "panel.sort": "排序",
140
+ "panel.sort.unavailable": "当前 DSH 未提供创建时间,暂按原顺序显示",
141
+ "panel.sort.updated-desc": "最近更新",
142
+ "panel.sort.updated-asc": "最早更新",
143
+ "panel.sort.created-desc": "最新创建",
144
+ "panel.sort.created-asc": "最早创建",
145
+ "panel.count": "显示 {shown} / {total} 个会话",
146
+ "panel.reset": "重置筛选",
147
+ "panel.empty.filtered": "没有匹配的会话,请调整搜索或筛选条件",
148
+ "panel.filter.all": "全部",
149
+ "panel.filter.active": "未归档",
150
+ "panel.filter.archived": "已归档",
151
+ "panel.empty": "暂无会话",
152
+ "panel.empty.archived": "归档为空",
153
+ "panel.archived": "已归档",
154
+ "panel.running": "运行中",
155
+ "panel.current": "当前",
156
+ "panel.session.new": "新建会话",
157
+ "panel.close": "关闭",
158
+ "row.open": "打开",
159
+ "row.archive": "归档",
160
+ "row.unarchive": "移出归档",
161
+ "row.move": "移动至工作区",
162
+ "row.delete": "删除会话",
163
+ "row.migrate": "迁移预设",
164
+ "migrate.title": "迁移 Agent 预设",
165
+ "migrate.current": "当前预设",
166
+ "migrate.target": "目标预设",
167
+ "migrate.noPresets": "没有可用的目标预设",
168
+ "migrate.same": "请选择与当前预设不同的目标预设",
169
+ "migrate.confirm": "确认迁移",
170
+ "migrate.completed": "Agent 预设已迁移为 {preset}",
171
+ "confirm.delete.title": "删除会话",
172
+ "confirm.delete.desc": "会话「{title}」将被永久删除,包括其全部消息记录与磁盘文件,此操作不可撤销。",
173
+ "confirm.delete.running": "该会话正在运行,删除将立即中断它。",
174
+ "confirm.delete.confirm": "确认删除",
175
+ "confirm.move.title": "移动会话到工作区",
176
+ "confirm.move.desc": "将会话「{title}」移动到目标工作区。",
177
+ "confirm.move.running": "该会话正在运行,移动会中断并关闭它;之后可在目标工作区重新打开继续。",
178
+ "confirm.move.empty": "没有可移动到的其他工作区。",
179
+ "confirm.move.select": "选择目标工作区",
180
+ "confirm.move.current": "当前工作区:{name}",
181
+ "confirm.move.confirm": "确认移动",
182
+ "confirm.cancel": "取消",
183
+ "busy.processing": "处理中…",
184
+ "error.operation": "操作失败:{message}",
185
+ "workspace.default": "默认工作区",
186
+ "workspace.sessions": "{count} 个会话",
187
+ "bulk.title": "批量管理",
188
+ "bulk.selectedFiltered": "已选 {selected} / {filtered} 项",
189
+ "bulk.selectAllFiltered": "全选当前筛选",
190
+ "bulk.clear": "清空选择",
191
+ "bulk.archive": "归档",
192
+ "bulk.unarchive": "取消归档",
193
+ "bulk.delete": "删除会话",
194
+ "bulk.move": "移动至工作区",
195
+ "bulk.moveBtn": "移动至工作区",
196
+ "bulk.presetMigrate": "迁移预设",
197
+ "bulk.favorite": "收藏",
198
+ "bulk.unfavorite": "取消收藏",
199
+ "bulk.review": "待回看",
200
+ "bulk.unreview": "取消待回看",
201
+ "bulk.tagsAdd": "添加标签",
202
+ "bulk.tagsRemove": "清空标签",
203
+ "bulk.priority": "设置优先级",
204
+ "bulk.tags.placeholder": "用逗号分隔,例如:排障, 方案",
205
+ "bulk.priority.aria": "选择优先级",
206
+ "bulk.workspace.aria": "选择目标工作区",
207
+ "bulk.preset.aria": "选择目标 preset",
208
+ "bulk.dialog.title": "批量操作:{action}",
209
+ "bulk.dialog.summary": "将会对 {count} 个会话执行「{action}」。",
210
+ "bulk.dialog.warning.delete": "删除操作不可撤销,请确认后再继续。",
211
+ "bulk.confirm.archive": "确认归档",
212
+ "bulk.confirm.unarchive": "确认还原",
213
+ "bulk.confirm.favorite": "确认收藏",
214
+ "bulk.confirm.unfavorite": "确认移除",
215
+ "bulk.confirm.review": "确认标记",
216
+ "bulk.confirm.unreview": "确认清除",
217
+ "bulk.confirm.tagsAdd": "确认添加",
218
+ "bulk.confirm.tagsRemove": "确认清空",
219
+ "bulk.confirm.priority": "确认设置",
220
+ "bulk.confirm.move": "确认移动",
221
+ "bulk.confirm.presetMigrate": "确认迁移",
222
+ "bulk.confirm.delete": "确认删除",
223
+ "bulk.dialog.execute": "执行",
224
+ "bulk.dialog.cancel": "取消",
225
+ "bulk.progress.title": "正在执行:{action}",
226
+ "bulk.progress.current": "处理 {done} / {total}",
227
+ "bulk.progress.cancel": "取消",
228
+ "bulk.progress.completed": "已完成 {done} / {total}",
229
+ "bulk.result.title": "批量操作完成:{action}",
230
+ "bulk.result.successGroup": "成功 ({count})",
231
+ "bulk.result.failedGroup": "失败 ({count})",
232
+ "bulk.result.skippedGroup": "跳过 ({count})",
233
+ "bulk.result.retry": "重试失败项",
234
+ "bulk.result.close": "关闭",
235
+ "bulk.error.tooMany": "批量操作最多支持 200 项,请缩小筛选范围后重试",
236
+ "bulk.checkbox.aria": "选择 {title}",
237
+ "bulk.heading": "批量操作",
238
+ "panel.selectMode": "批量处理",
239
+ "panel.selectMode.exit": "退出批量处理",
240
+ "panel.selectMode.aria": "进入批量管理模式",
241
+ "panel.selectMode.active.aria": "退出批量管理模式",
242
+ };
243
+ const en = {
244
+ "marks.filter.favorite": "Favorites",
245
+ "marks.filter.favorite.aria": "Favorites filter",
246
+ "marks.filter.review": "Review later",
247
+ "marks.filter.review.aria": "Review later filter",
248
+ "marks.filter.tags": "Filter by tag",
249
+ "marks.filter.priority": "Filter by priority",
250
+ "marks.favorite.add": "Favorite session",
251
+ "marks.favorite.remove": "Remove favorite",
252
+ "marks.review.add": "Mark for review",
253
+ "marks.review.remove": "Clear review flag",
254
+ "marks.edit": "Tags/Notes",
255
+ "marks.header.edit": "Tags/Notes",
256
+ "marks.edit.aria": "Edit tags, notes and priority",
257
+ "marks.title": "Session annotations",
258
+ "marks.favorite": "Favorite",
259
+ "marks.review": "Review later",
260
+ "marks.tags": "Tags",
261
+ "marks.tags.placeholder": "Separate with commas. Up to 20 tags, 32 chars each (duplicates merged). e.g. bug, design",
262
+ "marks.tags.help": "Up to 20 tags, 32 characters each; duplicates are merged",
263
+ "marks.note": "Note",
264
+ "marks.note.placeholder": "For example: awaiting Windows verification; check rollback next (up to 2000 characters)",
265
+ "marks.privacy": "For organization only. Not written into chat history or automatically sent to the model.",
266
+ "marks.priority": "Priority",
267
+ "marks.priority.all": "All priorities",
268
+ "marks.priority.1": "1 · Highest",
269
+ "marks.priority.2": "2 · High",
270
+ "marks.priority.3": "3 · Normal",
271
+ "marks.priority.4": "4 · Low",
272
+ "marks.priority.5": "5 · Lowest",
273
+ "marks.priority.help": "1 is highest, 5 is lowest. Default is 3 (Normal).",
274
+ "marks.tags.all": "All tags",
275
+ "marks.tags.none": "No tags",
276
+ "marks.tags.missing": "{tag} (no matches)",
277
+ "marks.save": "Save annotations",
278
+ "marks.saving": "Saving…",
279
+ "marks.cancel": "Cancel",
280
+ "marks.loading": "Loading session annotations…",
281
+ "marks.error": "Could not load annotations: {message}",
282
+ "marks.retry": "Retry annotations",
283
+ "marks.conflict": "Another window changed these annotations. Your draft is preserved; load the latest version before editing again.",
284
+ "marks.reload": "Load latest",
285
+ "marks.discard": "Discard unsaved annotation changes?",
286
+ "marks.note.exists": "Has note",
287
+ "marks.sort": "Priority (1 → 5)",
288
+ "marks.note.exists": "Has note",
289
+ "marks.import.copy": "Copy Prompt",
290
+ "marks.import.copy.copied": "Copied. Paste it into the current conversation to generate the result.",
291
+ "marks.import.copy.failed": "Copy failed: {message}",
292
+ "marks.import.paste": "Import",
293
+ "marks.import.paste.label": "AI response",
294
+ "marks.import.paste.help": "Paste the JSON returned by the model into the box below, then click Import.",
295
+ "marks.import.paste.aria": "JSON returned by the model",
296
+ "marks.import.paste.placeholder": "Supports json code fences or plain JSON, e.g. { \"tags\": [\"tag1\", \"tag2\"], \"note\": \"session note\" }",
297
+ "marks.import.paste.clear": "Clear input",
298
+ "marks.import.empty": "Paste the JSON returned by the model into the box above first.",
299
+ "marks.import.unknown": "unknown field",
300
+ "marks.import.discard": "Importing will overwrite your unsaved edits. Continue?",
301
+ "marks.import.failure": "Import failed: {message}",
302
+ "marks.import.warnings": "Imported, with adjustments:",
303
+ "marks.import.success": "Imported AI result from the clipboard",
304
+ "marks.tags.clear": "Clear tags",
305
+ "marks.note.clear": "Clear note",
306
+ "header.aria": "Session actions",
307
+ "menu.open": "Open",
308
+ "menu.archive": "Archive session",
309
+ "menu.unarchive": "Unarchive session",
310
+ "menu.delete": "Delete session…",
311
+ "menu.move": "Move to workspace…",
312
+ "menu.manage": "Open session manager…",
313
+ "footer.aria": "Session manager",
314
+ "footer.label": "Sessions",
315
+ "panel.title": "Session manager",
316
+ "panel.github.title": "Star or open an issue",
317
+ "panel.search": "Search title, ID, tags or notes",
318
+ "panel.workspace": "Workspace",
319
+ "panel.workspace.all": "All workspaces",
320
+ "panel.workspace.ungrouped": "Ungrouped",
321
+ "panel.workspace.loading": "Loading workspaces…",
322
+ "panel.workspace.error": "Could not load workspaces: {message}",
323
+ "panel.workspace.retry": "Retry",
324
+ "panel.sort": "Sort by",
325
+ "panel.sort.unavailable": "Creation times are unavailable in this DSH version; showing source order.",
326
+ "panel.sort.updated-desc": "Recently updated",
327
+ "panel.sort.updated-asc": "Least recently updated",
328
+ "panel.sort.created-desc": "Newest created",
329
+ "panel.sort.created-asc": "Oldest created",
330
+ "panel.count": "Showing {shown} / {total} sessions",
331
+ "panel.reset": "Reset filters",
332
+ "panel.empty.filtered": "No matching sessions. Adjust your search or filters.",
333
+ "bulk.title": "Bulk management",
334
+ "bulk.selectedFiltered": "Selected {selected} / {filtered}",
335
+ "bulk.selectAllFiltered": "Select all in filter",
336
+ "bulk.clear": "Clear selection",
337
+ "bulk.archive": "Archive",
338
+ "bulk.unarchive": "Unarchive",
339
+ "bulk.delete": "Delete session",
340
+ "bulk.move": "Move to workspace",
341
+ "bulk.moveBtn": "Move",
342
+ "bulk.presetMigrate": "Migrate preset",
343
+ "bulk.favorite": "Favorite",
344
+ "bulk.unfavorite": "Unfavorite",
345
+ "bulk.review": "Mark for review",
346
+ "bulk.unreview": "Clear review flag",
347
+ "bulk.tagsAdd": "Add tags",
348
+ "bulk.tagsRemove": "Clear tags",
349
+ "bulk.priority": "Set priority",
350
+ "bulk.tags.placeholder": "Comma-separated, e.g. bug, design",
351
+ "bulk.priority.aria": "Choose priority",
352
+ "bulk.workspace.aria": "Choose target workspace",
353
+ "bulk.preset.aria": "Choose target preset",
354
+ "bulk.dialog.title": "Bulk action: {action}",
355
+ "bulk.dialog.summary": "Apply \"{action}\" to {count} sessions.",
356
+ "bulk.dialog.warning.delete": "Deletion is permanent and cannot be undone.",
357
+ "bulk.confirm.archive": "Archive",
358
+ "bulk.confirm.unarchive": "Unarchive",
359
+ "bulk.confirm.favorite": "Favorite",
360
+ "bulk.confirm.unfavorite": "Unfavorite",
361
+ "bulk.confirm.review": "Mark review",
362
+ "bulk.confirm.unreview": "Clear review",
363
+ "bulk.confirm.tagsAdd": "Add tags",
364
+ "bulk.confirm.tagsRemove": "Clear tags",
365
+ "bulk.confirm.priority": "Set priority",
366
+ "bulk.confirm.move": "Move",
367
+ "bulk.confirm.presetMigrate": "Migrate",
368
+ "bulk.confirm.delete": "Delete",
369
+ "bulk.dialog.execute": "Execute",
370
+ "bulk.dialog.cancel": "Cancel",
371
+ "bulk.progress.title": "Running: {action}",
372
+ "bulk.progress.current": "Processing {done} / {total}",
373
+ "bulk.progress.cancel": "Cancel",
374
+ "bulk.progress.completed": "Finished {done} / {total}",
375
+ "bulk.result.title": "Bulk action finished: {action}",
376
+ "bulk.result.successGroup": "Success ({count})",
377
+ "bulk.result.failedGroup": "Failed ({count})",
378
+ "bulk.result.skippedGroup": "Skipped ({count})",
379
+ "bulk.result.retry": "Retry failed",
380
+ "bulk.result.close": "Close",
381
+ "bulk.error.tooMany": "Batch actions support at most 200 sessions; please narrow the filter.",
382
+ "bulk.checkbox.aria": "Select {title}",
383
+ "bulk.heading": "Bulk actions",
384
+ "panel.selectMode": "Batch process",
385
+ "panel.selectMode.exit": "Exit batch process",
386
+ "panel.selectMode.aria": "Enter bulk selection mode",
387
+ "panel.selectMode.active.aria": "Exit bulk selection mode",
388
+ "panel.filter.all": "All",
389
+ "panel.filter.active": "Active",
390
+ "panel.filter.archived": "Archived",
391
+ "panel.empty": "No sessions",
392
+ "panel.empty.archived": "No archived sessions",
393
+ "panel.archived": "Archived",
394
+ "panel.running": "Running",
395
+ "panel.current": "Current",
396
+ "panel.session.new": "New session",
397
+ "panel.close": "Close",
398
+ "row.open": "Open",
399
+ "row.archive": "Archive",
400
+ "row.unarchive": "Unarchive",
401
+ "row.move": "Move to workspace",
402
+ "row.delete": "Delete session",
403
+ "row.migrate": "Migrate preset",
404
+ "migrate.title": "Migrate Agent preset",
405
+ "migrate.current": "Current preset",
406
+ "migrate.target": "Target preset",
407
+ "migrate.noPresets": "No target presets are available",
408
+ "migrate.same": "Choose a target preset different from the current preset",
409
+ "migrate.confirm": "Migrate",
410
+ "migrate.completed": "Agent preset migrated to {preset}",
411
+ "confirm.delete.title": "Delete session",
412
+ "confirm.delete.desc": "Session \"{title}\" will be permanently deleted, including all its messages and files on disk. This cannot be undone.",
413
+ "confirm.delete.running": "This session is running; deleting will interrupt it immediately.",
414
+ "confirm.delete.confirm": "Delete",
415
+ "confirm.move.title": "Move session to workspace",
416
+ "confirm.move.desc": "Move session \"{title}\" to the target workspace.",
417
+ "confirm.move.running": "This session is running; moving will interrupt and close it; you can reopen it from the target workspace to continue.",
418
+ "confirm.move.empty": "No other workspaces to move to.",
419
+ "confirm.move.select": "Select target workspace",
420
+ "confirm.move.current": "Current workspace: {name}",
421
+ "confirm.move.confirm": "Move",
422
+ "confirm.cancel": "Cancel",
423
+ "busy.processing": "Working…",
424
+ "error.operation": "Operation failed: {message}",
425
+ "workspace.default": "Default workspace",
426
+ "workspace.sessions": "{count} sessions"
427
+ };
428
+
429
+ const css = [
430
+ ".sm-header{display:flex;align-items:center;gap:6px}",
431
+ ".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}",
432
+ ".sm-headerBtn:hover:not(:disabled),.sm-headerBtn:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}",
433
+ ".sm-headerBtn:disabled{opacity:.5;cursor:not-allowed}",
434
+ ".sm-headerBtn svg{flex:none}",
435
+ ".sm-busy{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:28px;margin-left:4px}",
436
+ ".sm-footer{display:flex}",
437
+ ".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}",
438
+ ".sm-footerBtn:hover,.sm-footerBtn:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
439
+ ".sm-footerBtn svg{flex:none}",
440
+ ".sm-panel{display:flex;flex-direction:column;gap:10px;min-height:280px;max-height:min(70vh,640px);min-width:480px}",
441
+ ".sm-filter{display:flex;gap:4px;flex-wrap:wrap}",
442
+ ".sm-filterBtn{cursor:pointer;display:inline-flex;align-items:center;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;height:28px;padding:3px 10px;font-size:12px;line-height:18px;white-space:nowrap;box-sizing:border-box}",
443
+ ".sm-filterBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
444
+ ".sm-filterBtn.sm-on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2);border-color:transparent}",
445
+ ".sm-list{display:flex;flex-direction:column;gap:4px;min-height:0;overflow:auto;flex:1}",
446
+ ".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}",
447
+ ".sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
448
+ ".sm-rowCurrent{border-color:var(--dsw-alias-accent-primary,var(--dsw-alias-label-secondary))}",
449
+ ".sm-rowMain{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1 1 280px}",
450
+ ".sm-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;display:flex;align-items:center;gap:6px;min-width:0}",
451
+ ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}",
452
+ ".sm-rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}",
453
+ ".sm-rowActions{display:flex;gap:4px;flex:none;flex-wrap:wrap;justify-content:flex-end}",
454
+ ".sm-badge{flex:none;border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}",
455
+ ".sm-badgeArchived{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2)}",
456
+ ".sm-badgeRunning{color:var(--dsw-alias-state-warning-primary,#d97706);background:rgba(217,119,6,.12)}",
457
+ ".sm-badgeCurrent{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2)}",
458
+ ".sm-danger{color:var(--dsw-alias-state-error-primary) !important;border-color:var(--dsw-alias-state-error-primary) !important}",
459
+ ".sm-dangerText{color:var(--dsw-alias-state-error-primary) !important}",
460
+ ".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}",
461
+ ".sm-warn svg{flex:none;margin-top:1px}",
462
+ ".sm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;min-height:0;margin-top:4px}",
463
+ ".sm-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;width:100%}",
464
+ ".sm-footError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;margin-right:auto}",
465
+ ".sm-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;text-align:center;padding:24px 0}",
466
+ ".sm-moveSection{display:flex;flex-direction:column;gap:8px}",
467
+ ".sm-moveLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}",
468
+ ".sm-moveList{display:flex;flex-direction:column;gap:4px;max-height:200px;overflow:auto}",
469
+ ".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}",
470
+ ".sm-moveItem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
471
+ ".sm-moveItemSelected{border-color:var(--dsw-alias-accent-primary);background:var(--dsw-alias-accent-primary-bg)}",
472
+ ".sm-moveItemName{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px}",
473
+ ".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-annotationToggleOpen{position:relative;z-index:10004}.sm-headerBtnActive{background:var(--dsw-alias-fill-l3);color:var(--dsw-alias-label-primary)}",
474
+ ".sm-panelDialog{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:600px;max-width:calc(100vw - 32px);max-height:560px;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{display:flex;align-items:center;gap:8px;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;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center;flex:1 1 auto}.sm-panelDialog .sm-nativeDialogHeaderActions{display:flex;align-items:center;gap:6px;flex:none}.sm-panelDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600;text-align:center;flex:1 1 auto}.sm-nativeDialogFooter,.sm-bulkDialog .sm-nativeDialogFooter,.sm-confirmDialog .sm-nativeDialogFooter,.sm-migrateDialog .sm-nativeDialogFooter{display:flex;padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:space-between;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:6px 10px;min-height:36px;border:1px solid transparent;border-radius:8px;background:transparent;flex-shrink:0}.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:visible;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:360px;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;text-align:center;width:100%}.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:space-between;gap:6px;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);text-align:center}.sm-confirmDialog .sm-nativeDialogCancel:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.sm-confirmDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:var(--dsw-alias-accent-primary,#356ae6)}.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;padding:0 7px;font-size:11px;line-height:18px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}.sm-rowBtn.sm-markIcon{padding:0 6px;font-size:13px;line-height:18px}.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-nativeDialogDanger{background:var(--dsw-alias-state-error-primary,#d92d20);color:#fff;border-color:var(--dsw-alias-state-error-primary,#d92d20)}.sm-nativeDialogDanger:hover:not(:disabled){filter:brightness(.92)}.sm-confirmDialog .sm-nativeDialogDanger,.sm-migrateDialog .sm-nativeDialogDanger,.sm-bulkDialog .sm-nativeDialogDanger{background:var(--dsw-alias-state-error-primary,#d92d20);color:#fff;border-color:var(--dsw-alias-state-error-primary,#d92d20)}.sm-confirmDialog .sm-nativeDialogDanger:hover:not(:disabled),.sm-migrateDialog .sm-nativeDialogDanger:hover:not(:disabled),.sm-bulkDialog .sm-nativeDialogDanger:hover:not(:disabled){filter:brightness(.92)}.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:space-between;gap:6px;margin-top:0}.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:360px;max-width:calc(100vw - 32px);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:14px;line-height:20px;font-weight:600;color:#111;color:var(--dsw-alias-label-primary,#111);text-align:center;width:100%}.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:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);flex:none;flex-wrap:wrap;justify-content:space-between;gap:6px;margin-top:0;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:28px;padding:3px 14px;font-size:12px;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);text-align:center}.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)}",
475
+ ".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;flex:none}.sm-panelHeaderGithub{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:6px;color:var(--dsw-alias-label-secondary,#555);text-decoration:none;flex:none;transition:background .15s,color .15s;cursor:pointer}.sm-panelHeaderGithub:hover{background:var(--dsw-alias-interactive-bg-hover,#eee);color:var(--dsw-alias-label-primary,#111)}.sm-panelHeaderGithub svg{display:block;width:16px;height:16px;fill:currentColor}.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-panelHeaderSelect{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;display:inline-flex;align-items:center;gap:4px}.sm-panelHeaderSelect:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#eee);color:var(--dsw-alias-label-primary,#111)}.sm-panelHeaderSelect:disabled{opacity:.5;cursor:not-allowed}.sm-panelHeaderSelect.sm-on{background:var(--dsw-alias-accent-primary-bg,rgba(53,106,230,.12));border-color:var(--dsw-alias-accent-primary,#356ae6);color:var(--dsw-alias-accent-primary,#356ae6)}.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:360px;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}",
476
+
477
+ // Theme colors are scoped to the plugin dialogs. DSH exposes its
478
+ // actual theme through `body[data-ds-dark-theme]` and color-scheme;
479
+ // keeping these values local avoids changing DSH's global variables.
480
+ "[data-sm-theme=light] .sm-panelDialog,[data-sm-theme=light] .sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog,[data-sm-theme=light] .sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog{--dsw-alias-surface-l1:#fff;--dsw-alias-label-primary:#1f1f1f;--dsw-alias-label-secondary:#555;--dsw-alias-label-tertiary:#888;--dsw-alias-border-l1:#d4d4d8;--dsw-alias-border-l2:#e5e5e5;--dsw-alias-fill-l2:#f5f5f5;--dsw-alias-interactive-bg-hover:#eee;--dsw-alias-state-error-primary:#d92d20;--dsw-alias-state-warning-primary:#d97706;--dsw-alias-accent-primary:#356ae6;--dsw-alias-accent-primary-bg:rgba(53,106,230,.12);background:#fff!important;background-color:#fff!important;color:#1f1f1f!important;border-color:#e5e5e5!important}",
481
+ "[data-sm-theme=dark] .sm-panelDialog,[data-sm-theme=dark] .sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog,[data-sm-theme=dark] .sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog,[data-sm-theme=dark] .sm-bulkDialog.sm-nativeDialogLayer .sm-nativeDialog{--dsw-alias-surface-l1:#1f1f23;--dsw-alias-label-primary:#ececec;--dsw-alias-label-secondary:#b3b3b3;--dsw-alias-label-tertiary:#999;--dsw-alias-border-l1:#555;--dsw-alias-border-l2:#3a3a40;--dsw-alias-fill-l1:#1a1a1e;--dsw-alias-fill-l2:#2a2a30;--dsw-alias-interactive-bg-hover:rgba(255,255,255,.06);--dsw-alias-state-error-primary:#f47171;--dsw-alias-state-warning-primary:#f0a040;--dsw-alias-accent-primary:#5d8aff;--dsw-alias-accent-primary-bg:rgba(93,138,255,.18);background:#1f1f23!important;background-color:#1f1f23!important;color:#ececec!important;border-color:#3a3a40!important}","[data-sm-theme=dark] .sm-header{--dsw-alias-surface-l1:#1f1f23;--dsw-alias-label-primary:#ececec;--dsw-alias-label-secondary:#b3b3b3;--dsw-alias-label-tertiary:#999;--dsw-alias-border-l1:#555;--dsw-alias-border-l2:#3a3a40;--dsw-alias-fill-l1:#1a1a1e;--dsw-alias-fill-l2:#2a2a30;--dsw-alias-interactive-bg-hover:rgba(255,255,255,.06);--dsw-alias-accent-primary:#5d8aff;--dsw-alias-accent-primary-bg:rgba(93,138,255,.18)}",
482
+ "[data-sm-theme=light] .sm-panelDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-panelDialog .sm-nativeDialogFooter,[data-sm-theme=light] .sm-confirmDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-confirmDialog .sm-nativeDialogFooter,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogHeader,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogBody,[data-sm-theme=light] .sm-migrateDialog .sm-nativeDialogFooter{background:#fff!important;background-color:#fff!important;border-color:#e5e5e5!important}",
483
+ "[data-sm-theme=dark] .sm-panelDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-panelDialog .sm-nativeDialogFooter,[data-sm-theme=dark] .sm-confirmDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-confirmDialog .sm-nativeDialogFooter,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogBody,[data-sm-theme=dark] .sm-migrateDialog .sm-nativeDialogFooter,[data-sm-theme=dark] .sm-bulkDialog .sm-nativeDialogHeader,[data-sm-theme=dark] .sm-bulkDialog .sm-nativeDialogBody,[data-sm-theme=dark] .sm-bulkDialog .sm-nativeDialogFooter{background:#1f1f23!important;background-color:#1f1f23!important;border-color:#3a3a40!important}[data-sm-theme=dark] .sm-bulkDialog .sm-bulkSessionList,[data-sm-theme=dark] .sm-bulkDialog .sm-bulkResultList,[data-sm-theme=dark] .sm-bulkDialog .sm-bulkProgressBar{background:#1a1a1e!important;background-color:#1a1a1e!important;border-color:#3a3a40!important;color:var(--dsw-alias-label-primary,#ececec)}[data-sm-theme=dark] .sm-bulkDialog .sm-bulkSessionItem,[data-sm-theme=dark] .sm-bulkDialog .sm-bulkResultItemTitle,[data-sm-theme=dark] .sm-bulkDialog .sm-bulkResultItem{color:var(--dsw-alias-label-primary,#ececec)}",
484
+ ".sm-panelDialog{max-height:min(640px,calc(100vh - 32px))}.sm-panelDialog .sm-panel{min-width:0;overflow:hidden}",
485
+ ".sm-panelTools{display:flex;flex-direction:column;gap:8px;flex:none;padding:2px 0 6px}",
486
+ ".sm-queryInput,.sm-viewSelect{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:5px 9px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:7px;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);font:inherit;font-size:12px;line-height:20px}",
487
+ ".sm-queryInput::placeholder{color:var(--dsw-alias-label-tertiary,#888)}.sm-queryInput:focus-visible,.sm-viewSelect:focus-visible,.sm-resetFilters:focus-visible{outline:2px solid var(--dsw-alias-accent-primary,#356ae6);outline-offset:2px}.sm-viewSelect:disabled{opacity:.6;cursor:wait}",
488
+ ".sm-panelSelects{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.sm-panelSelects .sm-viewField>span{display:none}.sm-panelSelects .sm-viewField .sm-viewSelect{font-size:11px;padding:4px 7px;height:28px;line-height:18px}.sm-viewField{display:flex;flex-direction:column;gap:3px;min-width:0;color:var(--dsw-alias-label-secondary,#666);font-size:11px;line-height:16px}",
489
+ ".sm-filterBar{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap}.sm-resultSummary{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}.sm-resetFilters{padding:2px 0;border:0;background:none;color:var(--dsw-alias-accent-primary,#356ae6);font:inherit;cursor:pointer}.sm-resetFilters:hover{text-decoration:underline}.sm-workspaceNotice{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary,#666)}",
490
+ "@media(max-width:540px){.sm-panelSelects{grid-template-columns:minmax(0,1fr)}.sm-panelDialog .sm-row{height:auto;min-height:36px;flex-shrink:0;flex-wrap:wrap;padding:6px 8px}.sm-panelDialog .sm-rowMain{flex-basis:100%}.sm-panelDialog .sm-rowActions{width:100%;min-width:0;flex:1 1 100%;justify-content:flex-start}}",
491
+ ".sm-panelDialog:not(.sm-annotationSurface){width:680px}.sm-panelDialog .sm-row{flex-shrink:0;padding:5px 8px}.sm-panelDialog .sm-rowMain{flex:1 1 200px;flex-direction:column;gap:4px}.sm-panelDialog .sm-rowActions{max-width:100%}.sm-rowSecondary{display:flex;flex-wrap:nowrap;align-items:center;gap:6px;min-width:0;line-height:18px}.sm-rowBadges{display:flex;align-items:center;gap:5px;min-width:0;flex:1 1 auto;flex-wrap:wrap;font-size:11px;line-height:18px}.sm-rowMeta{color:var(--dsw-alias-label-tertiary,#888);font-size:11px;line-height:18px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;flex:none}.sm-rowAnnotations{display:flex;align-items:center;gap:5px;min-width:0;font-size:10px;line-height:16px}",
492
+ ".sm-markIcon{min-width:25px;padding-left:5px;padding-right:5px;font-size:15px}.sm-markIcon.sm-markOn{color:var(--dsw-alias-accent-primary,#356ae6)!important;background:var(--dsw-alias-fill-l2,#eee)}.sm-priorityBadge{padding:0 5px;border-radius:4px;background:var(--dsw-alias-fill-l2,#eee);color:var(--dsw-alias-label-secondary,#666)}.sm-priority-1{color:#c2410c;background:rgba(234,88,12,.12)}.sm-priority-2{color:#a16207;background:rgba(202,138,4,.12)}",
493
+ "[data-sm-theme=dark] .sm-priority-1{color:#fdba74}[data-sm-theme=dark] .sm-priority-2{color:#fde68a}",
494
+ ".sm-tagChip,.sm-noteBadge{max-width:88px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:0;border-radius:4px;background:var(--dsw-alias-fill-l2,#eee);color:var(--dsw-alias-label-secondary,#666);font:inherit;line-height:16px;padding:0 5px;cursor:pointer}.sm-noteBadge{background:transparent}.sm-tagChip:hover,.sm-noteBadge:hover{color:var(--dsw-alias-accent-primary,#356ae6)}",
495
+ ".sm-markFilters{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.sm-markFiltersGroup{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.sm-markFiltersSpacer{flex:1 1 auto;min-width:12px}.sm-markFilterToggle{height:28px;padding:3px 10px;white-space:nowrap}.sm-markFilterToggle:disabled{opacity:.5;cursor:not-allowed}.sm-bulkSelectBtn{height:28px;padding:3px 10px;white-space:nowrap}.sm-bulkSelectBtn:disabled{opacity:.5;cursor:not-allowed}.sm-header{flex-wrap:wrap}",
496
+ ".sm-annotationDialog.sm-nativeDialogLayer{position:fixed;display:block;pointer-events:none;z-index:10002;background:transparent}.sm-annotationDialog .sm-nativeDialogBackdrop{display:none}.sm-annotationDialog.sm-annotationDialog-anchored.sm-nativeDialogLayer{inset:auto;background:transparent}.sm-annotationDialog.sm-confirmDialog.sm-nativeDialogLayer:not(.sm-annotationDialog-anchored){inset:50% auto auto calc(50vw + 308px);transform:translateY(-50%);max-height:calc(100vh - 32px)}.sm-panelDialog.sm-annotationSurface{position:absolute;width:360px;max-height:min(680px,calc(100vh - 32px));pointer-events:auto;z-index:10003;box-shadow:0 16px 48px rgba(0,0,0,.25)}.sm-panelDialog.sm-annotationSurface.sm-annotationSurface-anchored{position:relative;transform:none;left:auto;top:auto;margin:0}.sm-annotationCloseHint{margin:0 16px 8px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}",
497
+ ".sm-annotationBody{padding:14px 16px;display:flex;flex-direction:column;gap:12px;min-height:0;overflow:auto;flex:1 1 auto}.sm-annotationTarget{font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,#666)}.sm-annotationChecks{display:grid;grid-template-columns:auto 1fr;column-gap:14px;row-gap:4px;align-items:center;font-size:13px}.sm-annotationChecks>label{display:flex;gap:6px;align-items:center;cursor:pointer}.sm-annotationChecks .sm-annotationPriority{display:inline-flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:inherit}.sm-annotationChecks .sm-prioritySelectInline{height:24px;padding:2px 6px;font-size:12px;line-height:18px;min-width:104px;width:auto;border-radius:6px;color:var(--dsw-alias-label-primary,#111)}.sm-annotationChecks>small{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888);justify-self:start}.sm-annotationHint,.sm-annotationBody small{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#888)}",
498
+ ".sm-noteInput{box-sizing:border-box;width:100%;min-height:60px;resize:vertical;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:7px;padding:8px;background:var(--dsw-alias-surface-l1,#fff);color:var(--dsw-alias-label-primary,#111);font:inherit;font-size:13px;line-height:19px}.sm-noteInput:focus-visible{outline:2px solid var(--dsw-alias-accent-primary,#356ae6);outline-offset:2px}.sm-markConflict{display:flex;flex-direction:column;gap:5px;font-size:12px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:6px}.sm-fieldWithClear .sm-fieldRow{position:relative;display:flex;align-items:flex-start;gap:4px}.sm-fieldRow .sm-queryInput{flex:1 1 auto;min-width:0}.sm-fieldRowNote{align-items:stretch}.sm-fieldClear{position:absolute;top:5px;right:6px;width:22px;height:22px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:4px;font-size:14px;line-height:18px;display:inline-flex;align-items:center;justify-content:center;padding:0}.sm-fieldClear:hover,.sm-fieldClear:focus-visible{color:var(--dsw-alias-accent-primary);background:var(--dsw-alias-interactive-bg-hover);outline:none}.sm-fieldClearNote{top:6px}.sm-importGroup{display:flex;flex-direction:column;gap:8px;padding:10px 16px;border-top:1px solid var(--dsw-alias-border-l2,#eee);background:var(--dsw-alias-fill-l1,#fafbfc)}.sm-importGroupButtons{display:flex;justify-content:space-between;align-items:center;gap:8px}.sm-importBtn{cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-surface-l1,#fff);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;padding:5px 12px;font-size:12px;line-height:18px}.sm-importBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.sm-importBtn:disabled{opacity:.5;cursor:not-allowed}.sm-importStatus{margin:0 16px;padding:8px 10px;font-size:12px;line-height:18px;background:var(--dsw-alias-accent-primary-bg,rgba(53,106,230,.08));color:var(--dsw-alias-label-secondary);border-radius:6px;white-space:pre-wrap;word-break:break-word}",
499
+ ".sm-bulkBar{box-sizing:border-box;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));align-items:stretch;gap:6px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:10px;background:var(--dsw-alias-fill-l1,#fafbfc)}.sm-bulkBar .sm-bulkSummary{grid-column:1/-1;color:var(--dsw-alias-label-primary,#111);font-size:12px;line-height:18px;font-weight:500;margin:0 0 2px}",
500
+ ".sm-bulkSummary{color:var(--dsw-alias-label-primary,#111);font-size:12px;line-height:18px;font-weight:500}",
501
+ "",
502
+ ".sm-bulkBar .sm-bulkBtn{cursor:pointer;color:var(--dsw-alias-label-secondary,#666);background:var(--dsw-alias-surface-l1,#fff);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;padding:4px 10px;font-size:12px;line-height:18px;display:inline-flex;align-items:center;justify-content:center;gap:4px;white-space:nowrap;text-align:center}",
503
+ ".sm-bulkBar .sm-bulkBtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary,#111)}",
504
+ ".sm-bulkBar .sm-bulkBtn:disabled{opacity:.5;cursor:not-allowed}",
505
+ ".sm-bulkBar .sm-bulkBtnDanger{color:var(--dsw-alias-state-error-primary);border-color:rgba(217,80,80,.4)}",
506
+ ".sm-bulkBar .sm-bulkBtnDanger:hover:not(:disabled){color:#fff;background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}",
507
+ "",
508
+ ".sm-bulkRowCheckbox{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;flex:none}",
509
+ ".sm-bulkRowCheckbox input{width:14px;height:14px;cursor:pointer;margin:0}",
510
+ ".sm-bulkRowCheckbox input:disabled{cursor:not-allowed}",
511
+ ".sm-rowBulkSelected{border-color:var(--dsw-alias-accent-primary,#356ae6);background:var(--dsw-alias-accent-primary-bg,rgba(53,106,230,.06))}",
512
+ ".sm-bulkDialog.sm-nativeDialogLayer{position:fixed;left:calc(50vw + 184px);top:calc(50vh - 90px);width:360px;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);z-index:10002}.sm-bulkDialog.sm-nativeDialogLayer .sm-nativeDialogBackdrop{display:none}.sm-bulkDialog.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-bulkDialog .sm-nativeDialogHeader{padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2,#eee);flex:none;display:flex;align-items:center;gap:8px}.sm-bulkDialog .sm-nativeDialogTitle{margin:0;font-size:14px;line-height:20px;font-weight:600;text-align:center;width:100%}.sm-bulkDialog .sm-nativeDialogBody{padding:12px 16px;display:flex;flex-direction:column;gap:10px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#111)}.sm-bulkDialog .sm-nativeDialogFooter{padding:10px 16px;display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px}.sm-bulkDialog .sm-nativeDialogButton{min-height:28px;padding:3px 14px;font-size:12px;line-height:20px;border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-primary,#111);text-align:center}.sm-bulkDialog .sm-nativeDialogConfirm{background:var(--dsw-alias-accent-primary,#356ae6);color:#fff;border-color:var(--dsw-alias-accent-primary,#356ae6)}.sm-bulkDialog .sm-nativeDialogConfirm:hover:not(:disabled){filter:brightness(.94)}.sm-bulkDialog .sm-nativeDialogDanger{background:var(--dsw-alias-state-error-primary,#d92d20);color:#fff;border-color:var(--dsw-alias-state-error-primary,#d92d20)}.sm-bulkDialog .sm-nativeDialogDanger:hover:not(:disabled){filter:brightness(.92)}.sm-bulkDialog .sm-nativeDialog select.sm-viewSelect{font-size:13px;line-height:20px;height:32px;padding:5px 9px}",
513
+ ".sm-bulkDialog .sm-nativeDialog{width:100%;max-height:min(640px,calc(100vh - 32px))}",
514
+ ".sm-bulkDialogBody{display:flex;flex-direction:column;gap:12px;min-height:0}",
515
+ ".sm-bulkDialogSummary{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#111)}",
516
+ ".sm-bulkDialogWarn{display:flex;align-items:flex-start;gap:8px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;background:rgba(217,80,80,.08);border:1px solid rgba(217,80,80,.25);border-radius:8px;padding:8px 10px}",
517
+ ".sm-bulkSessionList{display:flex;flex-direction:column;gap:4px;max-height:200px;overflow:auto;border:1px solid var(--dsw-alias-border-l2,#eee);border-radius:8px;padding:6px}",
518
+ ".sm-bulkSessionItem{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#666);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
519
+ ".sm-bulkProgressBar{position:relative;height:8px;background:var(--dsw-alias-fill-l2,#eee);border-radius:4px;overflow:hidden}",
520
+ ".sm-bulkProgressFill{position:absolute;top:0;left:0;bottom:0;background:var(--dsw-alias-accent-primary,#356ae6);transition:width .2s ease}",
521
+ ".sm-bulkResultGroup{display:flex;flex-direction:column;gap:6px}",
522
+ ".sm-bulkResultHeader{font-size:12px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-secondary,#666)}",
523
+ ".sm-bulkResultList{display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;border:1px solid var(--dsw-alias-border-l2,#eee);border-radius:8px;padding:6px}",
524
+ ".sm-bulkResultItem{display:flex;flex-direction:column;gap:2px;padding:4px 6px;font-size:12px;line-height:18px;border-radius:6px;cursor:default}",
525
+ ".sm-bulkResultItem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
526
+ ".sm-bulkResultItemTitle{color:var(--dsw-alias-label-primary,#111);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
527
+ ".sm-bulkResultItemReason{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;word-break:break-word}",
528
+ ".sm-bulkResultItemReasonSkip{color:var(--dsw-alias-label-tertiary,#888)}",
529
+ ".sm-bulkResultFooter{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}",
530
+ ".sm-footerBtn-wide{flex:1 1 auto;min-width:0;min-height:36px;justify-content:flex-start;padding:0 10px;border-radius:12px;font-size:13px;line-height:20px;gap:8px}",
531
+ ".sm-footerBtn-wide:hover,.sm-footerBtn-wide:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
532
+ ".sm-footerBtn-rail{width:36px;height:36px;padding:0;justify-content:center;border-radius:12px;color:var(--dsw-alias-label-secondary)}",
533
+ ".sm-footerBtn-rail:hover,.sm-footerBtn-rail:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
534
+ ".sm-footerBtn-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%}",
535
+ ".sm-footer-wide{display:flex;width:100%}",
536
+ ".sm-footer-rail{display:flex;width:100%;justify-content:center}",
537
+ "@media(max-width:720px){.sm-panelSelects{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.sm-panelDialog .sm-row{flex-wrap:wrap}.sm-panelDialog .sm-rowMain{flex-basis:100%}.sm-panelDialog .sm-rowActions{width:100%;min-width:0;flex:1 1 100%;justify-content:flex-start}}@media(max-width:540px){.sm-panelSelects{grid-template-columns:minmax(0,1fr)}}",
538
+ ].join("");
539
+
540
+ // Auto-follow DSH's theme. We detect DSH's current theme from
541
+ // body[data-ds-dark-theme] / color-scheme / data-theme / class tokens,
542
+ // mirror it onto <html data-sm-theme>, and apply inline dialog colors.
543
+ // The dialogs use that attribute via the scoped CSS rules above, and
544
+ // inline styles guarantee an opaque background even when DSH's tokens
545
+ // resolve to `unset` / `transparent`.
546
+ const SM_THEME_ATTR = "data-sm-theme";
547
+ function smReadDshTheme() {
548
+ try {
549
+ const html = document.documentElement;
550
+ const body = document.body;
551
+ if (body && body.hasAttribute("data-ds-dark-theme")) return "dark";
552
+ const elements = [html, body].filter(Boolean);
553
+ const dark = /\b(dark|dim)\b/i;
554
+ const light = /\b(light|bright)\b/i;
555
+ for (const el of elements) {
556
+ const value = ["theme", "data-theme", "data-bs-theme", "color-scheme", "data-color-mode", "data-mode"]
557
+ .map((name) => String(el.getAttribute(name) || ""))
558
+ .join(" ");
559
+ if (dark.test(value)) return "dark";
560
+ if (light.test(value)) return "light";
561
+ }
562
+ for (const el of elements) {
563
+ const scheme = String(getComputedStyle(el).colorScheme || "").toLowerCase();
564
+ if (scheme.includes("dark")) return "dark";
565
+ if (scheme.includes("light")) return "light";
566
+ }
567
+ const bg = body ? getComputedStyle(body).backgroundColor : "";
568
+ const match = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
569
+ if (match) {
570
+ const luminance = (Number(match[1]) * 299 + Number(match[2]) * 587 + Number(match[3]) * 114) / 1000;
571
+ return luminance < 128 ? "dark" : "light";
572
+ }
573
+ } catch (_) { /* ignore */ }
574
+ return "light";
575
+ }
576
+ function smApplyInlineTheme(theme) {
577
+ try {
578
+ const values = theme === "dark"
579
+ ? { background: "#1f1f23", color: "#ececec", border: "#3a3a40" }
580
+ : { background: "#fff", color: "#1f1f1f", border: "#e5e5e5" };
581
+ const roots = document.querySelectorAll(
582
+ ".sm-panelDialog," +
583
+ ".sm-confirmDialog.sm-nativeDialogLayer .sm-nativeDialog," +
584
+ ".sm-migrateDialog.sm-nativeDialogLayer .sm-nativeDialog"
585
+ );
586
+ for (const el of roots) {
587
+ el.style.setProperty("background", values.background, "important");
588
+ el.style.setProperty("background-color", values.background, "important");
589
+ el.style.setProperty("color", values.color, "important");
590
+ el.style.setProperty("border-color", values.border, "important");
591
+ }
592
+ } catch (_) { /* ignore */ }
593
+ }
594
+ function smApplyTheme() {
595
+ try {
596
+ const theme = smReadDshTheme();
597
+ document.documentElement.setAttribute(SM_THEME_ATTR, theme);
598
+ smApplyInlineTheme(theme);
599
+ } catch (_) { /* ignore */ }
600
+ }
601
+ smApplyTheme();
602
+ if (typeof MutationObserver === "function") {
603
+ try {
604
+ const observer = new MutationObserver(() => smApplyTheme());
605
+ const options = { attributes: true, attributeFilter: ["class", "theme", "data-theme", "data-bs-theme", "data-ds-dark-theme", "data-color-mode", "data-mode", "color-scheme", "style"] };
606
+ if (document.documentElement) observer.observe(document.documentElement, options);
607
+ if (document.body) observer.observe(document.body, options);
608
+ observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
609
+ } catch (_) { /* ignore */ }
610
+ }
611
+
612
+ const CSS_ID = "dsh-session-manager/session-manager";
613
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_ID) + "]") === null) {
614
+ const tag = document.createElement("style");
615
+ tag.dataset.plugin = "dsh-session-manager";
616
+ tag.dataset.pluginCss = CSS_ID;
617
+ tag.textContent = css;
618
+ document.head.appendChild(tag);
619
+ }
620
+
621
+
622
+ // Anchor the session manager panel to the sidebar right edge so it
623
+ // always fits within the sidebar area, regardless of viewport size.
624
+ const SM_PANEL_CSS_ID = "dsh-session-manager/session-manager-anchor";
625
+ const installAnchor = () => {
626
+ if (typeof document === "undefined") return;
627
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(SM_PANEL_CSS_ID) + "]") !== null) return;
628
+ const tag = document.createElement("style");
629
+ tag.dataset.plugin = "dsh-session-manager";
630
+ tag.dataset.pluginCss = SM_PANEL_CSS_ID;
631
+ tag.textContent = ":root{--sm-panel-left:8px}";
632
+ document.head.appendChild(tag);
633
+ const updateLeft = () => {
634
+ let right = 8;
635
+ const candidates = [
636
+ "[data-dsh-sidebar]",
637
+ "[data-testid=sidebar]",
638
+ "[aria-label=会话][role=navigation]",
639
+ "aside",
640
+ ".dsw-sidebar"
641
+ ];
642
+ for (const sel of candidates) {
643
+ const el = document.querySelector(sel);
644
+ if (el !== null) {
645
+ const rect = el.getBoundingClientRect();
646
+ if (rect.right > 100 && rect.right < window.innerWidth - 40) {
647
+ right = Math.round(rect.right) + 8;
648
+ break;
649
+ }
650
+ }
651
+ }
652
+ document.documentElement.style.setProperty("--sm-panel-left", right + "px");
653
+ };
654
+ updateLeft();
655
+ window.addEventListener("resize", updateLeft);
656
+ };
657
+ try { installAnchor(); } catch (_) {}
658
+
659
+ const h = (type, props, ...children) => React.createElement(type, props, ...children);
660
+ const callApi = (path, payload) => fetch(API + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload || {}) }).then((r) => r.json());
661
+ const fetchApi = (path, options) => fetch(API + path, options).then((r) => r.json());
662
+ // Run a bulk action across many sessionIds. /batch already does per-id
663
+ // error reporting, so partial failures come back as {summary, items}
664
+ // with per-id success/failed/skipped status. Cancellation is best-effort
665
+ // because the v0.1.7 host does not reliably support req.on("close").
666
+ async function runBatch(args, onProgress) {
667
+ const { action, sessionIds, payload } = args || {};
668
+ const controller = new AbortController();
669
+ try {
670
+ const response = await fetch(API + "/batch", {
671
+ method: "POST",
672
+ headers: { "content-type": "application/json" },
673
+ body: JSON.stringify({ action, sessionIds: sessionIds || [], payload: payload || {} }),
674
+ signal: controller.signal
675
+ });
676
+ const data = await response.json();
677
+ if (data && data.ok) {
678
+ const result = data.result || {};
679
+ const items = Array.isArray(result.items) ? result.items : [];
680
+ if (typeof onProgress === "function") onProgress({ done: items.length, total: (sessionIds || []).length, lastItem: items[items.length - 1] || null });
681
+ return { summary: result.summary || { total: items.length, success: 0, failed: 0, skipped: 0 }, items, aborted: !!result.aborted };
682
+ }
683
+ const error = new Error((data && data.error) || "batch failed");
684
+ error.code = (data && data.code) || "bad-request";
685
+ throw error;
686
+ } catch (error) {
687
+ if (error && error.name === "AbortError") return { summary: { total: 0, success: 0, failed: 0, skipped: 0 }, items: [], aborted: true };
688
+ throw error;
689
+ }
690
+ }
691
+
692
+ const EMPTY_ANNOTATION = Object.freeze({ favorite: false, reviewLater: false, tags: Object.freeze([]), note: "", priority: 3, revision: 0 });
693
+ const validAnnotation = value => value && typeof value.favorite === "boolean" && typeof value.reviewLater === "boolean" && Array.isArray(value.tags) && value.tags.every(tag => typeof tag === "string") && typeof value.note === "string" && (value.priority === null || Number.isInteger(value.priority) && value.priority >= 1 && value.priority <= 5) && Number.isSafeInteger(value.revision) && value.revision >= 0;
694
+ const annotationOf = (state, id) => Object.hasOwn(state.entries, id) ? state.entries[id] : EMPTY_ANNOTATION;
695
+ const annotationStore = (() => {
696
+ let state = { entries: {}, loaded: false, loading: false, error: null, pending: new Set() };
697
+ const listeners = new Set();
698
+ let tail = Promise.resolve(); let loading; let refreshAgain = false;
699
+ const publish = patch => { state = { ...state, ...patch }; for (const listener of [...listeners]) listener(); };
700
+ const enqueue = operation => { const result = tail.then(operation); tail = result.catch(() => {}); return result; };
701
+ const apiError = data => Object.assign(new Error(data?.error || "Annotation request failed"), { code: data?.code });
702
+ const store = {
703
+ notify: null,
704
+ getSnapshot: () => state,
705
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
706
+ hasSubscribers: () => listeners.size > 0,
707
+ load(force = false) {
708
+ if (loading) { if (force) refreshAgain = true; return loading; }
709
+ if (state.loaded && !force) return Promise.resolve();
710
+ publish({ loading: true, error: null });
711
+ loading = enqueue(async () => {
712
+ const data = await fetchApi("/annotations", { cache: "no-store" });
713
+ if (!data?.ok) throw apiError(data);
714
+ const entries = data.result?.annotations;
715
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)
716
+ || Object.values(entries).some(value => !validAnnotation(value))) throw new Error("Invalid annotation response");
717
+ publish({ entries, loaded: true, error: null });
718
+ }).catch(error => { publish({ error: error instanceof Error ? error.message : String(error) }); })
719
+ .finally(() => { loading = null; publish({ loading: false }); if (refreshAgain) { refreshAgain = false; void store.load(true); } });
720
+ return loading;
721
+ },
722
+ save(id, patch, expectedRevision) {
723
+ if (state.pending.has(id)) return Promise.reject(new Error("This session's annotations are being saved"));
724
+ if (!state.loaded || state.error) return Promise.reject(new Error(state.error || "Annotations have not loaded yet"));
725
+ publish({ pending: new Set([...state.pending, id]) });
726
+ return enqueue(async () => {
727
+ const data = await callApi("/annotations", { sessionId: id, patch, ...(expectedRevision !== undefined ? { expectedRevision } : {}) });
728
+ if (!data?.ok) throw apiError(data);
729
+ const annotation = data.result?.annotation;
730
+ if (!validAnnotation(annotation)) throw new Error("Invalid annotation response");
731
+ publish({ entries: { ...state.entries, [id]: annotation } });
732
+ try { store.notify?.(); } catch (_) { /* cross-tab sync is best-effort */ }
733
+ return annotation;
734
+ }).finally(() => { const pending = new Set(state.pending); pending.delete(id); publish({ pending }); });
735
+ }
736
+ };
737
+ return store;
738
+ })();
739
+ const useAnnotations = (refreshOnMount = false) => {
740
+ const state = useSyncExternalStore(annotationStore.subscribe, annotationStore.getSnapshot, annotationStore.getSnapshot);
741
+ useEffect(() => { void annotationStore.load(refreshOnMount); }, [refreshOnMount]);
742
+ return state;
743
+ };
744
+ const annotationButtons = (id, title, t, state, className, onToggle, onEdit, disabled = false, editorOpen = false) => {
745
+ const value = annotationOf(state, id);
746
+ const busy = disabled || !state.loaded || !!state.error || state.pending.has(id);
747
+ const favoriteLabel = t(value.favorite ? "marks.favorite.remove" : "marks.favorite.add") + ": " + title;
748
+ const reviewLabel = t(value.reviewLater ? "marks.review.remove" : "marks.review.add") + ": " + title;
749
+ return h(React.Fragment, null,
750
+ h("button", { type: "button", className: className + " sm-markIcon" + (value.favorite ? " sm-markOn" : ""), disabled: busy, "aria-label": favoriteLabel, title: favoriteLabel, "aria-pressed": value.favorite, onClick: () => onToggle({ favorite: !value.favorite }) }, value.favorite ? "★" : "☆"),
751
+ h("button", { type: "button", className: className + " sm-markIcon" + (value.reviewLater ? " sm-markOn" : ""), disabled: busy, "aria-label": reviewLabel, title: reviewLabel, "aria-pressed": value.reviewLater, onClick: () => onToggle({ reviewLater: !value.reviewLater }) }, "◷"),
752
+ h("button", { type: "button", className: className + (editorOpen ? " sm-annotationToggleOpen sm-headerBtnActive" : ""), disabled: busy, "aria-label": t("marks.edit.aria") + ": " + title, title: t("marks.edit.aria"), ...(className === "sm-headerBtn" ? { "aria-expanded": editorOpen } : {}), onClick: e => onEdit(e) }, t(className === "sm-headerBtn" ? "marks.header.edit" : "marks.edit"), className === "sm-headerBtn" ? h("span", { className: "sm-priorityBadge sm-priority-" + (value.priority ?? 3), title: t("marks.priority." + (value.priority ?? 3)) }, "P" + (value.priority ?? 3)) : null)
753
+ );
754
+ };
755
+
756
+
757
+ // Inline clipboard parser mirroring lib/clipboard-parser.js. The server
758
+ // re-validates the patch via annotationStore.save, so this helper only
759
+ // needs to be permissive enough to surface obvious mistakes early.
760
+ const TAG_LIMITS = { tags: 20, tagLength: 32, noteLength: 2000 };
761
+ const TAG_REJECT_RE = /[,,\x00-\x1f]/;
762
+ // Recover the most useful JSON object from a possibly noisy
763
+ // clipboard string. Tries, in order:
764
+ // 1. JSON.parse on the trimmed text.
765
+ // 2. JSON.parse inside a Markdown \`\`\`json ... \`\`\` fence.
766
+ // 3. JSON.parse on the first balanced {...} the scanner finds.
767
+ // 4. JSON.parse after repairing common AI mistakes
768
+ // (smart quotes, full-width punctuation, stray backslashes,
769
+ // trailing commas).
770
+ // Every failing attempt appends the underlying error so the user can
771
+ // see what actually went wrong.
772
+ const parseImportedAnnotation = raw => {
773
+ if (typeof raw !== "string" || raw.trim() === "") return { ok: false, error: "剪贴板为空 / Clipboard is empty" };
774
+ let work = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
775
+ work = work.replace(/^\s+|\s+$/g, "");
776
+ const fence = work.match(/^```(?:json|JSON)?\s*([\s\S]*?)\s*```$/);
777
+ const scanForObject = text => {
778
+ const len = text.length;
779
+ for (let i = 0; i < len; i++) {
780
+ if (text[i] !== "{") continue;
781
+ let depth = 0, inString = false, escape = false;
782
+ for (let j = i; j < len; j++) {
783
+ const c = text[j];
784
+ if (inString) {
785
+ if (escape) escape = false;
786
+ else if (c === String.fromCharCode(92)) escape = true;
787
+ else if (c === String.fromCharCode(34)) inString = false;
788
+ continue;
789
+ }
790
+ if (c === String.fromCharCode(34)) inString = true;
791
+ else if (c === "{") depth++;
792
+ else if (c === "}") { depth--; if (depth === 0) return text.slice(i, j + 1); }
793
+ }
794
+ }
795
+ return null;
796
+ };
797
+ const repairLooseJson = text => text
798
+ // Smart quotes from Chinese / English typography.
799
+ .replace(/[“”]/g, "\"")
800
+ .replace(/[‘’]/g, "'")
801
+ // Stray single backslashes that aren't part of a valid JSON escape.
802
+ // Walk the string char-by-char to avoid touching \", \, \/, \n etc.
803
+ .replace(/\\(?![\\"/bfnrtu])/g, "\\\\");
804
+ const attempts = [];
805
+ const tryParse = (label, value) => {
806
+ try { return JSON.parse(value); }
807
+ catch (error) { attempts.push(label + ": " + error.message); return null; }
808
+ };
809
+ let parsed;
810
+ const primary = fence ? fence[1] : work;
811
+ parsed = tryParse("原始", primary) || tryParse("修复引号/反斜杠", repairLooseJson(primary));
812
+ if (parsed === null) {
813
+ const scanned = scanForObject(work);
814
+ if (scanned !== null) {
815
+ parsed = tryParse("扫描对象", scanned) || tryParse("扫描对象+修复", repairLooseJson(scanned));
816
+ }
817
+ }
818
+ if (parsed === null) {
819
+ return { ok: false, error: "剪贴板中的 JSON 无法解析 — " + attempts.join(";") };
820
+ }
821
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
822
+ return { ok: false, error: "剪贴板内容不是 JSON 对象" };
823
+ }
824
+ const allowed = new Set(["tags", "note", "priority"]);
825
+ const warnings = [];
826
+ for (const key of Object.keys(parsed)) if (!allowed.has(key)) warnings.push({ field: key, kind: "ignored" });
827
+ let tags = [];
828
+ if ("tags" in parsed) {
829
+ if (!Array.isArray(parsed.tags)) return { ok: false, error: "tags must be an array of strings" };
830
+ const seen = new Set();
831
+ for (const item of parsed.tags) {
832
+ if (typeof item !== "string") { warnings.push({ field: "tags", kind: "rejected" }); continue; }
833
+ const tag = item.trim();
834
+ if (!tag) continue;
835
+ if (tag.length > TAG_LIMITS.tagLength || TAG_REJECT_RE.test(tag)) {
836
+ warnings.push({ field: "tags", kind: "rejected", value: tag });
837
+ continue;
838
+ }
839
+ const key = tag.toLowerCase();
840
+ if (seen.has(key)) continue;
841
+ seen.add(key);
842
+ if (tags.length >= TAG_LIMITS.tags) { warnings.push({ field: "tags", kind: "truncated" }); continue; }
843
+ tags.push(tag);
844
+ }
845
+ }
846
+ let note = "";
847
+ if ("note" in parsed) {
848
+ if (typeof parsed.note !== "string") return { ok: false, error: "note must be a string" };
849
+ if (parsed.note.includes(String.fromCharCode(0))) return { ok: false, error: "note must not contain NUL characters" };
850
+ if (parsed.note.length > TAG_LIMITS.noteLength) {
851
+ warnings.push({ field: "note", kind: "truncated", from: parsed.note.length, to: TAG_LIMITS.noteLength });
852
+ note = parsed.note.slice(0, TAG_LIMITS.noteLength);
853
+ } else note = parsed.note;
854
+ }
855
+ let priority;
856
+ if ("priority" in parsed) {
857
+ // AI-returned null is treated as the default priority (3 = Normal) so
858
+ // the UI never has to special-case unset entries.
859
+ if (parsed.priority === null) priority = 3;
860
+ else if (Number.isInteger(parsed.priority) && parsed.priority >= 1 && parsed.priority <= 5) priority = parsed.priority;
861
+ else warnings.push({ field: "priority", kind: "rejected", value: parsed.priority });
862
+ }
863
+ const annotation = { tags, note };
864
+ if (priority !== undefined) annotation.priority = priority;
865
+ return { ok: true, annotation, warnings };
866
+ };
867
+ const CLIPBOARD_PROMPT_TEXT = {
868
+ zh: [
869
+ "请根据当前对话内容,为这个会话生成整理标记。",
870
+ "",
871
+ "要求:",
872
+ "1. 生成最多 20 个标签,每个标签最多 32 个字符。",
873
+ "2. 标签应简洁、具体,便于之后搜索。",
874
+ "3. 标签不要重复,不要生成无意义的标签。",
875
+ "4. 生成一段不超过 2000 个字符的备注,总结本次对话的主题、结论、未完成事项或下一步行动。",
876
+ "5. 不要编造对话中没有出现的信息。",
877
+ "6. 只返回合法 JSON,不要使用 Markdown 代码块,不要附加解释。",
878
+ "",
879
+ "返回格式:",
880
+ "{",
881
+ ' "tags": ["标签1", "标签2"],',
882
+ ' "note": "会话备注"',
883
+ "}"
884
+ ].join(String.fromCharCode(10)),
885
+ en: [
886
+ "Summarize this conversation into session annotations.",
887
+ "",
888
+ "Requirements:",
889
+ "1. Up to 20 tags, at most 32 characters each.",
890
+ "2. Tags should be concise and help future searches.",
891
+ "3. Do not repeat tags or invent meaningless ones.",
892
+ "4. Write a note under 2000 characters summarizing the topic, conclusions, outstanding items and next steps.",
893
+ "5. Do not fabricate details that are not present in the conversation.",
894
+ "6. Return only valid JSON, no Markdown fences, no extra commentary.",
895
+ "",
896
+ "Return shape:",
897
+ "{",
898
+ ' "tags": ["tag1", "tag2"],',
899
+ ' "note": "session note"',
900
+ "}"
901
+ ].join(String.fromCharCode(10))
902
+ };
903
+ const promptFor = lang => CLIPBOARD_PROMPT_TEXT[lang] || CLIPBOARD_PROMPT_TEXT.zh;
904
+ function BatchPreviewDialog(props) {
905
+ const t = typeof props.t === "function" ? props.t : (key) => key;
906
+ const open = props.open === true;
907
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
908
+ const onExecute = typeof props.onExecute === "function" ? props.onExecute : () => {};
909
+ const actionLabel = props.actionLabel || "";
910
+ const sessions = Array.isArray(props.sessions) ? props.sessions : [];
911
+ const warning = typeof props.warning === "string" ? props.warning : "";
912
+ const confirmLabel = props.confirmLabel || t("bulk.dialog.execute");
913
+ const cancelLabel = props.cancelLabel || t("bulk.dialog.cancel");
914
+ const danger = props.danger === true;
915
+ const [expanded, setExpanded] = useState(false);
916
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
917
+ const escStateRef = useRef({});
918
+ escStateRef.current = { onCancel };
919
+ useEffect(() => {
920
+ if (!open) return;
921
+ const handler = (e) => { if (e.key !== "Escape" || e.defaultPrevented) return; e.preventDefault(); e.stopPropagation(); escStateRef.current.onCancel(); };
922
+ window.addEventListener("keydown", handler);
923
+ return () => window.removeEventListener("keydown", handler);
924
+ }, [open]);
925
+ if (!open) return null;
926
+ const showExpand = sessions.length > 12;
927
+ const visible = expanded ? sessions : sessions.slice(0, 12);
928
+ return h("div", { className: "sm-nativeDialogLayer sm-bulkDialog", role: "presentation", style: { zIndex: zIndex || undefined } },
929
+ h("section", { className: "sm-nativeDialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "sm-batch-preview-title" },
930
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-batch-preview-title", className: "sm-nativeDialogTitle" }, t("bulk.dialog.title", { action: actionLabel }))),
931
+ h("div", { className: "sm-nativeDialogBody" },
932
+ h("div", { className: "sm-bulkDialogSummary" }, t("bulk.dialog.summary", { count: sessions.length, action: actionLabel })),
933
+ warning ? h("div", { className: "sm-bulkDialogWarn" }, warning) : null,
934
+ h("div", { className: "sm-bulkSessionList" },
935
+ visible.map((s, i) => h("div", { key: (s.id || i) + "-" + i, className: "sm-bulkSessionItem", title: s.id || "" }, s.displayTitle || s.id || "—"))
936
+ ),
937
+ showExpand ? h("button", { type: "button", className: "sm-resetFilters", onClick: () => setExpanded(value => !value) }, expanded ? t("panel.reset") : "…(+" + (sessions.length - 12) + ")") : null
938
+ ),
939
+ h("div", { className: "sm-nativeDialogFooter" },
940
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogCancel", onClick: onCancel }, cancelLabel),
941
+ h("button", { type: "button", className: "sm-nativeDialogButton" + (danger ? " sm-nativeDialogDanger" : " sm-nativeDialogConfirm"), onClick: onExecute }, confirmLabel)
942
+ )
943
+ )
944
+ );
945
+ }
946
+
947
+ function BatchProgressDialog(props) {
948
+ const t = typeof props.t === "function" ? props.t : (key) => key;
949
+ const open = props.open === true;
950
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
951
+ const actionLabel = props.actionLabel || "";
952
+ const done = Number.isInteger(props.done) ? props.done : 0;
953
+ const total = Number.isInteger(props.total) ? props.total : 0;
954
+ const completed = props.completed === true;
955
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
956
+ const cancelLabel = props.cancelLabel || t("bulk.progress.cancel");
957
+ if (!open) return null;
958
+ const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
959
+ return h("div", { className: "sm-nativeDialogLayer sm-bulkDialog", role: "presentation", style: { zIndex: zIndex || undefined } },
960
+ h("section", { className: "sm-nativeDialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "sm-batch-progress-title" },
961
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-batch-progress-title", className: "sm-nativeDialogTitle" }, t("bulk.progress.title", { action: actionLabel }))),
962
+ h("div", { className: "sm-nativeDialogBody" },
963
+ h("div", { className: "sm-bulkDialogSummary" }, completed ? t("bulk.progress.completed", { done, total }) : t("bulk.progress.current", { done, total })),
964
+ h("div", { className: "sm-bulkProgressBar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": total, "aria-valuenow": done },
965
+ h("div", { className: "sm-bulkProgressFill", style: { width: pct + "%" } })
966
+ )
967
+ ),
968
+ h("div", { className: "sm-nativeDialogFooter" },
969
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogCancel", disabled: completed, onClick: onCancel }, cancelLabel)
970
+ )
971
+ )
972
+ );
973
+ }
974
+
975
+ function BatchResultDialog(props) {
976
+ const t = typeof props.t === "function" ? props.t : (key) => key;
977
+ const open = props.open === true;
978
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
979
+ const actionLabel = props.actionLabel || "";
980
+ const items = Array.isArray(props.items) ? props.items : [];
981
+ const onClose = typeof props.onClose === "function" ? props.onClose : () => {};
982
+ const onRetry = typeof props.onRetry === "function" ? props.onRetry : () => {};
983
+ const closeLabel = props.closeLabel || t("bulk.result.close");
984
+ const retryLabel = props.retryLabel || t("bulk.result.retry");
985
+ const escStateRef = useRef({});
986
+ escStateRef.current = { onClose };
987
+ useEffect(() => {
988
+ if (!open) return;
989
+ const handler = (e) => { if (e.key !== "Escape" || e.defaultPrevented) return; e.preventDefault(); e.stopPropagation(); escStateRef.current.onClose(); };
990
+ window.addEventListener("keydown", handler);
991
+ return () => window.removeEventListener("keydown", handler);
992
+ }, [open]);
993
+ if (!open) return null;
994
+ const groups = { success: [], failed: [], skipped: [] };
995
+ for (const item of items) {
996
+ const status = item && item.status;
997
+ if (status === "success") groups.success.push(item);
998
+ else if (status === "skipped") groups.skipped.push(item);
999
+ else groups.failed.push(item);
1000
+ }
1001
+ const renderGroup = (labelKey, group, reasonClass) => {
1002
+ if (group.length === 0) return null;
1003
+ return h("div", { key: labelKey, className: "sm-bulkResultGroup" },
1004
+ h("div", { className: "sm-bulkResultHeader" }, t(labelKey, { count: group.length })),
1005
+ h("div", { className: "sm-bulkResultList" },
1006
+ group.map((item, i) => h("div", { key: (item.sessionId || i) + "-" + i, className: "sm-bulkResultItem", title: item.sessionId || "" },
1007
+ h("div", { className: "sm-bulkResultItemTitle" }, item.displayTitle || item.sessionId || "—"),
1008
+ reasonClass && (item.reason || item.error) ? h("div", { className: reasonClass }, item.reason || item.error) : null
1009
+ ))
1010
+ )
1011
+ );
1012
+ };
1013
+ return h("div", { className: "sm-nativeDialogLayer sm-bulkDialog", role: "presentation", style: { zIndex: zIndex || undefined } },
1014
+ h("section", { className: "sm-nativeDialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "sm-batch-result-title" },
1015
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-batch-result-title", className: "sm-nativeDialogTitle" }, t("bulk.result.title", { action: actionLabel }))),
1016
+ h("div", { className: "sm-nativeDialogBody" },
1017
+ renderGroup("bulk.result.successGroup", groups.success),
1018
+ renderGroup("bulk.result.failedGroup", groups.failed, "sm-bulkResultItemReason"),
1019
+ renderGroup("bulk.result.skippedGroup", groups.skipped, "sm-bulkResultItemReasonSkip")
1020
+ ),
1021
+ h("div", { className: "sm-nativeDialogFooter" },
1022
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogCancel", onClick: onClose }, closeLabel),
1023
+ groups.failed.length > 0 ? h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogConfirm", onClick: () => onRetry(groups.failed.map(item => item.sessionId)) }, retryLabel) : null
1024
+ )
1025
+ )
1026
+ );
1027
+ }
1028
+
1029
+ function BulkActionBar(props) {
1030
+ const t = props.t;
1031
+ const selectedCount = props.selectedCount || 0;
1032
+ const filteredCount = props.filteredCount || 0;
1033
+ const busy = props.busy === true;
1034
+ const onAction = props.onAction;
1035
+ const onOpenTagDialog = props.onOpenTagDialog;
1036
+ const onOpenPriorityDialog = props.onOpenPriorityDialog;
1037
+ const onOpenMoveDialog = props.onOpenMoveDialog;
1038
+ const onOpenPresetDialog = props.onOpenPresetDialog;
1039
+ const disableNonDestructive = selectedCount === 0 || busy;
1040
+ const destructiveBusy = selectedCount === 0 || busy;
1041
+ return h("div", { className: "sm-bulkBar", role: "toolbar", "aria-label": t("bulk.heading") },
1042
+ h("span", { className: "sm-bulkSummary" }, t("bulk.selectedFiltered", { selected: selectedCount, filtered: filteredCount })),
1043
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("archive") }, t("bulk.archive")),
1044
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("unarchive") }, t("bulk.unarchive")),
1045
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("favorite") }, t("bulk.favorite")),
1046
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("unfavorite") }, t("bulk.unfavorite")),
1047
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("review") }, t("bulk.review")),
1048
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("unreview") }, t("bulk.unreview")),
1049
+ // Row 2 of the 3x6 grid (添加标签 / 清空标签 / 设置优先级 / 移动至工作区 / 迁移预设 / 删除会话).
1050
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: onOpenTagDialog }, t("bulk.tagsAdd")),
1051
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: () => onAction("remove-tags", { tags: [] }) }, t("bulk.tagsRemove")),
1052
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: onOpenPriorityDialog }, t("bulk.priority")),
1053
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: onOpenMoveDialog }, t("bulk.moveBtn")),
1054
+ h("button", { type: "button", className: "sm-bulkBtn", disabled: disableNonDestructive, onClick: onOpenPresetDialog }, t("bulk.presetMigrate")),
1055
+ h("button", { type: "button", className: "sm-bulkBtn sm-bulkBtnDanger", disabled: destructiveBusy, onClick: () => onAction("delete", null, true) }, t("bulk.delete"))
1056
+ );
1057
+ }
1058
+
1059
+ function BulkTagInputDialog(props) {
1060
+ const t = typeof props.t === "function" ? props.t : (key) => key;
1061
+ const open = props.open === true;
1062
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
1063
+ const title = props.title || t("bulk.tagsAdd");
1064
+ const placeholder = props.placeholder || t("bulk.tags.placeholder");
1065
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
1066
+ const onApply = typeof props.onApply === "function" ? props.onApply : () => {};
1067
+ const confirmLabel = props.confirmLabel || t("bulk.confirm.tagsAdd");
1068
+ const [value, setValue] = useState("");
1069
+ useEffect(() => { if (open) setValue(""); }, [open]);
1070
+ const escStateRef = useRef({});
1071
+ escStateRef.current = { onCancel };
1072
+ useEffect(() => {
1073
+ if (!open) return;
1074
+ const handler = (e) => { if (e.key !== "Escape" || e.defaultPrevented) return; e.preventDefault(); e.stopPropagation(); escStateRef.current.onCancel(); };
1075
+ window.addEventListener("keydown", handler);
1076
+ return () => window.removeEventListener("keydown", handler);
1077
+ }, [open]);
1078
+ if (!open) return null;
1079
+ const submit = () => {
1080
+ const tags = (value || "").split(/[,,\n]/).map(s => s.trim()).filter(Boolean);
1081
+ onApply(tags);
1082
+ };
1083
+ return h("div", { className: "sm-nativeDialogLayer sm-bulkDialog", role: "presentation", style: { zIndex: zIndex || undefined } },
1084
+ h("section", { className: "sm-nativeDialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "sm-bulk-tags-title" },
1085
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-bulk-tags-title", className: "sm-nativeDialogTitle" }, title)),
1086
+ h("div", { className: "sm-nativeDialogBody" },
1087
+ h("textarea", { className: "sm-noteInput", "aria-label": placeholder, value, placeholder, rows: 3, onChange: (e) => setValue(e.target.value), autoFocus: true })
1088
+ ),
1089
+ h("div", { className: "sm-nativeDialogFooter" },
1090
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogCancel", onClick: onCancel }, t("bulk.dialog.cancel")),
1091
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogConfirm", onClick: submit }, confirmLabel)
1092
+ )
1093
+ )
1094
+ );
1095
+ }
1096
+
1097
+ function BulkChoiceDialog(props) {
1098
+ const t = typeof props.t === "function" ? props.t : (key) => key;
1099
+ const open = props.open === true;
1100
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
1101
+ const title = props.title || "";
1102
+ const description = props.description || "";
1103
+ const options = Array.isArray(props.options) ? props.options : [];
1104
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
1105
+ const onApply = typeof props.onApply === "function" ? props.onApply : () => {};
1106
+ const confirmLabel = props.confirmLabel || t("bulk.dialog.execute");
1107
+ const [value, setValue] = useState("");
1108
+ useEffect(() => { if (open) setValue(""); }, [open]);
1109
+ const escStateRef = useRef({});
1110
+ escStateRef.current = { onCancel };
1111
+ useEffect(() => {
1112
+ if (!open) return;
1113
+ const handler = (e) => { if (e.key !== "Escape" || e.defaultPrevented) return; e.preventDefault(); e.stopPropagation(); escStateRef.current.onCancel(); };
1114
+ window.addEventListener("keydown", handler);
1115
+ return () => window.removeEventListener("keydown", handler);
1116
+ }, [open]);
1117
+ if (!open) return null;
1118
+ return h("div", { className: "sm-nativeDialogLayer sm-bulkDialog", role: "presentation", style: { zIndex: zIndex || undefined } },
1119
+ h("section", { className: "sm-nativeDialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "sm-bulk-choice-title" },
1120
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-bulk-choice-title", className: "sm-nativeDialogTitle" }, title)),
1121
+ h("div", { className: "sm-nativeDialogBody" },
1122
+ description ? h("div", { className: "sm-bulkDialogSummary" }, description) : null,
1123
+ options.length === 0
1124
+ ? h("div", { className: "sm-empty" }, t("migrate.noPresets"))
1125
+ : h("select", { className: "sm-viewSelect", value, onChange: (e) => setValue(e.target.value), "aria-label": title, autoFocus: true },
1126
+ h("option", { value: "" }, "—"),
1127
+ options.map((opt) => h("option", { key: opt.value, value: opt.value }, opt.label))
1128
+ )
1129
+ ),
1130
+ h("div", { className: "sm-nativeDialogFooter" },
1131
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogCancel", onClick: onCancel }, t("bulk.dialog.cancel")),
1132
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogConfirm", disabled: value === "", onClick: () => onApply(value) }, confirmLabel)
1133
+ )
1134
+ )
1135
+ );
1136
+ }
1137
+ function AnnotationDialog({ sessionId, displayTitle, t, onClose, anchor }) {
1138
+ const annotations = useAnnotations();
1139
+ const latest = annotationOf(annotations, sessionId);
1140
+ const [base, setBase] = useState(() => latest);
1141
+ const [favorite, setFavorite] = useState(() => latest.favorite);
1142
+ const [reviewLater, setReviewLater] = useState(() => latest.reviewLater);
1143
+ const [tagsText, setTagsText] = useState(() => latest.tags.join(", "));
1144
+ const [note, setNote] = useState(() => latest.note);
1145
+ const [priority, setPriority] = useState(() => String(latest.priority ?? 3));
1146
+ const [busy, setBusy] = useState(false);
1147
+ const [error, setError] = useState(null);
1148
+ const dialogRef = useRef(null);
1149
+ // Claim a fresh z-index so this popup always sits above any earlier
1150
+ // popup the user may have opened (annotation popup typically opens on
1151
+ // top of a confirm/move/migrate dialog, so we need a guaranteed-
1152
+ // higher z-index than those static CSS values).
1153
+ const [zIndex] = useState(() => nextDialogZ());
1154
+ const dirty = favorite !== base.favorite || reviewLater !== base.reviewLater || tagsText !== base.tags.join(", ") || note !== base.note || priority !== String(base.priority ?? 3);
1155
+ const cancel = () => { if (busy || dirty && !window.confirm(t("marks.discard"))) return false; onClose(); return true; };
1156
+ const reload = () => {
1157
+ if (dirty && !window.confirm(t("marks.discard"))) return;
1158
+ setBase(latest); setFavorite(latest.favorite); setReviewLater(latest.reviewLater);
1159
+ setTagsText(latest.tags.join(", ")); setNote(latest.note); setPriority(String(latest.priority ?? 3)); setError(null);
1160
+ };
1161
+ const save = async () => {
1162
+ if (busy || annotations.pending.has(sessionId)) return;
1163
+ setBusy(true); setError(null);
1164
+ try {
1165
+ await annotationStore.save(sessionId, { favorite, reviewLater, tags: tagsText.split(/[,,\n]/).map(tag => tag.trim()).filter(Boolean), note, priority: Number(priority) }, base.revision);
1166
+ onClose();
1167
+ } catch (error) {
1168
+ setError(error instanceof Error ? error.message : String(error));
1169
+ if (error?.code === "annotation-conflict") await annotationStore.load(true);
1170
+ } finally { setBusy(false); }
1171
+ };
1172
+ const escStateRef = useRef({}); escStateRef.current = { busy, cancel };
1173
+ useEffect(() => {
1174
+ const handler = e => {
1175
+ if (e.key !== "Escape" || e.isComposing || e.keyCode === 229) return;
1176
+ if (e.defaultPrevented) return;
1177
+ if (escStateRef.current.busy) return;
1178
+ e.preventDefault(); e.stopPropagation();
1179
+ if (escStateRef.current.cancel()) {
1180
+ const ae = document.activeElement;
1181
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
1182
+ }
1183
+ };
1184
+ window.addEventListener("keydown", handler);
1185
+ return () => window.removeEventListener("keydown", handler);
1186
+ }, []);
1187
+
1188
+ const onKeyDown = e => {
1189
+ if (e.key !== "Tab" || !dialogRef.current) return;
1190
+ const controls = [...dialogRef.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled)')];
1191
+ if (!controls.length) return;
1192
+ const first = controls[0], last = controls[controls.length - 1];
1193
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
1194
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
1195
+ };
1196
+ const [importStatus, setImportStatus] = useState(null);
1197
+ const copyPrompt = async () => {
1198
+ if (busy) return;
1199
+ const text = promptFor(activeLanguage);
1200
+ try {
1201
+ if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === "function") await navigator.clipboard.writeText(text);
1202
+ else { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "absolute"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); }
1203
+ setImportStatus(t("marks.import.copy.copied"));
1204
+ } catch (err) { setImportStatus(t("marks.import.copy.failed", { message: err instanceof Error ? err.message : String(err) })); }
1205
+ };
1206
+ const [importText, setImportText] = useState("");
1207
+ const pasteFromInput = () => {
1208
+ if (busy) return;
1209
+ const text = (importText || "").trim();
1210
+ if (!text) { setImportStatus(t("marks.import.failure", { message: t("marks.import.empty") })); return; }
1211
+ if (dirty && !window.confirm(t("marks.import.discard"))) { setImportStatus(null); return; }
1212
+ const result = parseImportedAnnotation(text);
1213
+ if (!result.ok) { setImportStatus(t("marks.import.failure", { message: result.error })); return; }
1214
+ const ann = result.annotation;
1215
+ if (Array.isArray(ann.tags)) setTagsText(ann.tags.join(", "));
1216
+ if (typeof ann.note === "string") setNote(ann.note);
1217
+ if ("priority" in ann) setPriority(String(ann.priority ?? 3));
1218
+ setError(null);
1219
+ const warningLines = (result.warnings || []).map(w => {
1220
+ const field = w.field || t("marks.import.unknown");
1221
+ const detail = w.kind === "truncated" && typeof w.from === "number" && typeof w.to === "number"
1222
+ ? field + ": " + w.from + " -> " + w.to
1223
+ : w.kind === "rejected" && w.value !== undefined ? field + ": " + JSON.stringify(w.value)
1224
+ : w.kind ? field + " (" + w.kind + ")" : field;
1225
+ return " - " + detail + (w.reason ? " - " + w.reason : "");
1226
+ });
1227
+ if (warningLines.length) setImportStatus(t("marks.import.warnings") + String.fromCharCode(10) + warningLines.join(String.fromCharCode(10)));
1228
+ else setImportStatus(t("marks.import.success"));
1229
+ };
1230
+ // Detect the active UI language by probing a key both dictionaries define.
1231
+ // Falls back to the cached window flag (set elsewhere if available) and ultimately
1232
+ // to "zh" so the prompt never reads empty.
1233
+ const detectLanguage = (translator) => {
1234
+ if (typeof window !== "undefined" && window.__smActiveLanguage) return window.__smActiveLanguage;
1235
+ const sample = translator("marks.favorite");
1236
+ if (typeof sample === "string") {
1237
+ if (sample === "Favorite") return "en";
1238
+ if (sample === "收藏") return "zh";
1239
+ }
1240
+ return "zh";
1241
+ };
1242
+ const activeLanguage = detectLanguage(t);
1243
+ const hasAnchor = anchor && typeof anchor.top === "number" && typeof anchor.left === "number";
1244
+ const layerClass = "sm-nativeDialogLayer sm-annotationDialog sm-confirmDialog" + (hasAnchor ? " sm-annotationDialog-anchored" : "");
1245
+ const surfaceClass = "sm-nativeDialog sm-panelDialog sm-annotationSurface" + (hasAnchor ? " sm-annotationSurface-anchored" : "");
1246
+ // Default position matches ConfirmDialog / MoveDialog so all inner
1247
+ // dialogs line up at top calc(50vh - 90px), left calc(50vw + 308px).
1248
+ const layerStyle = {
1249
+ zIndex: zIndex || undefined,
1250
+ ...(hasAnchor ? { position: "fixed", top: anchor.top + "px", left: anchor.left + "px" } : {})
1251
+ };
1252
+ return h("div", { className: layerClass, style: layerStyle, role: "presentation" },
1253
+ h("section", { className: surfaceClass, role: "dialog", "aria-labelledby": "sm-annotation-title", ref: dialogRef, onKeyDown },
1254
+ h("div", { className: "sm-nativeDialogHeader" }, h("h2", { id: "sm-annotation-title", className: "sm-nativeDialogTitle" }, t("marks.title"))),
1255
+ h("div", { className: "sm-annotationBody" },
1256
+ h("div", { className: "sm-annotationTarget", title: displayTitle || sessionId }, displayTitle || sessionId),
1257
+ h("div", { className: "sm-annotationChecks" },
1258
+ h("label", null, h("input", { type: "checkbox", "aria-label": t("marks.favorite"), checked: favorite, disabled: busy, onChange: e => setFavorite(e.target.checked) }), t("marks.favorite")),
1259
+ h("label", { className: "sm-annotationPriority" },
1260
+ h("span", null, t("marks.priority")),
1261
+ h("select", { className: "sm-viewSelect sm-prioritySelectInline", "aria-label": t("marks.priority"), value: priority, disabled: busy, onChange: e => setPriority(e.target.value) },
1262
+ [1,2,3,4,5].map(value => h("option", { key: value, value: String(value) }, t("marks.priority." + value)))
1263
+ )
1264
+ ),
1265
+ h("label", null, h("input", { type: "checkbox", "aria-label": t("marks.review"), checked: reviewLater, disabled: busy, onChange: e => setReviewLater(e.target.checked) }), t("marks.review")),
1266
+ h("small", null, t("marks.priority.help"))
1267
+ ),
1268
+ h("label", { className: "sm-viewField sm-fieldWithClear" }, h("span", null, t("marks.tags")), h("div", { className: "sm-fieldRow sm-fieldRowNote" }, h("textarea", { className: "sm-noteInput", "aria-label": t("marks.tags"), value: tagsText, maxLength: 1000, autoFocus: true, rows: 3, placeholder: t("marks.tags.placeholder"), disabled: busy, onChange: e => setTagsText(e.target.value) }), tagsText && !busy ? h("button", { type: "button", className: "sm-fieldClear sm-fieldClearNote", "aria-label": t("marks.tags.clear"), title: t("marks.tags.clear"), onClick: () => setTagsText("") }, String.fromCharCode(10005)) : null)),
1269
+ h("label", { className: "sm-viewField sm-fieldWithClear" }, h("span", null, t("marks.note")), h("div", { className: "sm-fieldRow sm-fieldRowNote" }, h("textarea", { className: "sm-noteInput", "aria-label": t("marks.note"), value: note, maxLength: 2000, rows: 3, placeholder: t("marks.note.placeholder"), disabled: busy, onChange: e => setNote(e.target.value) }), note && !busy ? h("button", { type: "button", className: "sm-fieldClear sm-fieldClearNote", "aria-label": t("marks.note.clear"), title: t("marks.note.clear"), onClick: () => setNote("") }, String.fromCharCode(10005)) : null)),
1270
+ latest.revision !== base.revision ? h("div", { className: "sm-markConflict", role: "status" }, t("marks.conflict"), h("button", { type: "button", className: "sm-resetFilters", disabled: busy, onClick: reload }, t("marks.reload"))) : null,
1271
+ annotations.error ? h("div", { className: "sm-error", role: "alert" }, t("marks.error", { message: annotations.error }), h("button", { type: "button", className: "sm-resetFilters", disabled: busy || annotations.loading, onClick: () => annotationStore.load(true) }, t("marks.retry"))) : null,
1272
+ error ? h("div", { className: "sm-error", role: "alert" }, error) : null
1273
+ ),
1274
+ h("div", { className: "sm-importGroup" },
1275
+ h("div", { className: "sm-importGroupButtons" },
1276
+ h("button", { type: "button", className: "sm-importBtn", disabled: busy || !importText.trim(), onClick: pasteFromInput }, t("marks.import.paste")),
1277
+ h("button", { type: "button", className: "sm-importBtn", disabled: busy, onClick: copyPrompt }, t("marks.import.copy"))),
1278
+ h("div", { className: "sm-fieldWithClear" },
1279
+ h("div", { className: "sm-fieldRow sm-fieldRowNote" },
1280
+ h("textarea", { className: "sm-noteInput", "aria-label": t("marks.import.paste.aria"), value: importText, placeholder: t("marks.import.paste.placeholder"), disabled: busy, rows: 3, onChange: e => setImportText(e.target.value) }),
1281
+ importText && !busy ? h("button", { type: "button", className: "sm-fieldClear sm-fieldClearNote", "aria-label": t("marks.import.paste.clear"), title: t("marks.import.paste.clear"), onClick: () => setImportText("") }, String.fromCharCode(10005)) : null))),
1282
+ importStatus ? h("div", { className: "sm-importStatus", role: "status" }, importStatus) : null,
1283
+ h("div", { className: "sm-nativeDialogFooter" },
1284
+ h("button", { type: "button", className: "sm-nativeDialogButton", disabled: busy, onClick: cancel }, t("marks.cancel")),
1285
+ h("button", { type: "button", className: "sm-nativeDialogButton sm-nativeDialogConfirm", disabled: busy || !annotations.loaded || !!annotations.error || latest.revision !== base.revision, onClick: save }, t(busy ? "marks.saving" : "marks.save"))
1286
+ )
1287
+ )
1288
+ );
1289
+ }
1290
+
1291
+
1292
+ const formatRelative = (ts, now) => {
1293
+ const timestamp = sessionTime(ts);
1294
+ if (timestamp === null) return "—";
1295
+ const diff = Math.max(0, now - timestamp);
1296
+ const s = Math.floor(diff / 1000);
1297
+ if (s < 60) return s + "s";
1298
+ const m = Math.floor(s / 60);
1299
+ if (m < 60) return m + "m";
1300
+ const hr = Math.floor(m / 60);
1301
+ if (hr < 24) return hr + "h";
1302
+ const d = Math.floor(hr / 24);
1303
+ if (d < 30) return d + "d";
1304
+ const mo = Math.floor(d / 30);
1305
+ if (mo < 12) return mo + "mo";
1306
+ return Math.floor(mo / 12) + "y";
1307
+ };
1308
+ const shortPath = (cwd) => {
1309
+ if (!cwd) return "";
1310
+ const parts = cwd.replace(/[\\\/]+$/, "").split(/[\\\/]/);
1311
+ const last = parts[parts.length - 1] || "";
1312
+ return last.length > 26 ? last.slice(0, 24) + "\u2026" : last;
1313
+ };
1314
+
1315
+ function ConfirmDialog(props) {
1316
+ const open = props.open === true;
1317
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
1318
+ const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
1319
+ const busy = props.busy === true;
1320
+ const danger = props.danger === true;
1321
+ // Claim a fresh z-index so this popup sits above any earlier popup.
1322
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
1323
+ const title = props.title || "";
1324
+ const description = props.description || "";
1325
+ const warning = props.warning || "";
1326
+ const confirmLabel = props.confirmLabel || "确认";
1327
+ const cancelLabel = props.cancelLabel || "取消";
1328
+ // Esc is handled at window level (issue #7): the layer-bound
1329
+ // onKeyDown was unreachable when the row button that opened
1330
+ // the dialog stayed focused. ConfirmDialog's portal to body
1331
+ // makes it a separate subtree from the panel section, so
1332
+ // keydown never bubbles through it. Enter was previously on
1333
+ // the layer too, but that races with the focused Cancel /
1334
+ // Confirm button's native Enter handler -- removing the layer
1335
+ // Enter leaves Enter handling purely to the focused button.
1336
+ const escStateRef = useRef({});
1337
+ escStateRef.current = { busy, onCancel };
1338
+ useEffect(() => {
1339
+ if (!open) return;
1340
+ const handler = (e) => {
1341
+ if (e.key !== "Escape") return;
1342
+ if (e.defaultPrevented) return;
1343
+ const s = escStateRef.current;
1344
+ if (s.busy) return;
1345
+ e.preventDefault();
1346
+ e.stopPropagation();
1347
+ s.onCancel();
1348
+ // See SessionManagerPanel for the rationale.
1349
+ const ae = document.activeElement;
1350
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
1351
+ };
1352
+ window.addEventListener("keydown", handler);
1353
+ return () => window.removeEventListener("keydown", handler);
1354
+ }, [open]);
1355
+ // Render inline as a sibling of the panel; createPortal to document.body broke under some hosts. Migrate and AnnotationDialog render inline; do the same here.
1356
+
1357
+
1358
+ if (!open) return null;
1359
+
1360
+ return h("div", {
1361
+ className: "sm-nativeDialogLayer sm-confirmDialog",
1362
+ style: {
1363
+ zIndex: zIndex || undefined,
1364
+ ...(props.anchor && typeof props.anchor.top === "number"
1365
+ ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px" }
1366
+ : {})
1367
+ },
1368
+ role: "presentation"
1369
+ },
1370
+
1371
+ h("section", {
1372
+ className: "sm-nativeDialog",
1373
+ role: "dialog",
1374
+ "aria-modal": "true",
1375
+ "aria-labelledby": "sm-confirm-dialog-title"
1376
+ },
1377
+ h("div", { className: "sm-nativeDialogHeader" },
1378
+ h("h2", { id: "sm-confirm-dialog-title", className: "sm-nativeDialogTitle" }, title)
1379
+ ),
1380
+ h("div", { className: "sm-nativeDialogBody" },
1381
+ description ? h("div", { className: "sm-nativeDialogDescription" }, description) : null,
1382
+ warning ? h("div", { className: "sm-warn" }, h("span", null, warning)) : null
1383
+ ),
1384
+ h("div", { className: "sm-nativeDialogFooter" },
1385
+ h("button", {
1386
+ type: "button",
1387
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
1388
+ disabled: busy,
1389
+ onClick: () => { if (!busy) onCancel(); }
1390
+ }, cancelLabel),
1391
+ h("button", {
1392
+ type: "button",
1393
+ className: "sm-nativeDialogButton" + (danger ? " sm-nativeDialogDanger" : " sm-nativeDialogConfirm"),
1394
+ disabled: busy,
1395
+ onClick: () => { if (!busy) onConfirm(); }
1396
+ }, confirmLabel)
1397
+ )
1398
+ )
1399
+ );
1400
+ }
1401
+
1402
+ function MoveDialog(props) {
1403
+ const t = typeof props.t === "function" ? props.t : (key) => key;
1404
+ const open = props.open === true;
1405
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
1406
+ const onConfirm = typeof props.onConfirm === "function" ? props.onConfirm : () => {};
1407
+ const busy = props.busy === true;
1408
+ const title = props.title || t("confirm.move.title");
1409
+ const description = props.description || "";
1410
+ const currentWorkspaceId = props.currentWorkspaceId || "";
1411
+ const confirmLabel = props.confirmLabel || t("confirm.move.confirm");
1412
+ const cancelLabel = props.cancelLabel || t("confirm.cancel");
1413
+ const error = props.error || "";
1414
+ // Claim a fresh z-index so this popup sits above any earlier popup.
1415
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
1416
+ const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
1417
+ // Esc handled at window level (issue #7) -- Enter stays on the
1418
+ // layer because (a) it gates on selectedWorkspaceId which is set
1419
+ // only by clicking a listbox item, and (b) racing Enter on the
1420
+ // Cancel / Confirm buttons is benign since the layer Enter
1421
+ // handler only fires when focus is inside the dialog subtree,
1422
+ // which is exactly when the focused button's native Enter has
1423
+ // already triggered its own click -- and in MoveDialog that
1424
+ // click just toggles selection, never cancels or confirms.
1425
+ const escStateRef = useRef({});
1426
+ escStateRef.current = { busy, onCancel };
1427
+ useEffect(() => {
1428
+ if (!open) return;
1429
+ const handler = (e) => {
1430
+ if (e.key !== "Escape") return;
1431
+ if (e.defaultPrevented) return;
1432
+ const s = escStateRef.current;
1433
+ if (s.busy) return;
1434
+ e.preventDefault();
1435
+ e.stopPropagation();
1436
+ s.onCancel();
1437
+ // See SessionManagerPanel for the rationale.
1438
+ const ae = document.activeElement;
1439
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
1440
+ };
1441
+ window.addEventListener("keydown", handler);
1442
+ return () => window.removeEventListener("keydown", handler);
1443
+ }, [open]);
1444
+ if (!open) return null;
1445
+
1446
+ const list = (Array.isArray(props.workspaces) ? props.workspaces : [])
1447
+ .filter((ws) => ws && typeof ws === "object" && typeof ws.id === "string" && ws.id !== currentWorkspaceId);
1448
+ const body = h("div", { className: "sm-nativeDialogBody" },
1449
+ h("div", { className: "sm-nativeDialogDescription" }, description),
1450
+ list.length === 0
1451
+ ? h("div", { className: "sm-moveLabel" }, t("confirm.move.empty"))
1452
+ : h("div", { className: "sm-moveSection" },
1453
+ h("div", { className: "sm-moveLabel" }, t("confirm.move.select")),
1454
+ h("div", { className: "sm-moveList", role: "listbox", "aria-label": t("confirm.move.select") },
1455
+ list.map((ws) => h("button", {
1456
+ key: ws.id,
1457
+ type: "button",
1458
+ role: "option",
1459
+ "aria-selected": selectedWorkspaceId === ws.id,
1460
+ className: "sm-moveItem" + (selectedWorkspaceId === ws.id ? " sm-moveItemSelected" : ""),
1461
+ disabled: busy,
1462
+ onClick: () => setSelectedWorkspaceId(ws.id)
1463
+ },
1464
+ h("span", { className: "sm-moveItemName" }, String(ws.title || ws.name || ws.id)),
1465
+ h("span", { className: "sm-moveItemMeta" }, ws.path ? String(ws.path) : "")
1466
+ ))
1467
+ )
1468
+ ),
1469
+ error ? h("div", { className: "sm-error", role: "alert" }, String(error)) : null
1470
+ );
1471
+ // Single cancel button (footer) + ESC handler. No "x" close button so the
1472
+ // user only sees one cancel entry point.
1473
+ const onKeyDown = (e) => {
1474
+ // Enter only -- Esc moved to a window-level listener (see above).
1475
+ if (e.key === "Enter" && !busy && selectedWorkspaceId && list.length > 0) {
1476
+ e.preventDefault();
1477
+ onConfirm(selectedWorkspaceId);
1478
+ }
1479
+ };
1480
+ return h("div", {
1481
+ className: "sm-nativeDialogLayer sm-confirmDialog",
1482
+ style: {
1483
+ zIndex: zIndex || undefined,
1484
+ ...(props.anchor && typeof props.anchor.top === "number"
1485
+ ? { position: "fixed", top: props.anchor.top + "px", left: props.anchor.left + "px" }
1486
+ : {})
1487
+ },
1488
+ role: "presentation",
1489
+ onKeyDown
1490
+ },
1491
+
1492
+ h("section", {
1493
+ className: "sm-nativeDialog",
1494
+ role: "dialog",
1495
+ "aria-modal": "true",
1496
+ "aria-labelledby": "sm-move-dialog-title"
1497
+ },
1498
+ h("div", { className: "sm-nativeDialogHeader" },
1499
+ h("h2", { id: "sm-move-dialog-title", className: "sm-nativeDialogTitle" }, title)
1500
+ ),
1501
+ body,
1502
+ h("div", { className: "sm-nativeDialogFooter" },
1503
+ h("button", {
1504
+ type: "button",
1505
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
1506
+ disabled: busy,
1507
+ onClick: onCancel
1508
+ }, cancelLabel),
1509
+ h("button", {
1510
+ type: "button",
1511
+ className: "sm-nativeDialogButton sm-nativeDialogConfirm",
1512
+ disabled: busy || !selectedWorkspaceId || list.length === 0,
1513
+ onClick: () => { if (!busy && selectedWorkspaceId) onConfirm(selectedWorkspaceId); }
1514
+ }, confirmLabel)
1515
+ )
1516
+ )
1517
+ );
1518
+ }
1519
+
1520
+ function MigratePresetDialog(props) {
1521
+ const t = typeof props.t === "function" ? props.t : (key) => key;
1522
+ const open = props.open === true;
1523
+ const onCancel = typeof props.onCancel === "function" ? props.onCancel : () => {};
1524
+ const onRun = typeof props.onRun === "function" ? props.onRun : () => {};
1525
+ const busy = props.busy === true;
1526
+ const sessionId = props.sessionId || "";
1527
+ const fallbackPreset = props.currentPreset || "";
1528
+ const [scan, setScan] = useState(null);
1529
+ const [target, setTarget] = useState("");
1530
+ // Claim a fresh z-index so this popup sits above any earlier popup.
1531
+ const [zIndex] = useState(() => open ? nextDialogZ() : 0);
1532
+ useEffect(() => {
1533
+ if (!open || !sessionId) return;
1534
+ let alive = true;
1535
+ // Skip setScan(null)/setTarget("") -- no-ops on initial mount that
1536
+ // would force an extra render cycle, slowing dialog open.
1537
+ fetchApi("/preset-scan?sessionId=" + encodeURIComponent(sessionId)).then((d) => {
1538
+ if (!alive) return;
1539
+ if (d && d.ok) {
1540
+ const result = d.result || {};
1541
+ const rows = Array.isArray(result.rows)
1542
+ ? result.rows
1543
+ : (result.rows && Array.isArray(result.rows.rows) ? result.rows.rows : []);
1544
+ const availablePresets = Array.isArray(result.availablePresets)
1545
+ ? result.availablePresets
1546
+ : (result.rows && Array.isArray(result.rows.availablePresets) ? result.rows.availablePresets : []);
1547
+ const row = rows.find((item) => item && item.sessionId === sessionId) || null;
1548
+ setScan({ ok: true, row, availablePresets });
1549
+ } else {
1550
+ setScan({ ok: false, error: (d && d.error) || "scan failed" });
1551
+ }
1552
+ }).catch((error) => {
1553
+ if (alive) setScan({ ok: false, error: error instanceof Error ? error.message : String(error) });
1554
+ });
1555
+ return () => { alive = false; };
1556
+ }, [open, sessionId]);
1557
+ // Esc handled at window level (issue #7). MigratePresetDialog is
1558
+ // a sibling of the panel layer in the panel's render tree, so
1559
+ // the layer-bound onKeyDown (which the dialog relied on for
1560
+ // Esc) was dead code -- focus never reached the dialog's own
1561
+ // subtree because the row Migrate button stayed focused.
1562
+ const escStateRef = useRef({});
1563
+ escStateRef.current = { busy, onCancel };
1564
+ useEffect(() => {
1565
+ if (!open) return;
1566
+ const handler = (e) => {
1567
+ if (e.key !== "Escape") return;
1568
+ if (e.defaultPrevented) return;
1569
+ const s = escStateRef.current;
1570
+ if (s.busy) return;
1571
+ e.preventDefault();
1572
+ e.stopPropagation();
1573
+ s.onCancel();
1574
+ // See SessionManagerPanel for the rationale.
1575
+ const ae = document.activeElement;
1576
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
1577
+ };
1578
+ window.addEventListener("keydown", handler);
1579
+ return () => window.removeEventListener("keydown", handler);
1580
+ }, [open]);
1581
+ if (!open) return null;
1582
+
1583
+ const currentPreset = (scan && scan.ok && scan.row && (scan.row.finalPreset || scan.row.headerPreset)) || fallbackPreset || "—";
1584
+ const presets = scan && scan.ok
1585
+ ? Array.from(new Set((scan.availablePresets || []).filter((preset) => typeof preset === "string" && preset.length > 0))).sort()
1586
+ : [];
1587
+ const targets = presets.filter((preset) => preset !== currentPreset);
1588
+ const ready = target !== "" && target !== currentPreset;
1589
+ const body = h("div", { className: "sm-migratePanel" },
1590
+ scan === null
1591
+ ? h("div", { className: "sm-empty" }, t("busy.processing"))
1592
+ : !scan.ok
1593
+ ? h("div", { className: "sm-error", role: "alert" }, scan.error || "scan failed")
1594
+ : h(React.Fragment, null,
1595
+ h("div", { className: "sm-migrateRow" },
1596
+ h("label", null, t("migrate.current")),
1597
+ h("div", { className: "sm-migrateCurrent" }, currentPreset)
1598
+ ),
1599
+ h("div", { className: "sm-migrateRow" },
1600
+ h("label", null, t("migrate.target")),
1601
+ targets.length === 0
1602
+ ? h("div", { className: "sm-migrateEmpty" }, t("migrate.noPresets"))
1603
+ : h("div", { className: "sm-migrateChips" }, targets.map((preset) => h("button", {
1604
+ key: preset,
1605
+ type: "button",
1606
+ className: "sm-migrateChip" + (target === preset ? " sm-migrateChipOn" : ""),
1607
+ disabled: busy,
1608
+ onClick: () => setTarget(preset)
1609
+ }, preset)))
1610
+ ),
1611
+ target === currentPreset ? h("div", { className: "sm-migrateWarn" }, t("migrate.same")) : null
1612
+ )
1613
+ );
1614
+ return h("div", {
1615
+ className: "sm-nativeDialogLayer sm-confirmDialog sm-migrateConfirmLayer",
1616
+ role: "presentation",
1617
+ style: { zIndex: zIndex || undefined }
1618
+ },
1619
+
1620
+ h("section", {
1621
+ className: "sm-nativeDialog sm-migrateDialog",
1622
+ role: "dialog",
1623
+ "aria-modal": "true",
1624
+ "aria-labelledby": "sm-migrate-dialog-title"
1625
+ },
1626
+ h("div", { className: "sm-nativeDialogHeader" },
1627
+ h("h2", { id: "sm-migrate-dialog-title", className: "sm-nativeDialogTitle" }, t("migrate.title"))
1628
+ ),
1629
+ body,
1630
+ h("div", { className: "sm-nativeDialogFooter" },
1631
+ h("button", {
1632
+ type: "button",
1633
+ className: "sm-nativeDialogButton sm-nativeDialogCancel",
1634
+ disabled: busy,
1635
+ onClick: onCancel
1636
+ }, t("confirm.cancel")),
1637
+ h("button", {
1638
+ type: "button",
1639
+ className: "sm-nativeDialogButton sm-nativeDialogConfirm",
1640
+ disabled: busy || !ready,
1641
+ onClick: () => { if (ready) onRun(target); }
1642
+ }, busy ? t("busy.processing") : t("migrate.confirm"))
1643
+ )
1644
+ )
1645
+ );
1646
+ }
1647
+
1648
+ const panelStore = {
1649
+ open: false,
1650
+ listeners: new Set(),
1651
+ getSnapshot() { return this.open; },
1652
+ subscribe(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); },
1653
+ set(v) { if (this.open === v) return; this.open = v; for (const fn of [...this.listeners]) fn(); },
1654
+ toggle() { this.open = !this.open; for (const fn of [...this.listeners]) fn(); },
1655
+ // Pending action queued by the title-bar buttons (e.g. 移动至工作区).
1656
+ // The SessionManagerPanel consumes this on mount so it can pre-open
1657
+ // the matching inner dialog (move / delete / migrate) next to itself.
1658
+ pendingMove: null,
1659
+ pendingListeners: new Set(),
1660
+ _emitPending() { for (const fn of [...this.pendingListeners]) fn(); },
1661
+ subscribePending(cb) { this.pendingListeners.add(cb); return () => this.pendingListeners.delete(cb); },
1662
+ setPendingMove(v) { this.pendingMove = v; this._emitPending(); },
1663
+ consumePendingMove() { const v = this.pendingMove; this.pendingMove = null; return v; }
1664
+ };
1665
+
1666
+ const DEFAULT_SORT = "updated-desc";
1667
+ const SORT_ORDERS = ["updated-desc", "updated-asc", "created-desc", "created-asc", "priority"];
1668
+ const workspaceValue = (id) => "workspace:" + id;
1669
+ const pathKey = (value) => {
1670
+ if (typeof value !== "string" || value === "") return "";
1671
+ const path = value.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
1672
+ return /^[a-z]:(?:\/|$)/i.test(path) || path.startsWith("//") ? path.toLowerCase() : path;
1673
+ };
1674
+ const sessionTime = (value) => {
1675
+ if (typeof value !== "number" && typeof value !== "string") return null;
1676
+ if (typeof value === "string" && value.trim() === "") return null;
1677
+ const time = typeof value === "number" || /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : Date.parse(value);
1678
+ return Number.isFinite(time) ? time : null;
1679
+ };
1680
+ const indexWorkspaces = (workspaces) => {
1681
+ const bySession = new Map();
1682
+ const byPath = new Map();
1683
+ const ids = new Set();
1684
+ for (const workspace of workspaces) {
1685
+ ids.add(workspace.id);
1686
+ const key = pathKey(workspace.path);
1687
+ if (key) {
1688
+ if (!byPath.has(key)) byPath.set(key, new Set());
1689
+ byPath.get(key).add(workspace.id);
1690
+ }
1691
+ for (const id of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) {
1692
+ if (!bySession.has(id)) bySession.set(id, new Set());
1693
+ bySession.get(id).add(workspace.id);
1694
+ }
1695
+ }
1696
+ return { bySession, byPath, ids };
1697
+ };
1698
+ const rowWorkspaces = (session, index) => {
1699
+ // Registry membership includes archived sessions and takes precedence
1700
+ // over a stale cwd. Older metadata can fall back to workspaceId/cwd.
1701
+ if (index.bySession.has(session.id)) return index.bySession.get(session.id);
1702
+ if (index.ids.has(session.workspaceId)) return new Set([session.workspaceId]);
1703
+ return index.byPath.get(pathKey(session.cwd)) ?? new Set();
1704
+ };
1705
+
1706
+ function SessionManagerPanel(props) {
1707
+ const t = props.t;
1708
+ const open = props.open;
1709
+ const onClose = props.onClose;
1710
+ const useSessions = props.useSessions;
1711
+ const useWorkspaces = props.useWorkspaces;
1712
+ const onOpen = props.onOpen;
1713
+ const onArchive = props.onArchive;
1714
+ const onUnarchive = props.onUnarchive;
1715
+ const onRemove = props.onRemove;
1716
+ const onMove = props.onMove;
1717
+ const onMigrate = props.onMigrate;
1718
+ const annotations = useAnnotations(true);
1719
+ const marksReady = annotations.loaded && !annotations.error;
1720
+ const [onlyFavorite, setOnlyFavorite] = useState(false);
1721
+ const [onlyReview, setOnlyReview] = useState(false);
1722
+ const [tagFilter, setTagFilter] = useState("all");
1723
+ const [priorityFilter, setPriorityFilter] = useState("all");
1724
+ const [annotationFor, setAnnotationFor] = useState(null);
1725
+ const [annotationAnchor, setAnnotationAnchor] = useState(null);
1726
+ const list = useSessions((s) => s);
1727
+ const workspaceSnapshot = useWorkspaces((s) => s);
1728
+ const archivedIds = workspaceSnapshot.archivedSessionIds || [];
1729
+ const [filter, setFilter] = useState("all");
1730
+ const [query, setQuery] = useState("");
1731
+ const [workspaceFilter, setWorkspaceFilter] = useState("all");
1732
+ const [sortOrder, setSortOrder] = useState(DEFAULT_SORT);
1733
+ const [busyId, setBusyId] = useState(null);
1734
+ const [error, setError] = useState(null);
1735
+ const [now, setNow] = useState(() => Date.now());
1736
+ const [moveFor, setMoveFor] = useState(null);
1737
+ const [confirmFor, setConfirmFor] = useState(null);
1738
+ const [workspaces, setWorkspaces] = useState([]);
1739
+ const [createdTimes, setCreatedTimes] = useState({});
1740
+ const [workspacesLoading, setWorkspacesLoading] = useState(true);
1741
+ const [workspacesError, setWorkspacesError] = useState(null);
1742
+ const [workspaceRefresh, setWorkspaceRefresh] = useState(0);
1743
+ const [migrating, setMigrating] = useState(false);
1744
+ const [migrateFor, setMigrateFor] = useState(null);
1745
+ const sessionMembershipKey = useMemo(() => [...list.ids].sort().join("\0"), [list.ids]);
1746
+ // ----- Bulk-action selection state -----
1747
+ const [selectedIds, setSelectedIds] = useState(() => new Set());
1748
+ const [lastClickedRank, setLastClickedRank] = useState(null);
1749
+ const [bulkPreview, setBulkPreview] = useState(null);
1750
+ const [bulkProgress, setBulkProgress] = useState(null);
1751
+ const [bulkResult, setBulkResult] = useState(null);
1752
+ const [bulkBusy, setBulkBusy] = useState(false);
1753
+ const [bulkTagDraft, setBulkTagDraft] = useState(null);
1754
+ // Select-mode is the user-controlled switch that reveals the row
1755
+ // checkboxes and the bulk action bar. It lives outside selectedIds
1756
+ // so the row checkboxes can be visible even before anything is
1757
+ // ticked (otherwise the BulkActionBar's own "select all" button
1758
+ // is unreachable).
1759
+ const [selectModeEnabled, setSelectModeEnabled] = useState(false);
1760
+ // Closing the panel resets everything bulk-related.
1761
+ useEffect(() => {
1762
+ if (!open) {
1763
+ setSelectedIds(new Set());
1764
+ setLastClickedRank(null);
1765
+ setBulkPreview(null);
1766
+ setBulkProgress(null);
1767
+ setBulkResult(null);
1768
+ setBulkTagDraft(null);
1769
+ setBulkPresetDraft(null);
1770
+ setBulkPresetOptions([]);
1771
+ setBulkBusy(false);
1772
+ setSelectModeEnabled(false);
1773
+ }
1774
+ }, [open]);
1775
+ const toggleSelectMode = () => {
1776
+ setSelectModeEnabled(value => {
1777
+ if (value) clearSelection();
1778
+ return !value;
1779
+ });
1780
+ };
1781
+ const toggleSelect = (id, shift, currentIndex) => {
1782
+ setSelectedIds(prev => {
1783
+ const next = new Set(prev);
1784
+ if (shift && lastClickedRank !== null && Number.isInteger(currentIndex)) {
1785
+ const lo = Math.min(lastClickedRank, currentIndex);
1786
+ const hi = Math.max(lastClickedRank, currentIndex);
1787
+ const allInRange = rows.slice(lo, hi + 1).every(r => next.has(r.id));
1788
+ for (let i = lo; i <= hi; i++) {
1789
+ const rowId = rows[i] && rows[i].id;
1790
+ if (!rowId) continue;
1791
+ if (allInRange) next.delete(rowId); else next.add(rowId);
1792
+ }
1793
+ } else {
1794
+ if (next.has(id)) next.delete(id); else next.add(id);
1795
+ }
1796
+ return next;
1797
+ });
1798
+ if (!shift) setLastClickedRank(Number.isInteger(currentIndex) ? currentIndex : null);
1799
+ };
1800
+ // Clear the selected-id set without touching selectModeEnabled.
1801
+ // Earlier this also reset selectModeEnabled to false, which made
1802
+ // clicking the "清空选择" toolbar button silently exit bulk
1803
+ // mode. Bulk mode is now exited exclusively by toggleSelectMode()
1804
+ // (the "退出批量处理" toggle button in the toolbar).
1805
+ const clearSelection = () => { setSelectedIds(new Set()); setLastClickedRank(null); };
1806
+ const selectAllFiltered = () => {
1807
+ if (rows.length > 200) { setError(t("bulk.error.tooMany")); return; }
1808
+ setSelectedIds(new Set(rows.map(r => r.id)));
1809
+ };
1810
+ const selectedSessions = useMemo(() => {
1811
+ const out = [];
1812
+ for (const id of selectedIds) {
1813
+ const s = list.byId[id];
1814
+ if (!s) continue;
1815
+ out.push({ id, displayTitle: s.blank ? t("panel.session.new") : (s.displayTitle || id) });
1816
+ }
1817
+ return out;
1818
+ }, [selectedIds, list.byId, t]);
1819
+ const executeBulk = async ({ action, payload, actionLabel, ids, sessions }) => {
1820
+ if (ids.length === 0) return;
1821
+ if (ids.length > 200) { setError(t("bulk.error.tooMany")); return; }
1822
+ const cancelController = { abort: () => {} };
1823
+ setBulkProgress({ action, actionLabel, done: 0, total: ids.length, completed: false, cancel: () => cancelController.abort() });
1824
+ setBulkBusy(true);
1825
+ let result;
1826
+ try {
1827
+ result = await runBatch({ action, sessionIds: ids, payload }, ({ done }) => {
1828
+ setBulkProgress(prev => prev ? { ...prev, done, completed: false } : prev);
1829
+ });
1830
+ } catch (error) {
1831
+ setBulkProgress(null);
1832
+ setBulkBusy(false);
1833
+ setError(error instanceof Error ? error.message : String(error));
1834
+ return;
1835
+ }
1836
+ const items = (result.items || []).map(item => {
1837
+ const session = sessions.find(s => s.id === item.sessionId);
1838
+ return { ...item, displayTitle: session ? session.displayTitle : (item.sessionId || "—") };
1839
+ });
1840
+ setBulkProgress(null);
1841
+ setBulkResult({ action, actionLabel, items });
1842
+ setBulkBusy(false);
1843
+ try { await annotationStore.load(true); } catch (_) { /* ignore */ }
1844
+ if (typeof props.onRefreshAfterBulk === "function") {
1845
+ try { await props.onRefreshAfterBulk(action); } catch (_) { /* ignore */ }
1846
+ }
1847
+ };
1848
+ const runBulk = ({ action, payload, actionLabel, needsConfirm }) => {
1849
+ if (bulkBusy) return;
1850
+ const ids = [...selectedIds];
1851
+ if (ids.length === 0) return;
1852
+ if (ids.length > 200) { setError(t("bulk.error.tooMany")); return; }
1853
+ const sessions = selectedSessions;
1854
+ const resolvedLabel = actionLabel || ({
1855
+ archive: t("bulk.archive"), unarchive: t("bulk.unarchive"), delete: t("bulk.delete"),
1856
+ move: t("bulk.move"), "preset-migrate": t("bulk.presetMigrate"),
1857
+ favorite: t("bulk.favorite"), unfavorite: t("bulk.unfavorite"),
1858
+ review: t("bulk.review"), unreview: t("bulk.unreview"),
1859
+ "add-tags": t("bulk.tagsAdd"), "remove-tags": t("bulk.tagsRemove"),
1860
+ "set-priority": t("bulk.priority")
1861
+ })[action] || action;
1862
+ if (needsConfirm) {
1863
+ setBulkPreview({ action, payload, actionLabel: resolvedLabel, sessions, warning: action === "delete" ? t("bulk.dialog.warning.delete") : "" });
1864
+ return;
1865
+ }
1866
+ void executeBulk({ action, payload, actionLabel: resolvedLabel, ids, sessions });
1867
+ };
1868
+ const retryFailedBulk = (failedIds, action, payload, actionLabel) => {
1869
+ setBulkResult(null);
1870
+ const filtered = (failedIds || []).filter(id => typeof id === "string" && id !== "");
1871
+ setSelectedIds(new Set(filtered));
1872
+ if (filtered.length === 0) return;
1873
+ const needsConfirm = action === "delete" || action === "move" || action === "preset-migrate" || action === "add-tags" || action === "remove-tags" || action === "set-priority";
1874
+ runBulk({ action, payload, actionLabel, needsConfirm });
1875
+ };
1876
+ const needsConfirmFor = (action) => action === "delete";
1877
+ const onBulkAction = (action, payload, forceNeedsConfirm) => {
1878
+ // Toggle: if a preview or result dialog is already open for this
1879
+ // same action, click the button again to close it.
1880
+ if (bulkPreview && bulkPreview.action === action) { setBulkPreview(null); return; }
1881
+ if (bulkResult && bulkResult.action === action) { setBulkResult(null); return; }
1882
+ const needsConfirm = forceNeedsConfirm === true || needsConfirmFor(action);
1883
+ runBulk({ action, payload, needsConfirm });
1884
+ };
1885
+ const onBulkSelectAll = () => { setSelectModeEnabled(true); selectAllFiltered(); };
1886
+ const onBulkClear = () => clearSelection();
1887
+ const onBulkTagsAdd = () => { if (bulkTagDraft && bulkTagDraft.mode === "add") { setBulkTagDraft(null); return; } setBulkTagDraft({ mode: "add" }); };
1888
+ const onBulkRemoveAllTags = () => onBulkAction("remove-tags", { tags: [] });
1889
+ const onBulkPriority = () => { if (bulkTagDraft && bulkTagDraft.mode === "priority") { setBulkTagDraft(null); return; } setBulkTagDraft({ mode: "priority" }); };
1890
+ const onBulkMove = () => { if (bulkTagDraft && bulkTagDraft.mode === "move") { setBulkTagDraft(null); return; } setBulkTagDraft({ mode: "move" }); };
1891
+ const onBulkTagApply = (tags) => {
1892
+ const mode = bulkTagDraft && bulkTagDraft.mode;
1893
+ setBulkTagDraft(null);
1894
+ if (mode === "add") onBulkAction("add-tags", { tags }, true);
1895
+ };
1896
+ const onBulkPriorityApply = (value) => {
1897
+ const priority = Number(value);
1898
+ setBulkTagDraft(null);
1899
+ if (Number.isInteger(priority) && priority >= 1 && priority <= 5) onBulkAction("set-priority", { priority }, true);
1900
+ };
1901
+ const onBulkMoveApply = (value) => {
1902
+ setBulkTagDraft(null);
1903
+ if (value) onBulkAction("move", { targetWorkspaceId: value }, true);
1904
+ };
1905
+ const [bulkPresetDraft, setBulkPresetDraft] = useState(null);
1906
+ const [bulkPresetOptions, setBulkPresetOptions] = useState([]);
1907
+ const onBulkPreset = () => {
1908
+ if (selectedIds.size === 0) return;
1909
+ // Toggle: close the dialog if it is already open.
1910
+ if (bulkPresetDraft) { setBulkPresetDraft(null); return; }
1911
+
1912
+ // Scan using the first selected session; /preset-scan returns
1913
+ // the global available preset list regardless of session, so any
1914
+ // selected id works.
1915
+ const firstId = [...selectedIds][0];
1916
+ setBulkPresetDraft({ mode: "preset" });
1917
+ setBulkPresetOptions([]);
1918
+ fetchApi("/preset-scan?sessionId=" + encodeURIComponent(firstId)).then((data) => {
1919
+ if (!data || !data.ok) return;
1920
+ const result = data.result || {};
1921
+ const presets = Array.isArray(result.availablePresets)
1922
+ ? result.availablePresets
1923
+ : (result.rows && Array.isArray(result.rows.availablePresets) ? result.rows.availablePresets : []);
1924
+ setBulkPresetOptions(presets.filter(p => typeof p === "string" && p.length > 0).map(p => ({ value: p, label: p })));
1925
+ }).catch(() => { /* surface empty list */ });
1926
+ };
1927
+ const onBulkPresetApply = (value) => {
1928
+ setBulkPresetDraft(null);
1929
+ if (value) onBulkAction("preset-migrate", { toPreset: value }, true);
1930
+ };
1931
+ const bulkMoveOptions = useMemo(() => workspaces.map(ws => ({ value: ws.id, label: ws.title || ws.name || ws.id })), [workspaces]);
1932
+ const bulkPriorityOptions = useMemo(() => [1, 2, 3, 4, 5].map(value => ({ value: String(value), label: t("marks.priority." + value) })), [t]);
1933
+ useEffect(() => {
1934
+ const timer = setInterval(() => setNow(Date.now()), 30000);
1935
+ return () => clearInterval(timer);
1936
+ }, []);
1937
+ useEffect(() => {
1938
+ if (!open) return;
1939
+ let alive = true;
1940
+ const controller = new AbortController();
1941
+ setWorkspacesLoading(true);
1942
+ setWorkspacesError(null);
1943
+ fetchApi("/workspaces", { signal: controller.signal }).then((data) => {
1944
+ if (!data?.ok || !Array.isArray(data.result?.workspaces)) throw new Error(data?.error || "Invalid workspace response");
1945
+ const next = data.result.workspaces.filter(workspace => workspace && typeof workspace.id === "string");
1946
+ if (!alive) return;
1947
+ setWorkspaces(next);
1948
+ const times = data.result.sessionCreatedAt;
1949
+ setCreatedTimes(times && typeof times === "object" && !Array.isArray(times) ? times : {});
1950
+ setWorkspaceFilter(value => value.startsWith("workspace:") && !next.some(workspace => workspaceValue(workspace.id) === value) ? "all" : value);
1951
+ }).catch(error => {
1952
+ if (alive) setWorkspacesError(error instanceof Error ? error.message : String(error));
1953
+ }).finally(() => {
1954
+ if (alive) setWorkspacesLoading(false);
1955
+ });
1956
+ return () => { alive = false; controller.abort(); };
1957
+ }, [open, workspaceSnapshot, Boolean(moveFor), workspaceRefresh, sessionMembershipKey]);
1958
+ // Drain any pending action queued by a header button. The panel is
1959
+ // the single owner of every inner dialog, so opening it with a
1960
+ // pending action pre-populates the corresponding dialog state.
1961
+ useEffect(() => {
1962
+ if (!open) return;
1963
+ const pending = panelStore.consumePendingMove();
1964
+ if (pending && pending.sessionId) {
1965
+ setMoveFor({ id: pending.sessionId, displayTitle: pending.displayTitle || pending.sessionId, workspaceId: pending.workspaceId || "" });
1966
+ }
1967
+ }, [open]);
1968
+ const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
1969
+ const workspaceIndex = useMemo(() => indexWorkspaces(workspaces), [workspaces]);
1970
+ const allRows = useMemo(() => list.ids.map(id => list.byId[id])
1971
+ .filter(session => session != null && session.origin !== "subagent" && (!session.blank || session.id === list.current)), [list]);
1972
+ const availableTags = useMemo(() => {
1973
+ const tags = new Map();
1974
+ for (const session of allRows) for (const tag of annotationOf(annotations, session.id).tags) if (!tags.has(tag.toLowerCase())) tags.set(tag.toLowerCase(), tag);
1975
+ return [...tags].sort((a, b) => a[1].localeCompare(b[1]));
1976
+ }, [allRows, annotations.entries]);
1977
+ useEffect(() => {
1978
+ // A removed last tag must not leave an invisible active filter while
1979
+ // the select falls back to displaying "All tags".
1980
+ if (annotations.loaded && !annotations.error && tagFilter.startsWith("tag:")
1981
+ && !availableTags.some(([key]) => key === tagFilter.slice(4))) setTagFilter("all");
1982
+ }, [availableTags, tagFilter, annotations.loaded, annotations.error]);
1983
+ const rows = useMemo(() => {
1984
+ const needle = query.trim().toLowerCase();
1985
+ const selected = allRows.filter(session => {
1986
+ if (filter === "active" && archived.has(session.id)) return false;
1987
+ if (filter === "archived" && !archived.has(session.id)) return false;
1988
+ const mark = annotationOf(annotations, session.id);
1989
+ if (onlyFavorite && !mark.favorite || onlyReview && !mark.reviewLater) return false;
1990
+ if (tagFilter === "none" && mark.tags.length !== 0) return false;
1991
+ if (tagFilter.startsWith("tag:") && !mark.tags.some(tag => tag.toLowerCase() === tagFilter.slice(4))) return false;
1992
+ if (priorityFilter !== "all" && (mark.priority ?? 3) !== Number(priorityFilter)) return false;
1993
+ const title = session.blank ? t("panel.session.new") : session.displayTitle || session.id;
1994
+ if (needle && ![title, session.id, mark.note, ...mark.tags].some(value => value.toLowerCase().includes(needle))) return false;
1995
+ if (workspaceFilter !== "all") {
1996
+ const memberships = rowWorkspaces(session, workspaceIndex);
1997
+ if (workspaceFilter === "ungrouped" ? memberships.size !== 0 : !memberships.has(workspaceFilter.slice("workspace:".length))) return false;
1998
+ }
1999
+ return true;
2000
+ });
2001
+ const field = sortOrder.startsWith("created-") ? "createdAt" : "updatedAt";
2002
+ const direction = sortOrder === "priority" || sortOrder.endsWith("-asc") ? 1 : -1;
2003
+ // Sort a derived array only. Equal timestamps preserve source order;
2004
+ // missing/invalid timestamps stay last in either direction. Priority
2005
+ // is normalized to 1-5 (legacy null treated as 3) so unset rows sort
2006
+ // alongside priority 3.
2007
+ const timeFor = session => {
2008
+ if (sortOrder === "priority") return annotationOf(annotations, session.id).priority ?? 3;
2009
+ return field === "createdAt" ? sessionTime(session.createdAt) ?? sessionTime(createdTimes[session.id]) : sessionTime(session.updatedAt);
2010
+ };
2011
+ return selected.map((session, rank) => ({ session, rank, time: timeFor(session) }))
2012
+ .sort((a, b) => a.time === b.time ? a.rank - b.rank : a.time === null ? 1 : b.time === null ? -1 : direction * (a.time - b.time))
2013
+ .map(item => item.session);
2014
+ }, [allRows, archived, filter, query, workspaceFilter, workspaceIndex, sortOrder, createdTimes, annotations.entries, onlyFavorite, onlyReview, tagFilter, priorityFilter, t]);
2015
+ // The set of ids that survive the current filter; bulk-selection
2016
+ // bookkeeping reads from this so it stays in sync with row visibility.
2017
+ const currentFilterIds = useMemo(() => rows.map(r => r.id), [rows]);
2018
+ const currentFilterRank = useMemo(() => {
2019
+ const m = new Map();
2020
+ rows.forEach((r, i) => m.set(r.id, i));
2021
+ return m;
2022
+ }, [rows]);
2023
+ // Drop ids from the selection that no longer match the current filter
2024
+ // (e.g. user narrowed the workspace filter or hid archived rows).
2025
+ useEffect(() => {
2026
+ const allowed = new Set(currentFilterIds);
2027
+ setSelectedIds(prev => {
2028
+ let changed = false;
2029
+ const next = new Set();
2030
+ for (const id of prev) if (allowed.has(id)) next.add(id); else changed = true;
2031
+ return changed ? next : prev;
2032
+ });
2033
+ }, [currentFilterIds]);
2034
+ const creationTimeUnavailable = sortOrder.startsWith("created-") && allRows.length > 0
2035
+ && !allRows.some(session => (sessionTime(session.createdAt) ?? sessionTime(createdTimes[session.id])) !== null);
2036
+ const hasViewChanges = onlyFavorite || onlyReview || tagFilter !== "all" || priorityFilter !== "all" || query !== "" || filter !== "all" || workspaceFilter !== "all" || sortOrder !== DEFAULT_SORT;
2037
+ const resetView = () => { setQuery(""); setFilter("all"); setWorkspaceFilter("all"); setSortOrder(DEFAULT_SORT); setOnlyFavorite(false); setOnlyReview(false); setTagFilter("all"); setPriorityFilter("all"); };
2038
+ const runRow = async (sessionId, fn) => {
2039
+ if (busyId !== null) return;
2040
+ setBusyId(sessionId);
2041
+ setError(null);
2042
+ try {
2043
+ await fn();
2044
+ } catch (e) {
2045
+ setError(e instanceof Error ? e.message : String(e));
2046
+ } finally {
2047
+ setBusyId(null);
2048
+ }
2049
+ };
2050
+ const filterBtn = (id, label) => h("button", {
2051
+ type: "button",
2052
+ key: id,
2053
+ className: "sm-filterBtn" + (filter === id ? " sm-on" : ""),
2054
+ "aria-pressed": filter === id,
2055
+ onClick: () => setFilter(id)
2056
+ }, label);
2057
+ const renderRow = (s, rank) => {
2058
+ const isArchived = archived.has(s.id);
2059
+ const isCurrent = s.id === list.current;
2060
+ const busy = busyId === s.id;
2061
+ const mark = annotationOf(annotations, s.id);
2062
+ const isSelected = selectedIds.has(s.id);
2063
+ const checkbox = (selectedIds.size === 0 && !selectModeEnabled)
2064
+ ? null
2065
+ : h("label", { className: "sm-bulkRowCheckbox", onClick: (e) => e.stopPropagation() },
2066
+ h("input", {
2067
+ type: "checkbox",
2068
+ checked: isSelected,
2069
+ disabled: busy || bulkBusy,
2070
+ "aria-label": t("bulk.checkbox.aria", { title: s.displayTitle || s.id }),
2071
+ onChange: (e) => toggleSelect(s.id, e.shiftKey === true, rank)
2072
+ })
2073
+ );
2074
+ return h("div", {
2075
+ key: s.id,
2076
+ "data-session-id": s.id,
2077
+ className: "sm-row" + (isCurrent ? " sm-rowCurrent" : "") + (isSelected ? " sm-rowBulkSelected" : "")
2078
+ },
2079
+ h("div", { className: "sm-rowCheckboxSlot" + ((selectedIds.size > 0 || selectModeEnabled) ? " sm-rowCheckboxSlot-visible" : "") }, checkbox),
2080
+ h("div", { className: "sm-rowMain" },
2081
+ h("div", { className: "sm-rowTitle" },
2082
+ h("span", null, s.blank ? t("panel.session.new") : (s.displayTitle || s.id)),
2083
+ isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
2084
+ s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
2085
+ isCurrent ? h("span", { className: "sm-badge sm-badgeCurrent" }, t("panel.current")) : null
2086
+ ),
2087
+ h("div", { className: "sm-rowSecondary" },
2088
+ h("div", { className: "sm-rowBadges" },
2089
+ h("span", { className: "sm-priorityBadge sm-priority-" + (mark.priority ?? 3), title: t("marks.priority." + (mark.priority ?? 3)) }, "P" + (mark.priority ?? 3)),
2090
+ mark.tags.slice(0, 2).map(tag => h("button", { key: tag, type: "button", className: "sm-tagChip", title: tag, onClick: () => setTagFilter("tag:" + tag.toLowerCase()) }, tag)),
2091
+ mark.tags.length > 2 ? h("span", { title: mark.tags.join(", ") }, "+" + (mark.tags.length - 2)) : null,
2092
+ mark.note ? h("button", { type: "button", className: "sm-noteBadge", title: t("marks.note.exists"), onClick: () => { setAnnotationAnchor(null); setAnnotationFor(current => current?.id === s.id ? null : s); } }, "✎") : null
2093
+ ),
2094
+ h("div", { className: "sm-rowMeta" }, shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now)),
2095
+ h("div", { className: "sm-rowActions" },
2096
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
2097
+ 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")),
2098
+ annotationButtons(s.id, s.displayTitle || s.id, t, annotations, "sm-rowBtn", patch => runRow(s.id, () => annotationStore.save(s.id, patch)), () => { setAnnotationAnchor(null); setAnnotationFor(current => current?.id === s.id ? null : s); }, busy),
2099
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (moveFor && moveFor.id === s.id) setMoveFor(null); else setMoveFor(s); } }, t("row.move")),
2100
+ h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (migrateFor && migrateFor.id === s.id) setMigrateFor(null); else setMigrateFor(s); } }, t("row.migrate")),
2101
+ 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"))
2102
+ )
2103
+ )
2104
+ )
2105
+ );
2106
+ };
2107
+ const rowsList = rows.length === 0
2108
+ ? h("div", { className: "sm-empty" }, allRows.length > 0 ? t("panel.empty.filtered") : filter === "archived" ? t("panel.empty.archived") : t("panel.empty"))
2109
+ : h("div", { className: "sm-list" }, rows.map((s, i) => renderRow(s, i)));
2110
+ const onPanelClose = () => {
2111
+ if (busyId !== null) return;
2112
+ setConfirmFor(null);
2113
+ setMoveFor(null);
2114
+ setMigrateFor(null);
2115
+ onClose();
2116
+ };
2117
+ // Esc is handled at window level (issue #7): the original layer-
2118
+ // bound onKeyDown was unreachable when focus stayed on the sidebar
2119
+ // toggle button that opened the panel -- that button is a sibling
2120
+ // of the panel layer, so keydown never bubbles into the panel
2121
+ // subtree. The state ref lets the listener see the latest values
2122
+ // without re-subscribing on every render. The panel listener
2123
+ // returns early when any inner dialog is open so the child
2124
+ // dialog's own window listener gets the first shot at Esc.
2125
+ const escStateRef = useRef({});
2126
+ escStateRef.current = { busyId, confirmFor, moveFor, migrateFor, annotationFor, onPanelClose };
2127
+ useEffect(() => {
2128
+ if (!open) return;
2129
+ const handler = (e) => {
2130
+ if (e.key !== "Escape") return;
2131
+ if (e.defaultPrevented) return;
2132
+ if (e.isComposing || e.keyCode === 229) return;
2133
+ const s = escStateRef.current;
2134
+ if (s.busyId !== null) return;
2135
+ if (s.confirmFor || s.moveFor || s.migrateFor || s.annotationFor) return;
2136
+ e.preventDefault();
2137
+ e.stopPropagation();
2138
+ s.onPanelClose();
2139
+ // Esc after a keyboard-driven close leaves the originally-
2140
+ // focused trigger button (sidebar toggle / row button) with
2141
+ // :focus-visible still active -- blur it so no focus ring
2142
+ // lingers after the panel closes. Mouse-driven closes are
2143
+ // not affected: :focus-visible only activates for keyboard
2144
+ // focus, so the ring never shows in that path.
2145
+ const ae = document.activeElement;
2146
+ if (ae && typeof ae.blur === "function" && ae !== document.body) ae.blur();
2147
+ };
2148
+ window.addEventListener("keydown", handler);
2149
+ return () => window.removeEventListener("keydown", handler);
2150
+ }, [open]);
2151
+ const panelModal = h("div", {
2152
+ className: "sm-nativeDialogLayer",
2153
+ role: "presentation"
2154
+ },
2155
+ h("section", {
2156
+ className: "sm-nativeDialog sm-panelDialog",
2157
+ role: "dialog",
2158
+ "aria-modal": "true",
2159
+ "aria-labelledby": "sm-panel-dialog-title"
2160
+ },
2161
+ h("div", { className: "sm-nativeDialogHeader" },
2162
+ h("a", {
2163
+ href: "https://github.com/hkkz9522/dsh-session-manager",
2164
+ target: "_blank",
2165
+ rel: "noopener noreferrer",
2166
+ className: "sm-panelHeaderGithub",
2167
+ title: t("panel.github.title"),
2168
+ "aria-label": t("panel.github.title")
2169
+ },
2170
+ h("svg", { viewBox: "0 0 16 16", width: 16, height: 16, "aria-hidden": "true" },
2171
+ h("path", {
2172
+ fill: "currentColor",
2173
+ d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"
2174
+ })
2175
+ )
2176
+ ),
2177
+ h("h2", { id: "sm-panel-dialog-title", className: "sm-nativeDialogTitle" }, t("panel.title")),
2178
+ h("div", { className: "sm-nativeDialogHeaderActions" },
2179
+ h("button", {
2180
+ type: "button",
2181
+ className: "sm-panelHeaderClose",
2182
+ disabled: busyId !== null || migrating,
2183
+ onClick: onPanelClose
2184
+ }, t("panel.close"))
2185
+ )
2186
+ ),
2187
+ h("div", { className: "sm-panel" },
2188
+ h("div", { className: "sm-panelTools" },
2189
+ h("input", {
2190
+ type: "search", className: "sm-queryInput", value: query,
2191
+ placeholder: t("panel.search"), "aria-label": t("panel.search"), autoComplete: "off",
2192
+ onChange: event => setQuery(event.target.value)
2193
+ }),
2194
+ h("div", { className: "sm-panelSelects" },
2195
+ h("label", { className: "sm-viewField" },
2196
+ h("span", null, t("panel.workspace")),
2197
+ h("select", {
2198
+ className: "sm-viewSelect", "aria-label": t("panel.workspace"), value: workspaceFilter,
2199
+ disabled: workspacesLoading || workspacesError !== null,
2200
+ onChange: event => setWorkspaceFilter(event.target.value)
2201
+ },
2202
+ h("option", { value: "all" }, t("panel.workspace.all")),
2203
+ h("option", { value: "ungrouped" }, t("panel.workspace.ungrouped")),
2204
+ workspaces.map(workspace => h("option", { key: workspace.id, value: workspaceValue(workspace.id), title: workspace.path || workspace.id }, workspace.title || workspace.name || workspace.id))
2205
+ )
2206
+ ),
2207
+ h("label", { className: "sm-viewField" },
2208
+ h("span", null, t("panel.sort")),
2209
+ h("select", {
2210
+ className: "sm-viewSelect", "aria-label": t("panel.sort"), value: sortOrder,
2211
+ onChange: event => setSortOrder(event.target.value)
2212
+ }, SORT_ORDERS.map(value => h("option", { key: value, value, disabled: value === "priority" && !marksReady }, t(value === "priority" ? "marks.sort" : "panel.sort." + value))))
2213
+ ),
2214
+ h("label", { className: "sm-viewField" },
2215
+ h("span", null, t("marks.tags")),
2216
+ h("select", { className: "sm-viewSelect", "aria-label": t("marks.filter.tags"), value: tagFilter, disabled: !marksReady, onChange: e => setTagFilter(e.target.value) },
2217
+ h("option", { value: "all" }, t("marks.tags.all")), h("option", { value: "none" }, t("marks.tags.none")),
2218
+ availableTags.map(([key, tag]) => h("option", { key, value: "tag:" + key }, tag)),
2219
+ tagFilter.startsWith("tag:") && !availableTags.some(([key]) => "tag:" + key === tagFilter) ? h("option", { value: tagFilter }, t("marks.tags.missing", { tag: tagFilter.slice(4) })) : null
2220
+ )
2221
+ ),
2222
+ h("label", { className: "sm-viewField" },
2223
+ h("span", null, t("marks.priority")),
2224
+ h("select", { className: "sm-viewSelect", "aria-label": t("marks.filter.priority"), value: priorityFilter, disabled: !marksReady, onChange: e => setPriorityFilter(e.target.value) },
2225
+ h("option", { value: "all" }, t("marks.priority.all")),
2226
+ [1,2,3,4,5].map(value => h("option", { key: value, value: String(value) }, t("marks.priority." + value)))
2227
+ )
2228
+ )
2229
+ ),
2230
+ h("div", { className: "sm-markFilters" },
2231
+ h("div", { className: "sm-markFiltersGroup" },
2232
+ filterBtn("all", t("panel.filter.all")),
2233
+ filterBtn("active", t("panel.filter.active")),
2234
+ filterBtn("archived", t("panel.filter.archived")),
2235
+ h("button", { type: "button", className: "sm-filterBtn sm-markFilterToggle" + (onlyFavorite ? " sm-on" : ""), "aria-label": t("marks.filter.favorite.aria"), "aria-pressed": onlyFavorite, disabled: !marksReady, onClick: () => setOnlyFavorite(value => !value) }, "★ " + t("marks.filter.favorite")),
2236
+ h("button", { type: "button", className: "sm-filterBtn sm-markFilterToggle" + (onlyReview ? " sm-on" : ""), "aria-label": t("marks.filter.review.aria"), "aria-pressed": onlyReview, disabled: !marksReady, onClick: () => setOnlyReview(value => !value) }, "◷ " + t("marks.filter.review"))
2237
+ ),
2238
+ h("div", { className: "sm-markFiltersSpacer" }),
2239
+ h("div", { className: "sm-markFiltersGroup" },
2240
+ h("button", {
2241
+ type: "button",
2242
+ className: "sm-filterBtn" + (selectModeEnabled ? " sm-on" : ""),
2243
+ disabled: busyId !== null || migrating,
2244
+ "aria-pressed": selectModeEnabled,
2245
+ "aria-label": t(selectModeEnabled ? "panel.selectMode.active.aria" : "panel.selectMode.aria"),
2246
+ onClick: toggleSelectMode
2247
+ }, selectModeEnabled ? t("panel.selectMode.exit") : t("panel.selectMode")),
2248
+ h("button", { type: "button", className: "sm-filterBtn sm-bulkSelectBtn", disabled: rows.length === 0 || rows.length === selectedIds.size || bulkBusy, onClick: onBulkSelectAll, "aria-label": t("bulk.selectAllFiltered") }, t("bulk.selectAllFiltered")),
2249
+ h("button", { type: "button", className: "sm-filterBtn sm-bulkSelectBtn", disabled: selectedIds.size === 0 || bulkBusy, onClick: onBulkClear, "aria-label": t("bulk.clear") }, t("bulk.clear"))
2250
+ )
2251
+ ),
2252
+ h("div", { className: "sm-resultSummary" },
2253
+ h("span", { role: "status", "aria-live": "polite", "aria-atomic": "true" }, t("panel.count", { shown: rows.length, total: allRows.length })),
2254
+ hasViewChanges ? h("button", { type: "button", className: "sm-resetFilters", onClick: resetView }, t("panel.reset")) : null
2255
+ ),
2256
+ annotations.loading && !annotations.loaded ? h("div", { className: "sm-workspaceNotice", role: "status" }, t("marks.loading")) : null,
2257
+ annotations.error ? h("div", { className: "sm-workspaceNotice", role: "alert" }, t("marks.error", { message: annotations.error }), h("button", { type: "button", className: "sm-resetFilters", onClick: () => annotationStore.load(true) }, t("marks.retry"))) : null,
2258
+ creationTimeUnavailable && !workspacesLoading ? h("div", { className: "sm-workspaceNotice", role: "status" }, t("panel.sort.unavailable")) : null,
2259
+ workspacesLoading ? h("div", { className: "sm-workspaceNotice", role: "status" }, t("panel.workspace.loading")) : null,
2260
+ workspacesError ? h("div", { className: "sm-workspaceNotice", role: "alert" },
2261
+ h("span", null, t("panel.workspace.error", { message: workspacesError })),
2262
+ h("button", { type: "button", className: "sm-resetFilters", onClick: () => setWorkspaceRefresh(value => value + 1) }, t("panel.workspace.retry"))
2263
+ ) : null
2264
+ ),
2265
+ error ? h("div", { className: "sm-error", role: "alert" }, t("error.operation", { message: error })) : null,
2266
+ (selectedIds.size > 0 || selectModeEnabled) ? h(BulkActionBar, {
2267
+ t, selectedCount: selectedIds.size, filteredCount: rows.length, busy: bulkBusy,
2268
+ onSelectAll: onBulkSelectAll, onClear: onBulkClear,
2269
+ onAction: onBulkAction,
2270
+ onOpenTagDialog: onBulkTagsAdd,
2271
+ onOpenPriorityDialog: onBulkPriority,
2272
+ onOpenMoveDialog: onBulkMove,
2273
+ onOpenPresetDialog: onBulkPreset
2274
+ }) : null,
2275
+ rowsList
2276
+ )
2277
+ )
2278
+ );
2279
+
2280
+ const deleteDialog = confirmFor === null ? null : h(ConfirmDialog, {
2281
+ open: true,
2282
+ onClose: () => { if (busyId === null) setConfirmFor(null); },
2283
+ title: t("confirm.delete.title"),
2284
+ description: t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }),
2285
+ warning: confirmFor.running ? t("confirm.delete.running") : null,
2286
+ confirmLabel: t("confirm.delete.confirm"),
2287
+ cancelLabel: t("confirm.cancel"),
2288
+ busy: busyId !== null,
2289
+ danger: true,
2290
+ onCancel: () => { if (busyId === null) setConfirmFor(null); },
2291
+ onConfirm: () => {
2292
+ const target = confirmFor;
2293
+ setConfirmFor(null);
2294
+ runRow(target.id, () => onRemove(target.id));
2295
+ }
2296
+ });
2297
+ const moveDialog = moveFor === null ? null : h(MoveDialog, {
2298
+ open: true,
2299
+ onClose: () => { if (busyId === null) setMoveFor(null); },
2300
+ title: t("confirm.move.title"),
2301
+ description: t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }),
2302
+ sessionTitle: moveFor.displayTitle || moveFor.id,
2303
+ currentWorkspaceId: moveFor.workspaceId,
2304
+ workspaces: workspaces,
2305
+ confirmLabel: t("confirm.move.confirm"),
2306
+ cancelLabel: t("confirm.cancel"),
2307
+ busy: busyId !== null,
2308
+ onCancel: () => { if (busyId === null) setMoveFor(null); },
2309
+ onConfirm: (targetWorkspaceId) => {
2310
+ const target = moveFor;
2311
+ setMoveFor(null);
2312
+ runRow(target.id, async () => {
2313
+ await onMove(target.id, targetWorkspaceId);
2314
+ setWorkspaceRefresh(value => value + 1);
2315
+ });
2316
+ }
2317
+ });
2318
+ const migrateDialog = migrateFor === null ? null : h(MigratePresetDialog, {
2319
+ open: true,
2320
+ t: t,
2321
+ sessionId: migrateFor.id,
2322
+ currentPreset: migrateFor.agentPreset || "",
2323
+ onCancel: () => { if (!migrating) setMigrateFor(null); },
2324
+ busy: migrating,
2325
+ onRun: async (targetPreset) => {
2326
+ const target = migrateFor;
2327
+ setMigrating(true);
2328
+ setError(null);
2329
+ try {
2330
+ await onMigrate(target.id, targetPreset);
2331
+ setMigrateFor(null);
2332
+ try { window.alert(t("migrate.completed", { preset: targetPreset })); } catch (_) {}
2333
+ } catch (error) {
2334
+ setError(error instanceof Error ? error.message : String(error));
2335
+ setMigrateFor(null);
2336
+ } finally {
2337
+ setMigrating(false);
2338
+ }
2339
+ }
2340
+ });
2341
+ // Render the panel AND the inner dialog as siblings so React keeps
2342
+ // both mounted. CSS z-index keeps the right ordering: the inner
2343
+ // dialog (.sm-confirmDialog / .sm-moveDialog / .sm-migrateDialog)
2344
+ // carries z-index 10000 and therefore sits ON TOP OF the panel
2345
+ // (z-index 9999). Clicking inside the panel that the inner dialog
2346
+ // blocks is not possible because the inner backdrop covers the
2347
+ // panel area entirely (the inner modal is centered).
2348
+ const annotationDialog = annotationFor ? h(AnnotationDialog, { key: annotationFor.id, sessionId: annotationFor.id, displayTitle: annotationFor.displayTitle || annotationFor.id, t, anchor: annotationAnchor, onClose: () => { setAnnotationFor(null); setAnnotationAnchor(null); } }) : null;
2349
+ // Render panel + ALL inner dialogs together; z-index ordering puts
2350
+ // whichever dialog is open above the panel. The bulk dialogs are
2351
+ // appended here so destructive actions get a preview, in-flight
2352
+ // operations get a progress bar, and finished runs show per-id
2353
+ // success / failed / skipped counts.
2354
+ // Map bulk action -> 4-char confirm label ("确认<verb>"). Delete is
2355
+ // the only destructive action routed through BatchPreviewDialog, so it
2356
+ // also gets danger: true so the confirm button paints red.
2357
+ const bulkConfirmLabelFor = (action) => ({
2358
+ archive: t("bulk.confirm.archive"),
2359
+ unarchive: t("bulk.confirm.unarchive"),
2360
+ favorite: t("bulk.confirm.favorite"),
2361
+ unfavorite: t("bulk.confirm.unfavorite"),
2362
+ review: t("bulk.confirm.review"),
2363
+ unreview: t("bulk.confirm.unreview"),
2364
+ "add-tags": t("bulk.confirm.tagsAdd"),
2365
+ "remove-tags": t("bulk.confirm.tagsRemove"),
2366
+ "set-priority": t("bulk.confirm.priority"),
2367
+ move: t("bulk.confirm.move"),
2368
+ "preset-migrate": t("bulk.confirm.presetMigrate"),
2369
+ delete: t("bulk.confirm.delete")
2370
+ })[action] || t("bulk.dialog.execute");
2371
+ const bulkPreviewDialog = bulkPreview ? h(BatchPreviewDialog, {
2372
+ open: true, t, actionLabel: bulkPreview.actionLabel,
2373
+ sessions: bulkPreview.sessions, warning: bulkPreview.warning,
2374
+ confirmLabel: bulkConfirmLabelFor(bulkPreview.action),
2375
+ danger: bulkPreview.action === "delete",
2376
+ onCancel: () => setBulkPreview(null),
2377
+ onExecute: () => {
2378
+ const preview = bulkPreview;
2379
+ setBulkPreview(null);
2380
+ void executeBulk({
2381
+ action: preview.action, payload: preview.payload,
2382
+ actionLabel: preview.actionLabel,
2383
+ ids: preview.sessions.map(s => s.id), sessions: preview.sessions
2384
+ });
2385
+ }
2386
+ }) : null;
2387
+ const bulkProgressDialog = bulkProgress ? h(BatchProgressDialog, {
2388
+ open: true, t, actionLabel: bulkProgress.actionLabel,
2389
+ done: bulkProgress.done, total: bulkProgress.total, completed: false,
2390
+ onCancel: () => { try { bulkProgress.cancel(); } catch (_) {} }
2391
+ }) : null;
2392
+ const bulkResultDialog = bulkResult ? h(BatchResultDialog, {
2393
+ open: true, t, actionLabel: bulkResult.actionLabel,
2394
+ items: bulkResult.items,
2395
+ onClose: () => { setBulkResult(null); clearSelection(); },
2396
+ onRetry: (failedIds) => {
2397
+ const last = bulkResult;
2398
+ setBulkResult(null);
2399
+ retryFailedBulk(failedIds, last.action, last.payload, last.actionLabel);
2400
+ }
2401
+ }) : null;
2402
+ const bulkTagDialog = bulkTagDraft && bulkTagDraft.mode === "add" ? h(BulkTagInputDialog, {
2403
+ open: true, t, title: t("bulk.tagsAdd"),
2404
+ confirmLabel: t("bulk.confirm.tagsAdd"),
2405
+ onCancel: () => setBulkTagDraft(null),
2406
+ onApply: onBulkTagApply
2407
+ }) : null;
2408
+ const bulkPriorityDialog = bulkTagDraft && bulkTagDraft.mode === "priority" ? h(BulkChoiceDialog, {
2409
+ open: true, t, title: t("bulk.priority"),
2410
+ description: t("bulk.dialog.summary", { count: selectedIds.size, action: t("bulk.priority") }),
2411
+ options: bulkPriorityOptions,
2412
+ confirmLabel: t("bulk.confirm.priority"),
2413
+ onCancel: () => setBulkTagDraft(null),
2414
+ onApply: onBulkPriorityApply
2415
+ }) : null;
2416
+ const bulkMoveDialog = bulkTagDraft && bulkTagDraft.mode === "move" ? h(BulkChoiceDialog, {
2417
+ open: true, t, title: t("bulk.move"),
2418
+ description: t("bulk.dialog.summary", { count: selectedIds.size, action: t("bulk.move") }),
2419
+ options: bulkMoveOptions,
2420
+ confirmLabel: t("bulk.confirm.move"),
2421
+ onCancel: () => setBulkTagDraft(null),
2422
+ onApply: onBulkMoveApply
2423
+ }) : null;
2424
+ const bulkPresetDialog = bulkPresetDraft ? h(BulkChoiceDialog, {
2425
+ open: true, t, title: t("bulk.presetMigrate"),
2426
+ description: t("bulk.dialog.summary", { count: selectedIds.size, action: t("bulk.presetMigrate") }),
2427
+ options: bulkPresetOptions,
2428
+ confirmLabel: t("bulk.confirm.presetMigrate"),
2429
+ onCancel: () => setBulkPresetDraft(null),
2430
+ onApply: onBulkPresetApply
2431
+ }) : null;
2432
+ // Bulk dialogs sit at z-index 10002 (set in CSS) so they overlap the
2433
+ return h(React.Fragment, null, panelModal, annotationDialog, moveDialog, deleteDialog, migrateDialog, bulkPreviewDialog, bulkProgressDialog, bulkResultDialog, bulkTagDialog, bulkPriorityDialog, bulkMoveDialog, bulkPresetDialog);
2434
+ }
2435
+
2436
+ function HeaderAction(props) {
2437
+ const t = props.t;
2438
+ const sessionId = props.sessionId;
2439
+ const annotations = useAnnotations();
2440
+ const [annotationFor, setAnnotationFor] = useState(null);
2441
+ const [annotationAnchor, setAnnotationAnchor] = useState(null);
2442
+ // Slot framework spreads inject-factory return into top-level props,
2443
+ // so the action callbacks live directly on props (not under .actions).
2444
+ const onArchive = props.onArchive, onUnarchive = props.onUnarchive;
2445
+ const onMove = props.onMove, onRemove = props.onRemove;
2446
+ // Archive state lives in the workspace registry's archivedSessionIds
2447
+ // (exposed through the global standard kit's useWorkspaces selector).
2448
+ const useWorkspaces = props.useWorkspaces;
2449
+ const archivedIds = (useWorkspaces ? useWorkspaces((s) => s.archivedSessionIds) : null) || [];
2450
+ const isArchived = archivedIds.includes(sessionId);
2451
+
2452
+ const [confirmFor, setConfirmFor] = useState(null);
2453
+ const [confirmAnchor, setConfirmAnchor] = useState(null);
2454
+ const [moveFor, setMoveFor] = useState(null);
2455
+ const [moveAnchor, setMoveAnchor] = useState(null);
2456
+ const [workspaces, setWorkspaces] = useState([]);
2457
+ const runWithAlert = (label, fn) => {
2458
+ return Promise.resolve()
2459
+ .then(() => fn())
2460
+ .catch((e) => {
2461
+ try {
2462
+ const msg = e instanceof Error ? e.message : String(e);
2463
+ window.alert(((t && t("error.operation")) || "操作失败:{message}").replace("{message}", msg));
2464
+ } catch (_) {}
2465
+ });
2466
+ };
2467
+ useEffect(() => {
2468
+ if (!moveFor) { setWorkspaces([]); return; }
2469
+ let alive = true;
2470
+ fetchApi("/workspaces").then((d) => {
2471
+ if (alive && d && d.ok) setWorkspaces(d.result.workspaces || []);
2472
+ });
2473
+ return () => { alive = false; };
2474
+ }, [moveFor ? (moveFor.id || "x") : null]);
2475
+ // All three HeaderAction dialogs (annotation / move / delete)
2476
+ // share a 360px width. Anchor the dialog's right edge to the
2477
+ // trigger button's right edge, with 8px screen-edge padding.
2478
+ const anchorFor = (el) => {
2479
+ if (!el || typeof el.getBoundingClientRect !== "function") return null;
2480
+ const r = el.getBoundingClientRect();
2481
+ return {
2482
+ top: Math.round(r.top + r.height + 6),
2483
+ left: Math.max(8, Math.round(r.right - 360))
2484
+ };
2485
+ };
2486
+ const archiveLabelKey = isArchived ? "row.unarchive" : "row.archive";
2487
+ return h(React.Fragment, null,
2488
+ annotationFor ? h(AnnotationDialog, { key: annotationFor.id, sessionId: annotationFor.id, displayTitle: annotationFor.displayTitle, t, anchor: annotationAnchor, onClose: () => { setAnnotationFor(null); setAnnotationAnchor(null); } }) : null,
2489
+ h("div", { className: "sm-header" },
2490
+ h("button", {
2491
+ type: "button",
2492
+ className: "sm-headerBtn" + (isArchived ? " sm-headerBtnActive" : ""),
2493
+ "aria-label": t ? t(archiveLabelKey) : (isArchived ? "移出归档" : "归档会话"),
2494
+ onClick: () => runWithAlert("archive", () =>
2495
+ isArchived ? onUnarchive(sessionId) : onArchive(sessionId)
2496
+ )
2497
+ }, isArchived ? (t ? t("row.unarchive") : "移出归档") : (t ? t("row.archive") : "归档")),
2498
+
2499
+ annotationButtons(sessionId, props.displayTitle || sessionId, t, annotations, "sm-headerBtn", patch => runWithAlert("annotations", () => annotationStore.save(sessionId, patch)), (e) => {
2500
+ const nextOpen = !(annotationFor && annotationFor.id === sessionId);
2501
+ // Do NOT clear moveFor / confirmFor here: the z-index counter
2502
+ // (see nextDialogZ) decides stacking, so opening the annotation
2503
+ // popup must not silently dismiss a previously opened move or
2504
+ // delete popup on the same session.
2505
+ if (nextOpen && e && e.currentTarget) {
2506
+ const r = e.currentTarget.getBoundingClientRect();
2507
+ setAnnotationAnchor({ top: Math.round(r.top + r.height + 6), left: Math.max(8, Math.round(r.right - 360)) });
2508
+ } else setAnnotationAnchor(null);
2509
+ setAnnotationFor(current => current?.id === sessionId ? null : { id: sessionId, displayTitle: props.displayTitle || sessionId });
2510
+ }, false, annotationFor?.id === sessionId),
2511
+ annotations.error ? h("button", { type: "button", className: "sm-headerBtn", title: t("marks.error", { message: annotations.error }), "aria-label": t("marks.retry"), onClick: () => annotationStore.load(true) }, "!") : null,
2512
+
2513
+ h("button", {
2514
+ type: "button",
2515
+ className: "sm-headerBtn" + (moveFor ? " sm-headerBtnActive" : ""),
2516
+ "aria-label": t ? t("row.move") : "移动至工作区",
2517
+ onClick: (e) => {
2518
+ e.stopPropagation();
2519
+ // Do NOT clear confirmFor / confirmAnchor here: opening the
2520
+ // move popup must not silently dismiss a previously opened
2521
+ // delete popup on the same session. The z-index counter
2522
+ // (see nextDialogZ) handles stacking.
2523
+ if (moveFor && moveFor.id === sessionId) {
2524
+ setMoveFor(null);
2525
+ setMoveAnchor(null);
2526
+ } else {
2527
+ setMoveAnchor(anchorFor(e.currentTarget));
2528
+ setMoveFor({ id: sessionId, displayTitle: sessionId, workspaceId: "" });
2529
+ }
2530
+ }
2531
+ }, t ? t("row.move") : "移动至工作区"),
2532
+ h("button", {
2533
+ type: "button",
2534
+ className: "sm-headerBtn sm-headerBtnDanger" + (confirmFor ? " sm-headerBtnActive" : ""),
2535
+ "aria-label": t ? t("row.delete") : "删除会话",
2536
+ onClick: (e) => {
2537
+ e.stopPropagation();
2538
+ // Do NOT clear moveFor / moveAnchor here: opening the delete
2539
+ // popup must not silently dismiss a previously opened move
2540
+ // popup on the same session. The z-index counter (see
2541
+ // nextDialogZ) handles stacking.
2542
+ if (confirmFor && confirmFor.id === sessionId) {
2543
+ setConfirmFor(null);
2544
+ setConfirmAnchor(null);
2545
+ } else {
2546
+ setConfirmAnchor(anchorFor(e.currentTarget));
2547
+ setConfirmFor({ id: sessionId, displayTitle: sessionId });
2548
+ }
2549
+ }
2550
+ }, t ? t("row.delete") : "删除会话")
2551
+ ),
2552
+ confirmFor === null ? null : h(ConfirmDialog, {
2553
+ open: true,
2554
+ anchor: confirmAnchor,
2555
+ onClose: () => setConfirmFor(null),
2556
+ onCancel: () => setConfirmFor(null),
2557
+ onConfirm: () => {
2558
+ const target = confirmFor;
2559
+ setConfirmFor(null);
2560
+ runWithAlert("delete", () => onRemove(target.id));
2561
+ },
2562
+ title: t ? t("confirm.delete.title") : "删除会话",
2563
+ description: (confirmFor && (t ? t("confirm.delete.desc", { title: confirmFor.displayTitle || confirmFor.id }) : ("将删除 " + (confirmFor.displayTitle || confirmFor.id)))) || "",
2564
+ confirmLabel: t ? t("confirm.delete.confirm") : "确认删除",
2565
+ cancelLabel: t ? t("confirm.cancel") : "取消",
2566
+ busy: false,
2567
+ danger: true
2568
+ }),
2569
+ moveFor === null ? null : h(MoveDialog, {
2570
+ open: true,
2571
+ anchor: moveAnchor,
2572
+ t: t,
2573
+ workspaces: workspaces,
2574
+ currentWorkspaceId: moveFor.workspaceId || "",
2575
+ onClose: () => { setMoveFor(null); setMoveAnchor(null); },
2576
+ onCancel: () => { setMoveFor(null); setMoveAnchor(null); },
2577
+ title: t ? t("confirm.move.title") : "移动会话到工作区",
2578
+ description: (moveFor && (t ? t("confirm.move.desc", { title: moveFor.displayTitle || moveFor.id }) : "")) || "",
2579
+ sessionTitle: moveFor.displayTitle || moveFor.id,
2580
+ confirmLabel: t ? t("confirm.move.confirm") : "确认移动",
2581
+ cancelLabel: t ? t("confirm.cancel") : "取消",
2582
+ busy: false,
2583
+ onConfirm: (targetWorkspaceId) => {
2584
+ const target = moveFor;
2585
+ setMoveFor(null);
2586
+ setMoveAnchor(null);
2587
+ runWithAlert("move", () => onMove(target.id, targetWorkspaceId));
2588
+ }
2589
+ })
2590
+ );
2591
+ }
2592
+
2593
+ class SafePanel extends React.Component {
2594
+ constructor(p) { super(p); this.state = { failed: false }; }
2595
+ static getDerivedStateFromError() { return { failed: true }; }
2596
+ componentDidCatch(error) { try { console.error("[dsh-session-manager] SessionManagerPanel crashed:", error); } catch (_) {} }
2597
+ render() {
2598
+ if (this.state.failed) return null;
2599
+ return h(SessionManagerPanel, {
2600
+ open: true,
2601
+ onClose: () => { try { this.props.panelStore.set(false); } catch (_) {} },
2602
+ useSessions: this.props.useSessions,
2603
+ useWorkspaces: this.props.useWorkspaces,
2604
+ t: this.props.t,
2605
+ // Bulk operations need to refresh ctx.sessions.list and the
2606
+ // workspace projection after /batch mutates disk state so the
2607
+ // panel reflects the new layout.
2608
+ onRefreshAfterBulk: async () => {
2609
+ try { await this.props.props.onBulkRefresh(); } catch (_) { /* ignore */ }
2610
+ },
2611
+ onOpen: (id) => { try { this.props.panelStore.set(false); this.props.props.onOpen(id); } catch (_) {} },
2612
+ onArchive: this.props.props.onArchive,
2613
+ onUnarchive: this.props.props.onUnarchive,
2614
+ onRemove: this.props.props.onRemove,
2615
+ onMove: this.props.props.onMove,
2616
+ onMigrate: this.props.props.onMigrate
2617
+ });
2618
+ }
2619
+ }
2620
+
2621
+ function FooterAction(props) {
2622
+ const t = props.t;
2623
+ const onOpenPanel = props.onOpenPanel;
2624
+ // props.wide comes from SidebarRoot via SidebarFooterActionOwnerProps.
2625
+ // When the sidebar is collapsed into the 56px rail, render a 36x36
2626
+ // icon-only button so the Chinese label does not word-wrap vertically
2627
+ // (issue #16); when expanded, render a full-width row with icon +
2628
+ // label, left-aligned to match neighbouring footer entries.
2629
+ const wide = props.wide === true;
2630
+ const open = useSyncExternalStore(
2631
+ (cb) => panelStore.subscribe(cb),
2632
+ () => panelStore.getSnapshot(),
2633
+ () => panelStore.getSnapshot()
2634
+ );
2635
+ const footerClass = "sm-footer" + (wide ? " sm-footer-wide" : " sm-footer-rail");
2636
+ const btnClass = "sm-footerBtn" + (wide ? " sm-footerBtn-wide" : " sm-footerBtn-rail");
2637
+ const btnLabel = t("footer.aria");
2638
+ return h("div", { className: footerClass },
2639
+ h("button", {
2640
+ type: "button",
2641
+ className: btnClass,
2642
+ "aria-label": btnLabel,
2643
+ title: wide ? undefined : btnLabel,
2644
+ onClick: () => onOpenPanel()
2645
+ },
2646
+ h(P.IconArchiveOutlineRegular, { size: wide ? 14 : 18 }),
2647
+ wide ? h("span", { className: "sm-footerBtn-label" }, t("footer.label")) : null
2648
+ ),
2649
+ open ? h(SafePanel, {
2650
+ panelStore: panelStore,
2651
+ props: props,
2652
+ t: t,
2653
+ useSessions: props.useSessions,
2654
+ useWorkspaces: props.useWorkspaces,
2655
+ }) : null
2656
+ );
2657
+ }
2658
+
2659
+ function apply(ctx) {
2660
+ ctx.effect(() => {
2661
+ const refresh = () => { if (annotationStore.hasSubscribers()) void annotationStore.load(true); };
2662
+ window.addEventListener("focus", refresh);
2663
+ let channel;
2664
+ try {
2665
+ if (typeof BroadcastChannel === "function") {
2666
+ channel = new BroadcastChannel("dsh-session-manager-annotations-v1");
2667
+ channel.onmessage = event => { if (event.data?.type === "changed") refresh(); };
2668
+ annotationStore.notify = () => channel.postMessage({ type: "changed" });
2669
+ }
2670
+ } catch (_) { /* unavailable browser messaging: focus refresh still works */ }
2671
+ return () => { window.removeEventListener("focus", refresh); channel?.close(); annotationStore.notify = null; };
2672
+ }, "session-manager: annotation synchronization");
2673
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-manager: dictionaries");
2674
+ const actions = () => ({
2675
+ // DSH 0.1.6+ removed ctx.sessions.open(); the navigation owner is
2676
+ // `ctx.uiWorkspace.openSession()`, and the bare SessionController only
2677
+ // exposes `retain()`. Pick the live surface so the row's "Open" button
2678
+ // actually navigates on every supported host build.
2679
+ onOpen: (sessionId) => {
2680
+ const uiWorkspace = ctx.uiWorkspace;
2681
+ if (uiWorkspace && typeof uiWorkspace.openSession === "function") {
2682
+ uiWorkspace.openSession(sessionId);
2683
+ return;
2684
+ }
2685
+ const sessions = ctx.sessions;
2686
+ if (sessions && typeof sessions.retain === "function") {
2687
+ // The bare retain() returns a SessionReference whose release()
2688
+ // tears down the in-memory binding; the user normally expects the
2689
+ // session to stay open, so a header-less build simply keeps it.
2690
+ sessions.retain(sessionId, { source: "mainView" });
2691
+ return;
2692
+ }
2693
+ throw new Error("session-manager: no Session open API on this DSH host");
2694
+ },
2695
+ onArchive: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId); },
2696
+ onUnarchive: async (sessionId) => {
2697
+ const response = await callApi("/unarchive", { sessionId });
2698
+ if (!response.ok) throw new Error(response.error || "unarchive failed");
2699
+ },
2700
+ onRemove: async (sessionId) => {
2701
+ const response = await callApi("/delete", { sessionId });
2702
+ if (!response.ok) throw new Error(response.error || "delete failed");
2703
+ await annotationStore.load(true);
2704
+ if (response.result?.warning) window.alert(response.result.warning);
2705
+ const current = ctx.sessions.list.getSnapshot().current;
2706
+ if (current === sessionId) ctx.sessions.clear();
2707
+ },
2708
+ onMove: async (sessionId, targetWorkspaceId) => {
2709
+ const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
2710
+ const response = await callApi("/move", { sessionId, targetWorkspaceId });
2711
+ if (!response.ok) throw new Error(response.error || "move failed");
2712
+ const refreshMovedSession = async () => {
2713
+ await Promise.allSettled([
2714
+ typeof ctx.workspaces?.refresh === "function" ? ctx.workspaces.refresh() : Promise.resolve(),
2715
+ ctx.sessions.refresh()
2716
+ ]);
2717
+ if (wasCurrent && ctx.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
2718
+ ctx.sessions.open(sessionId);
2719
+ }
2720
+ };
2721
+ // The 0.4.6+ move path keeps the live agent/session intact (no
2722
+ // session/disposed, no half-resumed Session object), so the chat
2723
+ // panel keeps the in-memory session as active. We still refresh
2724
+ // the workspace + session-list projections so the destination
2725
+ // workspace and the moved row appear without a manual refresh.
2726
+ await refreshMovedSession();
2727
+ setTimeout(() => { void refreshMovedSession(); }, 250);
2728
+ setTimeout(() => { void refreshMovedSession(); }, 900);
2729
+ },
2730
+ onMigrate: async (sessionId, toPreset) => {
2731
+ const wasCurrent = ctx.sessions.list.getSnapshot().current === sessionId;
2732
+ const response = await callApi("/preset-migrate", { sessionId, toPreset });
2733
+ if (!response.ok) throw new Error(response.error || "preset migration failed");
2734
+ // The current DSH client session store derives agentPreset from
2735
+ // the refreshed projection; older builds exposed noteAgentPreset,
2736
+ onOpenPanel: () => panelStore.toggle(),
2737
+ await ctx.sessions.refresh();
2738
+ // The migrate rewrite happens in-process on the live session, so
2739
+ // we just need a delayed baseline to let the api-gateway's projection
2740
+ // settle before re-activating the in-memory session.
2741
+ setTimeout(() => {
2742
+ void ctx.sessions.refresh().then(() => {
2743
+ if (wasCurrent) ctx.sessions.open(sessionId);
2744
+ }).catch(() => {});
2745
+ }, 250);
2746
+ },
2747
+ onOpenPanel: () => panelStore.toggle(),
2748
+ onBulkRefresh: async () => { try { await ctx.sessions.refresh(); } catch (_) { /* ignore */ } try { await ctx.workspaces.refresh(); } catch (_) { /* ignore */ } },
2749
+ });
2750
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
2751
+ name: "conversation.session.header.actions",
2752
+ id: "session-manager-header",
2753
+ order: 40,
2754
+ locale: NS,
2755
+ inject: () => actions()
2756
+ }, HeaderAction));
2757
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
2758
+ name: "sidebar.footer.action",
2759
+ id: "session-manager-footer",
2760
+ order: 30,
2761
+ locale: NS,
2762
+ inject: () => actions()
2763
+ }, FooterAction));
2764
+ }
2765
+
2766
+ const inject = ["slots", "sessions", "workspaces", "locale", "uiWorkspace"];
2767
+
2768
+ exports.apply = apply;
2769
+ exports.inject = inject;
2770
+ return module.exports;
2771
+ }
2772
+ });