dsh-better-workspace 0.4.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/src/client.js ADDED
@@ -0,0 +1,2053 @@
1
+ /**
2
+ * dsh-better-workspace — client half (plain JavaScript, no build step).
3
+ *
4
+ * Three registrations:
5
+ * 1. `sidebar.workspaces` (priority -1): replaces the shipped workspace
6
+ * browser with a hierarchy tree derived from "/" inside workspace titles.
7
+ * 2. `conversation.hero.workspace.directoryFlow`: the add-workspace picking
8
+ * interaction for the conversation empty-state menu — native directory
9
+ * pick, then a parent-group popup, then create + rename with the prefix.
10
+ * 3. `sidebar.workspaces.directoryFlow`: the same interaction for the
11
+ * shipped sidebar browser (fills only when that hole is declared, i.e.
12
+ * whenever this plugin's own browser is not the occupying entry).
13
+ *
14
+ * Every require below is a dsh client baseline module (see
15
+ * @deepseek-ai/dsh-client-web seed.ts): react, @deepseek-ai/dsh-client-store,
16
+ * @deepseek-ai/dsh-client-ui-primitives.
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-better-workspace',
20
+ factory: (require) => {
21
+ const React = require('react')
22
+ const storeKit = require('@deepseek-ai/dsh-client-store')
23
+ const ui = require('@deepseek-ai/dsh-client-ui-primitives')
24
+
25
+ const E = React.createElement
26
+ const NS = 'betterWorkspace'
27
+
28
+ /* ============================== i18n ============================== */
29
+
30
+ const zh = {
31
+ 'title': '工作区',
32
+ 'search.placeholder': '搜索工作区或会话',
33
+ 'add': '添加工作区',
34
+ 'newFolder': '新建分组',
35
+ 'rail.search': '搜索',
36
+ 'rail.add': '添加工作区',
37
+ 'empty': '暂无工作区',
38
+ 'empty.search': '没有匹配的结果',
39
+ 'session.new': '新会话',
40
+ 'group.ungrouped': '未分组',
41
+ 'sessions.expand': '展开 {n} 个会话',
42
+ 'sessions.collapse': '收起',
43
+ 'time.now': '刚刚',
44
+ 'time.minutes': '{n} 分钟',
45
+ 'time.hours': '{n} 小时',
46
+ 'time.days': '{n} 天',
47
+ 'time.months': '{n} 个月',
48
+ 'time.years': '{n} 年',
49
+ 'status.running': '生成中',
50
+ 'status.completed': '已完成',
51
+ 'status.approval': '等待批准',
52
+ 'status.planReview': '等待计划确认',
53
+ 'status.question': '等待回答',
54
+ 'status.subagents': '{n} 个子任务运行中',
55
+ 'menu.rename': '重命名',
56
+ 'menu.delete': '删除',
57
+ 'menu.fork': '分叉',
58
+ 'menu.archive': '归档',
59
+ 'menu.renameFolder': '重命名分组',
60
+ 'menu.removeFolder': '删除分组',
61
+ 'menu.renameSgroup': '重命名会话分组',
62
+ 'settings.title': '更好的工作区',
63
+ 'settings.desc': '工作区树的外观与折叠偏好',
64
+ 'settings.expand': '展开',
65
+ 'settings.collapse': '收起',
66
+ 'settings.compactChains': '单链分组折叠显示',
67
+ 'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;展开状态与自定义外观保存在当前浏览器。',
68
+ 'custom.title': '自定义外观',
69
+ 'custom.color': '颜色',
70
+ 'custom.glow': '发光',
71
+ 'custom.preview': '实时预览',
72
+ 'custom.preview.sample': '工作区示例',
73
+ 'custom.weight': '字体粗细',
74
+ 'custom.weight.regular': '常规',
75
+ 'custom.weight.medium': '中',
76
+ 'custom.weight.semibold': '半粗',
77
+ 'custom.weight.bold': '粗',
78
+ 'custom.shadow': '字体阴影',
79
+ 'custom.weak': '弱',
80
+ 'custom.medium': '中',
81
+ 'custom.strong': '强',
82
+ 'custom.icon': '图标',
83
+ 'custom.icon.solid': '实心文件夹',
84
+ 'custom.icon.outline': '空心文件夹',
85
+ 'custom.icon.none': '不显示',
86
+ 'custom.none': '不显示',
87
+ 'custom.reset': '清除自定义',
88
+ 'custom.done': '完成',
89
+ 'settings.on': '开',
90
+ 'settings.off': '关',
91
+ 'flow.title': '添加工作区',
92
+ 'flow.picked': '所选文件夹',
93
+ 'flow.parent': '所属分组',
94
+ 'flow.parentHint': '输入或下拉选择分组路径,留空表示根分组;多级用 / 分隔',
95
+ 'flow.creating': '正在创建…',
96
+ 'error.title': '出错了',
97
+ 'cancel': '取消',
98
+ 'create': '创建',
99
+ 'confirm': '确定',
100
+ 'close': '关闭',
101
+ 'ws.rename.title': '重命名工作区',
102
+ 'ws.rename.hint': '名称中的 / 即层级分组,例如 web/前端',
103
+ 'ws.delete.title': '删除工作区',
104
+ 'ws.delete.body': '仅移除工作区登记,目录和会话记录都会保留。确定删除「{name}」?',
105
+ 'folder.new.title': '新建分组',
106
+ 'folder.new.hint': '分组路径,可用 / 表示多级,例如 web/前端',
107
+ 'folder.rename.title': '重命名分组',
108
+ 'folder.rename.hint': '重命名会同步更新组内所有工作区名称',
109
+ 'folder.delete.body': '删除空分组「{name}」?',
110
+ 'folder.error.empty': '分组路径不能为空',
111
+ 'folder.error.exists': '分组已存在',
112
+ 'folder.error.notEmpty': '分组内还有工作区,无法删除',
113
+ }
114
+
115
+ const en = {
116
+ 'title': 'Workspaces',
117
+ 'search.placeholder': 'Search workspaces or sessions',
118
+ 'add': 'Add workspace',
119
+ 'newFolder': 'New folder',
120
+ 'rail.search': 'Search',
121
+ 'rail.add': 'Add workspace',
122
+ 'empty': 'No workspaces yet',
123
+ 'empty.search': 'No matches',
124
+ 'session.new': 'New session',
125
+ 'group.ungrouped': 'Ungrouped',
126
+ 'sessions.expand': 'Show {n} more sessions',
127
+ 'sessions.collapse': 'Collapse',
128
+ 'time.now': 'now',
129
+ 'time.minutes': '{n}min',
130
+ 'time.hours': '{n}h',
131
+ 'time.days': '{n}d',
132
+ 'time.months': '{n}mo',
133
+ 'time.years': '{n}y',
134
+ 'status.running': 'Running',
135
+ 'status.completed': 'Completed',
136
+ 'status.approval': 'Waiting for approval',
137
+ 'status.planReview': 'Waiting for plan review',
138
+ 'status.question': 'Waiting for answer',
139
+ 'status.subagents': '{n} subagent(s) running',
140
+ 'menu.rename': 'Rename',
141
+ 'menu.delete': 'Delete',
142
+ 'menu.fork': 'Fork',
143
+ 'menu.archive': 'Archive',
144
+ 'menu.renameFolder': 'Rename folder',
145
+ 'menu.removeFolder': 'Delete folder',
146
+ 'menu.renameSgroup': 'Rename session group',
147
+ 'settings.title': 'Better Workspaces',
148
+ 'settings.desc': 'Workspace tree appearance and folding preferences',
149
+ 'settings.expand': 'Expand',
150
+ 'settings.collapse': 'Collapse',
151
+ 'settings.compactChains': 'Merge single-child chains',
152
+ 'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. State and custom styling persist in this browser.',
153
+ 'custom.title': 'Customize',
154
+ 'custom.color': 'Color',
155
+ 'custom.glow': 'Glow',
156
+ 'custom.preview': 'Live preview',
157
+ 'custom.preview.sample': 'Workspace sample',
158
+ 'custom.weight': 'Font weight',
159
+ 'custom.weight.regular': 'Regular',
160
+ 'custom.weight.medium': 'Medium',
161
+ 'custom.weight.semibold': 'Semi-bold',
162
+ 'custom.weight.bold': 'Bold',
163
+ 'custom.shadow': 'Font shadow',
164
+ 'custom.weak': 'Subtle',
165
+ 'custom.medium': 'Medium',
166
+ 'custom.strong': 'Strong',
167
+ 'custom.icon': 'Icon',
168
+ 'custom.icon.solid': 'Solid folder',
169
+ 'custom.icon.outline': 'Outline folder',
170
+ 'custom.icon.none': 'Hidden',
171
+ 'custom.none': 'None',
172
+ 'custom.reset': 'Clear custom style',
173
+ 'custom.done': 'Done',
174
+ 'settings.on': 'On',
175
+ 'settings.off': 'Off',
176
+ 'flow.title': 'Add workspace',
177
+ 'flow.picked': 'Chosen folder',
178
+ 'flow.parent': 'Parent group',
179
+ 'flow.parentHint': 'Type or pick a group path; empty means root. Nest with /',
180
+ 'flow.creating': 'Creating…',
181
+ 'error.title': 'Something went wrong',
182
+ 'cancel': 'Cancel',
183
+ 'create': 'Create',
184
+ 'confirm': 'OK',
185
+ 'close': 'Close',
186
+ 'ws.rename.title': 'Rename workspace',
187
+ 'ws.rename.hint': 'Use / inside the name to nest, e.g. web/frontend',
188
+ 'ws.delete.title': 'Delete workspace',
189
+ 'ws.delete.body': 'Only the workspace registration is removed; the directory and session logs remain. Delete "{name}"?',
190
+ 'folder.new.title': 'New folder',
191
+ 'folder.new.hint': 'Folder path; nest with /, e.g. web/frontend',
192
+ 'folder.rename.title': 'Rename folder',
193
+ 'folder.rename.hint': 'Renaming updates every workspace title inside the folder',
194
+ 'folder.delete.body': 'Delete empty folder "{name}"?',
195
+ 'folder.error.empty': 'Folder path must not be empty',
196
+ 'folder.error.exists': 'Folder already exists',
197
+ 'folder.error.notEmpty': 'Folder still contains workspaces',
198
+ }
199
+
200
+ /* ============================= helpers ============================ */
201
+
202
+ const cls = (...xs) => xs.filter(Boolean).join(' ')
203
+ const messageOf = (reason) => (reason instanceof Error ? reason.message : String(reason))
204
+
205
+ const basename = (p) => {
206
+ if (!p) return ''
207
+ const s = String(p).replace(/[\\/]+$/, '')
208
+ const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
209
+ return i === -1 ? s : s.slice(i + 1)
210
+ }
211
+ const splitTitleSegs = (title) => String(title || '').split('/').map(s => s.trim()).filter(Boolean)
212
+ const normPath = (p) => String(p || '').split('/').map(s => s.trim()).filter(Boolean).join('/')
213
+
214
+ /** Render a primitives icon by name; unknown names degrade to null, never crash. */
215
+ const icon = (name, size) => {
216
+ const C = ui[name]
217
+ return C ? E(C, { size: size || 16 }) : null
218
+ }
219
+
220
+ /** Folder-with-plus glyph for the new-group header button (absent from the primitives set). */
221
+ const FolderPlusIcon = (props) => E('svg', {
222
+ width: props.size || 16,
223
+ height: props.size || 16,
224
+ viewBox: '0 0 16 16',
225
+ fill: 'none',
226
+ stroke: 'currentColor',
227
+ strokeWidth: 1.2,
228
+ strokeLinecap: 'round',
229
+ strokeLinejoin: 'round',
230
+ 'aria-hidden': 'true',
231
+ },
232
+ E('path', { d: 'M1.75 4.6c0-.74.6-1.35 1.35-1.35h2.5l1.4 1.6h4.9c.74 0 1.35.6 1.35 1.35v5.9c0 .74-.6 1.35-1.35 1.35H3.1c-.74 0-1.35-.6-1.35-1.35V4.6z' }),
233
+ E('path', { d: 'M8 7.2v3.6M6.2 9h3.6' }),
234
+ )
235
+
236
+ const FALLBACK_UNITS = { minutes: 'm', hours: 'h', days: 'd', months: 'mo', years: 'y' }
237
+ const timeLabel = (updatedAt, now, t) => {
238
+ if (typeof ui.relativeTime !== 'function') return ''
239
+ const r = ui.relativeTime(updatedAt, now)
240
+ if (!r) return ''
241
+ if (r.unit === 'now') return t('time.now')
242
+ const key = 'time.' + r.unit
243
+ const out = t(key, { n: r.n })
244
+ if (typeof out === 'string' && out !== '' && out !== key) return out
245
+ return String(r.n) + (FALLBACK_UNITS[r.unit] || '')
246
+ }
247
+
248
+ const pendingKindOf = (pending, id) => {
249
+ if (!pending) return undefined
250
+ const p = typeof pending.get === 'function' ? pending.get(id) : pending[id]
251
+ if (!p) return undefined
252
+ return p.kind || p.status || p.type || 'pending'
253
+ }
254
+
255
+ const sessionTitleOf = (summary, t) => {
256
+ if (!summary) return ''
257
+ if (summary.blank) return t('session.new')
258
+ return String(summary.displayTitle || summary.title || '')
259
+ }
260
+
261
+ /**
262
+ * Official visibility rule (dsh tree.ts sessionVisible): subagent children
263
+ * live in their parent's catalog, archived sessions are visible nowhere,
264
+ * and a blank row is the provisional New Session of the current selection.
265
+ */
266
+ const sessionVisible = (summary, current, archivedSet) => !!summary
267
+ && summary.origin !== 'subagent'
268
+ && !(archivedSet && archivedSet.has(summary.id))
269
+ && (!summary.blank || summary.id === current)
270
+
271
+ /**
272
+ * Running subagent descendants per session (light lineage walk over
273
+ * parentSessionId links) — a parent row keeps its "ongoing" ring while a
274
+ * spawned subagent is still working.
275
+ */
276
+ const subagentRunningCounts = (byId) => {
277
+ const children = new Map()
278
+ for (const id of Object.keys(byId || {})) {
279
+ const summary = byId[id]
280
+ if (!summary || !summary.parentSessionId) continue
281
+ let list = children.get(summary.parentSessionId)
282
+ if (!list) { list = []; children.set(summary.parentSessionId, list) }
283
+ list.push(summary)
284
+ }
285
+ const countFor = (rootId) => {
286
+ let count = 0
287
+ const queue = (children.get(rootId) || []).slice()
288
+ const seen = new Set([rootId])
289
+ while (queue.length > 0) {
290
+ const summary = queue.shift()
291
+ if (!summary || seen.has(summary.id)) continue
292
+ seen.add(summary.id)
293
+ if (summary.running) count += 1
294
+ const kids = children.get(summary.id)
295
+ if (kids) for (const kid of kids) queue.push(kid)
296
+ }
297
+ return count
298
+ }
299
+ const counts = new Map()
300
+ for (const id of Object.keys(byId || {})) counts.set(id, countFor(id))
301
+ return counts
302
+ }
303
+
304
+ /**
305
+ * Session nesting inside one workspace: same "/" convention as workspace
306
+ * titles. Groups are virtual (projection of names). Rows keep the Host
307
+ * workspace.sessionIds (manual) order — drag-to-reorder must be visible.
308
+ */
309
+ function buildSessionTree(rows) {
310
+ const root = { path: '', name: '', groups: [], sessions: [] }
311
+ const byPath = new Map([['', root]])
312
+ const ensure = (path) => {
313
+ if (path === '') return root
314
+ const known = byPath.get(path)
315
+ if (known) return known
316
+ const segs = path.split('/')
317
+ const parent = ensure(segs.slice(0, -1).join('/'))
318
+ const node = { path, name: segs[segs.length - 1], groups: [], sessions: [] }
319
+ byPath.set(path, node)
320
+ parent.groups.push(node)
321
+ return node
322
+ }
323
+ for (const row of rows || []) {
324
+ const segs = splitTitleSegs(row.title)
325
+ const folderPath = segs.slice(0, -1).join('/')
326
+ const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
327
+ ensure(folderPath).sessions.push({ ...row, leaf })
328
+ }
329
+ const sortRec = (node) => {
330
+ node.groups.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
331
+ for (const group of node.groups) sortRec(group)
332
+ }
333
+ sortRec(root)
334
+ return root
335
+ }
336
+
337
+ const countSessionTree = (node) => node.sessions.length + node.groups.reduce((sum, group) => sum + countSessionTree(group), 0)
338
+
339
+ const collectSessionRows = (node) => {
340
+ const out = node.sessions.slice()
341
+ for (const group of node.groups) out.push(...collectSessionRows(group))
342
+ return out
343
+ }
344
+
345
+ const findSessionGroup = (node, path) => {
346
+ if (node.path === path) return node
347
+ for (const group of node.groups) {
348
+ const hit = findSessionGroup(group, path)
349
+ if (hit) return hit
350
+ }
351
+ return null
352
+ }
353
+
354
+ /**
355
+ * Build the folder tree. Folders are virtual: they exist where workspace
356
+ * titles contain "/", plus the explicit empty folders the user created.
357
+ * Returns { path, name, folders, workspaces } with workspaces carrying
358
+ * their leaf display name.
359
+ */
360
+ function buildTree(items, explicitFolders) {
361
+ const root = { path: '', name: '', folders: [], workspaces: [] }
362
+ const byPath = new Map([['', root]])
363
+ const ensure = (path) => {
364
+ if (path === '') return root
365
+ const known = byPath.get(path)
366
+ if (known) return known
367
+ const segs = path.split('/')
368
+ const parent = ensure(segs.slice(0, -1).join('/'))
369
+ const node = { path, name: segs[segs.length - 1], folders: [], workspaces: [] }
370
+ byPath.set(path, node)
371
+ parent.folders.push(node)
372
+ return node
373
+ }
374
+ for (const folder of explicitFolders || []) {
375
+ const p = normPath(folder)
376
+ if (p !== '') ensure(p)
377
+ }
378
+ for (const workspace of items || []) {
379
+ const segs = splitTitleSegs(workspace.title)
380
+ const folderPath = segs.slice(0, -1).join('/')
381
+ const leaf = segs.length > 0 ? segs[segs.length - 1] : (basename(workspace.path) || String(workspace.title || '') || String(workspace.workspaceId || ''))
382
+ ensure(folderPath).workspaces.push({
383
+ workspaceId: workspace.workspaceId,
384
+ title: String(workspace.title || ''),
385
+ path: String(workspace.path || ''),
386
+ sessionIds: Array.isArray(workspace.sessionIds) ? workspace.sessionIds : [],
387
+ leaf,
388
+ folderPath,
389
+ })
390
+ }
391
+ const sortRec = (node) => {
392
+ node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
393
+ for (const child of node.folders) sortRec(child)
394
+ }
395
+ sortRec(root)
396
+ return root
397
+ }
398
+
399
+ const countWorkspaces = (node) => (node.kind === 'ws' ? 1 : node.workspaces.length + node.folders.reduce((sum, f) => sum + countWorkspaces(f), 0))
400
+
401
+ /**
402
+ * VS Code-style single-child chain compression (preference-controlled):
403
+ * a folder level holding exactly ONE child and nothing else merges into a
404
+ * single display row — folder chains join names with "/" (keeps the DEEPEST
405
+ * path as its expansion identity), and a folder whose only child is one
406
+ * workspace becomes that workspace row with the merged label. Only
407
+ * presentation changes; the underlying workspace/title data is untouched.
408
+ */
409
+ function compressTree(node) {
410
+ const folders = (node.folders || []).map(compressTree)
411
+ const workspaces = node.workspaces || []
412
+ if (workspaces.length === 0 && folders.length === 1) {
413
+ const child = folders[0]
414
+ if (child.kind === 'ws') {
415
+ const ws = child.workspace
416
+ return {
417
+ kind: 'ws',
418
+ path: child.path,
419
+ workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' },
420
+ folders: [],
421
+ workspaces: [],
422
+ dropPath: child.path,
423
+ }
424
+ }
425
+ return { kind: 'folder', path: child.path, name: node.name + '/' + child.name, folders: child.folders || [], workspaces: child.workspaces || [], dropPath: child.path || node.path }
426
+ }
427
+ if (folders.length === 0 && workspaces.length === 1) {
428
+ const ws = workspaces[0]
429
+ return { kind: 'ws', path: node.path, workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' }, folders: [], workspaces: [], dropPath: node.path }
430
+ }
431
+ return { kind: 'folder', path: node.path, name: node.name, folders, workspaces, dropPath: node.path }
432
+ }
433
+
434
+ /* ============================== styles ============================ */
435
+
436
+ const CSS_TEXT = [
437
+ '.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;color:var(--dsw-alias-label-primary,#e6e6e6)}',
438
+ '.bw-header{display:flex;align-items:center;gap:2px;padding:10px 10px 4px;flex:none}',
439
+ '.bw-header-title{flex:1;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-secondary,#b8b8b8);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
440
+ '.bw-icon-btn{flex:none;width:24px;height:24px;border:none;background:transparent;border-radius:6px;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
441
+ '.bw-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.15));color:var(--dsw-alias-label-primary,#e6e6e6)}',
442
+ '.bw-search-row{flex:none;padding:0 10px 6px}',
443
+ '.bw-input{width:100%;box-sizing:border-box;height:26px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;color:inherit;padding:0 8px;font-size:12px;outline:none;font-family:inherit}',
444
+ '.bw-input:focus{border-color:var(--dsw-alias-brand-primary,#5b8def)}',
445
+ '.bw-input::placeholder{color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
446
+ '.bw-tree{flex:1;overflow-y:auto;overflow-x:hidden;padding:2px 6px 12px;min-height:0}',
447
+ '.bw-row{display:flex;align-items:center;gap:6px;min-height:28px;padding:0 6px;border-radius:6px;cursor:pointer;user-select:none;font-size:13px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative}',
448
+ '.bw-drop-before::after{content:"";position:absolute;left:8px;right:8px;top:-1px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary,#5b8def);pointer-events:none}',
449
+ '.bw-drop-after::after{content:"";position:absolute;left:8px;right:8px;bottom:-1px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary,#5b8def);pointer-events:none}',
450
+ '.bw-drop-into{outline:1.5px dashed var(--dsw-alias-brand-primary,#5b8def);outline-offset:-1.5px}',
451
+ '.bw-row:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)))}',
452
+ '.bw-row:hover{background:color-mix(in srgb,var(--dsw-specific-sidebar-nav-item-hover,rgba(127,127,127,.14)) 50%,transparent)}',
453
+ '.bw-row-current{background:var(--dsw-specific-sidebar-nav-item-active,rgba(91,141,239,.15))}',
454
+ '.bw-row-current{background:color-mix(in srgb,var(--dsw-specific-sidebar-nav-item-active,rgba(91,141,239,.16)) 40%,transparent)}',
455
+ '.bw-row-icon{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
456
+ '.bw-chevron{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .15s ease}',
457
+ '.bw-chevron-open{transform:rotate(90deg)}',
458
+ '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
459
+ '.bw-row-count{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
460
+ '.bw-row-time{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
461
+ '.bw-row-actions{flex:none;display:none;align-items:center;gap:2px}',
462
+ '.bw-row:hover .bw-row-actions{display:flex}',
463
+ '.bw-row:hover .bw-row-time,.bw-row:hover .bw-row-count{display:none}',
464
+ '.bw-dot{flex:none;width:6px;height:6px;border-radius:50%;background:transparent}',
465
+ '.bw-session-row{font-size:12.5px;color:var(--dsw-alias-label-secondary,#b8b8b8);min-height:26px}',
466
+ '.bw-sgroup-row{font-size:12.5px;color:var(--dsw-alias-label-tertiary,#9a9a9a);min-height:24px}',
467
+ '.bw-sgroup-row:hover{color:var(--dsw-alias-label-secondary,#b8b8b8)}',
468
+ '.bw-session-row:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}',
469
+ '.bw-empty{padding:28px 12px;text-align:center;font-size:12px;color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
470
+ '.bw-swatch{width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));cursor:pointer;flex:none;background:transparent;padding:0}',
471
+ '.bw-swatch-active{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:1px}',
472
+ '.bw-color-input{width:36px;height:26px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;cursor:pointer;padding:0}',
473
+ '.bw-seg{display:flex;gap:6px;flex-wrap:wrap}',
474
+ '.bw-seg-btn{height:24px;padding:0 10px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));background:transparent;color:var(--dsw-alias-label-secondary,#b8b8b8);font-size:12px;cursor:pointer;font-family:inherit}',
475
+ '.bw-seg-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
476
+ '.bw-seg-btn-active{background:var(--dsw-alias-brand-primary,#5b8def);border-color:transparent;color:var(--dsw-alias-brand-text,#fff)}',
477
+ '.bw-seg-btn-active:hover{background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#5b8def));border-color:transparent}',
478
+ '.bw-icon-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(30px,1fr));gap:4px}',
479
+ '.bw-icon-cell{height:30px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
480
+ '.bw-icon-cell:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,#e6e6e6)}',
481
+ '.bw-icon-cell-active{border-color:var(--dsw-alias-brand-primary,#5b8def);color:var(--dsw-alias-label-primary,#e6e6e6);outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px}',
482
+ '.bw-icon-none{width:12px;height:2px;background:currentColor;border-radius:1px;opacity:.7}',
483
+ '.bw-rgb-row{display:flex;gap:10px;align-items:center}',
484
+ '.bw-rgb-label{display:flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
485
+ '.bw-rgb-input{width:52px;height:26px;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;color:inherit;font-size:12px;padding:0 6px;font-family:inherit}',
486
+ '.bw-ctx-overlay{position:fixed;inset:0;z-index:40}',
487
+ '.bw-ctx-menu{position:fixed;min-width:170px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay,rgba(28,28,32,.72)));-webkit-backdrop-filter:var(--dsh-any-blur-card-panels,blur(12px) saturate(1.15));backdrop-filter:var(--dsh-any-blur-card-panels,blur(12px) saturate(1.15));border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.3));border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.35);display:flex;flex-direction:column}',
488
+ '.bw-ctx-item{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12.5px;border-radius:6px;cursor:pointer;text-align:left;font-family:inherit}',
489
+ '.bw-ctx-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
490
+ '.bw-ctx-danger{color:var(--dsw-alias-state-error-primary,#f85149)}',
491
+ '.bw-ctx-sep{height:1px;background:var(--dsw-alias-border-l1,rgba(127,127,127,.2));margin:4px 6px}',
492
+ '.bw-settings{display:flex;flex-direction:column;gap:6px;max-width:640px}',
493
+ '.bw-plugin-card{list-style:none;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));border-radius:12px;background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.05));transition:border-color .16s,background .16s}',
494
+ '.bw-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
495
+ '.bw-plugin-card-open{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.1));border-color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
496
+ '.bw-plugin-head{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}',
497
+ '.bw-plugin-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px}',
498
+ '.bw-plugin-headtext{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}',
499
+ '.bw-plugin-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary,#e6e6e6)}',
500
+ '.bw-plugin-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
501
+ '.bw-plugin-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .16s}',
502
+ '.bw-plugin-chevron-open{transform:rotate(180deg)}',
503
+ '.bw-plugin-body{border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));margin:0 16px;padding:12px 0 16px}',
504
+ '.bw-setting-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
505
+ '.bw-setting-label{flex:1;min-width:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
506
+ '.bw-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:10px;background:var(--dsw-alias-border-l3,rgba(127,127,127,.3));cursor:pointer;transition:background 120ms ease}',
507
+ '.bw-switch:hover{background:var(--dsw-alias-label-dimmed,#7a7a7a)}',
508
+ '.bw-switch-on{background:var(--dsw-alias-brand-primary,#5b8def)}',
509
+ '.bw-switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:2px}',
510
+ '.bw-switch-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground,#fff);transition:transform 120ms ease}',
511
+ '.bw-switch-on .bw-switch-thumb{transform:translateX(16px)}',
512
+ '.bw-plugin-body .bw-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
513
+ '.bw-rail{display:flex;flex-direction:column;align-items:center;gap:6px;padding:6px 0}',
514
+ '.bw-rail-btn{width:36px;height:36px;border:none;background:transparent;border-radius:8px;display:grid;place-items:center;color:var(--dsw-alias-label-secondary,#b8b8b8);cursor:pointer;padding:0}',
515
+ '.bw-rail-btn:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)));color:var(--dsw-alias-label-primary,#e6e6e6)}',
516
+ '.bw-modal-body{display:flex;flex-direction:column;gap:10px;min-width:300px;max-width:380px;box-sizing:border-box}',
517
+ '.bw-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
518
+ '.bw-hint{font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a);line-height:1.5;white-space:normal;word-break:break-word}',
519
+ '.bw-path-echo{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a);word-break:break-all;max-width:380px}',
520
+ '.bw-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
521
+ '.bw-btn{height:28px;padding:0 14px;border-radius:6px;font-size:12.5px;cursor:pointer;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));background:transparent;color:var(--dsw-alias-label-primary,#e6e6e6);font-family:inherit}',
522
+ '.bw-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
523
+ '.bw-btn-primary{background:var(--dsw-alias-brand-primary,#5b8def);border-color:transparent;color:var(--dsw-alias-brand-text,#fff)}',
524
+ '.bw-btn-primary:hover{background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-brand-primary,#5b8def))}',
525
+ '.bw-btn:disabled{opacity:.5;cursor:default}',
526
+ '.bw-error-text{font-size:12.5px;color:var(--dsw-alias-state-error-primary,#f85149);word-break:break-all;max-width:380px}',
527
+ '.bw-dialog-input-row{display:flex;gap:6px;align-items:center}',
528
+ '.bw-glow-row{display:flex;align-items:center;gap:10px}',
529
+ '.bw-slider{flex:1;accent-color:var(--dsw-alias-brand-primary,#5b8def);height:22px}',
530
+ '.bw-glow-value{min-width:44px;text-align:right;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8);font-variant-numeric:tabular-nums}',
531
+ '.bw-preview{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px dashed var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:6px;min-height:28px}',
532
+ '.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
533
+ '.bw-preview-icon svg{width:18px;height:18px}',
534
+ '.bw-preview-label{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:18px}',
535
+ ].join('')
536
+
537
+ const StyleNode = () => E('style', null, CSS_TEXT)
538
+
539
+ /* ========================== view store =========================== */
540
+
541
+ const createViewStore = () => storeKit.defineStore({
542
+ init: () => ({ folders: [], expanded: {}, sessionsExpanded: {}, sessionGroups: {}, prefs: { compactChains: true }, styling: {} }),
543
+ // NOTE: hydration REPLACES the state with the persisted whole value —
544
+ // init defaults never merge. Every action must tolerate a missing key
545
+ // (states persisted by older plugin versions lack sessionGroups), and
546
+ // every selector read takes a fallback.
547
+ persist: 'dsh.betterWorkspace.view.v1',
548
+ actions: {
549
+ setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
550
+ setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
551
+ setSessionGroupExpanded: (d, key, value) => { if (!d.sessionGroups) d.sessionGroups = {}; d.sessionGroups[key] = value },
552
+ setPref: (d, key, value) => { if (!d.prefs) d.prefs = {}; d.prefs[key] = value },
553
+ setStyling: (d, key, value) => { if (!d.styling) d.styling = {}; if (value === null) delete d.styling[key]; else d.styling[key] = value },
554
+ addFolder: (d, path) => {
555
+ if (!Array.isArray(d.folders)) d.folders = []
556
+ const p = normPath(path)
557
+ if (p !== '' && !d.folders.includes(p)) d.folders.push(p)
558
+ },
559
+ removeFolder: (d, path) => {
560
+ if (!Array.isArray(d.folders)) d.folders = []
561
+ d.folders = d.folders.filter(f => f !== path)
562
+ },
563
+ renameFolder: (d, oldPath, newPath) => {
564
+ if (!Array.isArray(d.folders)) d.folders = []
565
+ const oo = oldPath + '/'
566
+ const nn = newPath + '/'
567
+ const next = d.folders.map(f => (f === oldPath ? newPath : (f.startsWith(oo) ? nn + f.slice(oo.length) : f)))
568
+ d.folders = Array.from(new Set(next))
569
+ },
570
+ },
571
+ })
572
+
573
+ /* ============================ flow dialog ========================= */
574
+
575
+ const BTN = (props) => (
576
+ ui.Button
577
+ ? E(ui.Button, props)
578
+ : E('button', { type: 'button', className: cls('bw-btn', props.variant === 'primary' && 'bw-btn-primary'), onClick: props.onClick, disabled: props.disabled }, props.children)
579
+ )
580
+
581
+ function TextDialog({ title, hint, initial, confirmLabel, onConfirm, onClose, t }) {
582
+ const [value, setValue] = React.useState(initial)
583
+ const inputRef = React.useRef(null)
584
+ React.useEffect(() => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [])
585
+ const commit = () => onConfirm(value)
586
+ return E(ui.Modal, {
587
+ open: true,
588
+ onClose,
589
+ closeLabel: t('close'),
590
+ title,
591
+ footer: E('div', { className: 'bw-modal-actions' },
592
+ E(BTN, { variant: 'outline', onClick: onClose }, t('cancel')),
593
+ E(BTN, { variant: 'primary', onClick: commit }, confirmLabel || t('confirm')),
594
+ ),
595
+ },
596
+ E('div', { className: 'bw-modal-body' },
597
+ E('div', { className: 'bw-field' },
598
+ E('input', {
599
+ ref: inputRef,
600
+ className: 'bw-input',
601
+ value,
602
+ onChange: (e) => setValue(e.target.value),
603
+ onKeyDown: (e) => { if (e.key === 'Enter') commit() },
604
+ }),
605
+ hint ? E('div', { className: 'bw-hint' }, hint) : null,
606
+ ),
607
+ ),
608
+ StyleNode(),
609
+ )
610
+ }
611
+
612
+ function ConfirmDialog({ title, body, onConfirm, onClose, t }) {
613
+ return E(ui.Modal, {
614
+ open: true,
615
+ onClose,
616
+ closeLabel: t('close'),
617
+ title,
618
+ footer: E('div', { className: 'bw-modal-actions' },
619
+ E(BTN, { variant: 'outline', onClick: onClose }, t('cancel')),
620
+ E(BTN, { variant: 'primary', onClick: onConfirm }, t('confirm')),
621
+ ),
622
+ }, E('div', { className: 'bw-modal-body' }, E('div', { className: 'bw-hint' }, body)), StyleNode())
623
+ }
624
+
625
+ /**
626
+ * Render crash insurance: dsh boots all-or-nothing (one failed entry
627
+ * fails the whole web boot) and React unmounts the root on an uncaught
628
+ * render error — so our registrations render behind this boundary and a
629
+ * bug degrades to "region renders nothing", never a blank application.
630
+ */
631
+ class QuietBoundary extends React.Component {
632
+ constructor(props) {
633
+ super(props)
634
+ this.state = { failed: false }
635
+ }
636
+ static getDerivedStateFromError() {
637
+ return { failed: true }
638
+ }
639
+ componentDidCatch(error, info) {
640
+ console.error('[dsh-better-workspace] render error (region degraded to empty)', error, info)
641
+ }
642
+ render() {
643
+ return this.state.failed ? null : this.props.children
644
+ }
645
+ }
646
+
647
+ /**
648
+ * The add-workspace picking interaction: native directory pick, then a
649
+ * small parent-group popup, then create + rename with the chosen prefix.
650
+ * Works as a directoryFlow occupant (owner conversation props) and as the
651
+ * browser's directly composed flow (same props, owner state lives above).
652
+ */
653
+ function BetterFlow(props) {
654
+ const { open, busy, onPicked, onCancel, onError, createWorkspace, renameWorkspace, pickDirectory, useWorkspaces, useStore, t } = props
655
+ const actions = props.actions
656
+ const [phase, setPhase] = React.useState('idle') // idle | picking | picked | submitting
657
+ const [pickedPath, setPickedPath] = React.useState('')
658
+ const [parentInput, setParentInput] = React.useState('')
659
+ // All hooks run before any early return: the flow unmounts its dialog
660
+ // while closed, but its hook sequence must stay stable.
661
+ const snapshotItems = typeof useWorkspaces === 'function' ? useWorkspaces(s => s.items) : []
662
+ const storeFolders = typeof useStore === 'function' ? (useStore(s => s.folders) || []) : []
663
+
664
+ React.useEffect(() => {
665
+ if (!open) {
666
+ setPhase('idle')
667
+ setPickedPath('')
668
+ setParentInput('')
669
+ return
670
+ }
671
+ let alive = true
672
+ setPhase('picking')
673
+ Promise.resolve()
674
+ .then(() => pickDirectory())
675
+ .then((path) => {
676
+ if (!alive) return
677
+ if (!path) { onCancel(); return }
678
+ setPickedPath(String(path))
679
+ setParentInput('')
680
+ setPhase('picked')
681
+ })
682
+ .catch((reason) => {
683
+ if (!alive) return
684
+ setPhase('idle')
685
+ onError(messageOf(reason))
686
+ })
687
+ return () => { alive = false }
688
+ }, [open])
689
+
690
+ if (!open || (phase !== 'picked' && phase !== 'submitting')) return null
691
+
692
+ const folderOptions = (() => {
693
+ const set = new Set(storeFolders)
694
+ for (const w of snapshotItems || []) {
695
+ const segs = splitTitleSegs(w.title)
696
+ for (let i = 1; i < segs.length; i++) set.add(segs.slice(0, i).join('/'))
697
+ }
698
+ return Array.from(set).sort((a, b) => a.localeCompare(b, 'zh'))
699
+ })()
700
+
701
+ const confirm = () => {
702
+ if (phase === 'submitting') return
703
+ const prefix = normPath(parentInput)
704
+ const base = basename(pickedPath)
705
+ const fullTitle = prefix !== '' ? prefix + '/' + base : base
706
+ setPhase('submitting')
707
+ Promise.resolve()
708
+ .then(() => createWorkspace({ path: pickedPath }))
709
+ .then(async (workspace) => {
710
+ try {
711
+ await renameWorkspace(workspace.workspaceId, fullTitle)
712
+ } catch (renameError) {
713
+ onError(messageOf(renameError))
714
+ onCancel()
715
+ return
716
+ }
717
+ if (prefix !== '' && actions && typeof actions.addFolder === 'function') actions.addFolder(prefix)
718
+ onCancel()
719
+ })
720
+ .catch((reason) => {
721
+ onError(messageOf(reason))
722
+ onCancel()
723
+ })
724
+ }
725
+
726
+ const submitting = phase === 'submitting' || busy === true
727
+ const datalistId = 'bw-folder-options'
728
+ const body = E('div', { className: 'bw-modal-body' },
729
+ E('div', { className: 'bw-field' },
730
+ t('flow.picked'),
731
+ E('div', { className: 'bw-path-echo' }, pickedPath),
732
+ ),
733
+ E('div', { className: 'bw-field' },
734
+ t('flow.parent'),
735
+ E('div', { className: 'bw-dialog-input-row' },
736
+ E('input', {
737
+ className: 'bw-input',
738
+ list: datalistId,
739
+ value: parentInput,
740
+ autoFocus: true,
741
+ placeholder: 'web/frontend',
742
+ disabled: submitting,
743
+ onChange: (e) => setParentInput(e.target.value),
744
+ onKeyDown: (e) => { if (e.key === 'Enter') confirm() },
745
+ }),
746
+ E('datalist', { id: datalistId },
747
+ folderOptions.map((option) => E('option', { key: option, value: option })),
748
+ ),
749
+ ),
750
+ E('div', { className: 'bw-hint' }, t('flow.parentHint')),
751
+ ),
752
+ )
753
+ const footer = E('div', { className: 'bw-modal-actions' },
754
+ E(BTN, { variant: 'outline', onClick: onCancel, disabled: submitting }, t('cancel')),
755
+ E(BTN, { variant: 'primary', onClick: confirm, disabled: submitting }, submitting ? t('flow.creating') : t('create')),
756
+ )
757
+ return E(ui.Modal, { open: true, onClose: () => { if (!submitting) onCancel() }, closeLabel: t('close'), title: t('flow.title'), footer }, body, StyleNode())
758
+ }
759
+
760
+ /* ============================== rows ============================== */
761
+
762
+ /** Folder glyph variants from the primitives family (solid / outline / hidden). */
763
+ /** Custom icon value → glyph: legacy slots (solid/outline/none) or any primitives icon name. */
764
+ const iconOf = (mode, expanded) => {
765
+ if (!mode || mode === 'none') return null
766
+ if (mode === 'solid') return icon(expanded ? 'IconFolderOpen16' : 'IconFolderClose16')
767
+ if (mode === 'outline') return icon('IconFolderOpenOutline16')
768
+ return icon(mode)
769
+ }
770
+
771
+ const colorToRgb = (hex) => {
772
+ if (!hex) return null
773
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(hex)
774
+ if (!m) return null
775
+ const v = parseInt(m[1], 16)
776
+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
777
+ }
778
+ const rgbToHex = (r, g, b) => '#' + [r, g, b]
779
+ .map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
780
+ .join('')
781
+
782
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, t }) {
783
+ const total = countWorkspaces(node)
784
+ return E('div', {
785
+ className: cls('bw-row', dropInto && 'bw-drop-into'),
786
+ style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
787
+ onClick: onToggle,
788
+ onContextMenu: onContextMenu,
789
+ role: 'treeitem',
790
+ 'aria-expanded': expanded,
791
+ ...(dragEvents || {}),
792
+ },
793
+ E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
794
+ E('span', { className: 'bw-row-icon' }, iconOf(iconMode, expanded)),
795
+ E('span', { className: 'bw-row-label' }, node.name),
796
+ total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
797
+ )
798
+ }
799
+
800
+ function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, t }) {
801
+ return E('div', {
802
+ className: cls('bw-row', currentInside && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
803
+ style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}) },
804
+ onClick: onToggle,
805
+ onContextMenu: onContextMenu,
806
+ role: 'treeitem',
807
+ 'aria-expanded': sessionsOpen,
808
+ ...(dragEvents || {}),
809
+ },
810
+ E('span', { className: cls('bw-chevron', sessionsOpen && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
811
+ E('span', { className: 'bw-row-icon' }, iconOf(iconMode, sessionsOpen)),
812
+ E('span', { className: 'bw-row-label', title: workspace.title || workspace.leaf }, workspace.leaf),
813
+ count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
814
+ E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
815
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
816
+ ),
817
+ )
818
+ }
819
+
820
+ function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, t }) {
821
+ // Official status priority: pending interaction > running > running
822
+ // subagents > completed reminder; idle rows show no dot at all.
823
+ let status = null
824
+ if (node.pending === 'approval' || node.pending === 'plan-review' || node.pending === 'question') {
825
+ status = { state: 'warning', title: t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending)) }
826
+ } else if (node.running || node.subagents > 0) {
827
+ status = { state: 'ongoing', title: node.running ? t('status.running') : t('status.subagents', { n: node.subagents }) }
828
+ } else if (node.completed) {
829
+ status = { state: 'done', title: t('status.completed') }
830
+ }
831
+ return E('div', {
832
+ className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
833
+ style: { paddingLeft: 8 + depth * 12, ...(custStyle || {}) },
834
+ onClick: () => onOpen(node.id),
835
+ onContextMenu: onContextMenu,
836
+ role: 'treeitem',
837
+ ...(dragEvents || {}),
838
+ },
839
+ E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
840
+ status && typeof ui.StateDot === 'function'
841
+ ? E(ui.StateDot, { state: status.state, size: 10 })
842
+ : E('span', { className: 'bw-dot' }),
843
+ ),
844
+ E('span', { className: 'bw-row-label' }, node.leaf || node.title),
845
+ E('span', { className: 'bw-row-time' }, timeLabel(node.updatedAt, now, t)),
846
+ )
847
+ }
848
+
849
+ /**
850
+ * Session sub-group inside a workspace (same "/" convention on session
851
+ * titles). Deliberately NOT styled like a workspace folder — no folder
852
+ * icon, tertiary color — so a session level never reads as a workspace.
853
+ */
854
+ function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, t }) {
855
+ return E('div', {
856
+ className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
857
+ style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
858
+ onClick: onToggle,
859
+ onContextMenu: onContextMenu,
860
+ role: 'treeitem',
861
+ 'aria-expanded': expanded,
862
+ ...(dragEvents || {}),
863
+ },
864
+ E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
865
+ E('span', { className: 'bw-row-label' }, name),
866
+ count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
867
+ )
868
+ }
869
+
870
+ /* --------------------- customization dialog ----------------------- */
871
+
872
+ const SWATCHES = ['', '#5b8def', '#3fb950', '#d29922', '#f85149', '#a371f7', '#39c5cf', '#ec6cb9', '#ff9f45', '#6e7681']
873
+ const GLOW_MAX = 14
874
+ const ICON_CHOICES = [
875
+ 'solid', 'outline', 'none',
876
+ 'IconProjectAddOutline16', 'IconBranchOutline16', 'IconArchiveOutline20', 'IconCodeOutline16',
877
+ 'IconDataOutline16', 'IconGoalOutline16', 'IconGlobeOutline14', 'IconInspectOutline12',
878
+ 'IconCopyOutline16', 'IconLinkOutline16', 'IconListPenOutline16', 'IconChecklistOutline14',
879
+ 'IconBrowseOutline16', 'IconDownloadOutline16', 'IconContextInjectionOutline16',
880
+ 'IconCordisPluginOutline14', 'IconApiOutline14', 'IconAgentPresetOutline16', 'IconEnhanceOutline16',
881
+ 'IconSkillOutline16', 'IconSparkle16', 'IconNewChatOutline16',
882
+ 'IconCheckOutline14', 'IconCheckOutline16', 'IconChevronDownOutline14', 'IconChevronLeftOutline14',
883
+ 'IconChevronRightOutline14', 'IconChevronUpOutline14', 'IconCloseOutline16', 'IconDarkOutline16',
884
+ 'IconEditOutline16', 'IconEllipsisOutline16', 'IconFolderClose16', 'IconFolderOpen16',
885
+ 'IconFolderOpenOutline16', 'IconFullscreenOutline16', 'IconLightOutline16', 'IconLinkOutline14',
886
+ 'IconLoadingOutline16', 'IconPanelLeftOutline16', 'IconPaperclipOutline16', 'IconPersonalizationOutline16',
887
+ 'IconPlayOutline16', 'IconPauseOutline16', 'IconPlusOutline16', 'IconQuestionOutline14',
888
+ 'IconQueueOutline14', 'IconRefreshOutline14', 'IconRefreshOutline16', 'IconRightUpOutline14',
889
+ 'IconSearchOutline16', 'IconSendOutline14', 'IconSendOutline16', 'IconSettingsOutline14',
890
+ 'IconSettingsOutline16', 'IconShareOutline16', 'IconStopFill16', 'IconThinkOutline14',
891
+ 'IconThinkOutline16', 'IconTrashOutline16', 'IconUserOutline16', 'IconWarningOutline16',
892
+ 'IconLikeOutline16', 'IconDislikeOutline16', 'IconFollowsystemOutline16',
893
+ ]
894
+
895
+ /**
896
+ * Per-row appearance: color swatches (+ native picker), glow intensity,
897
+ * folder-glyph mode. Committing the defaults clears the entry; Reset
898
+ * removes it entirely.
899
+ */
900
+ function CustomizeDialog({ open, initial, kind, onChange, onReset, onClose, t }) {
901
+ // Icons render only for workspace / workspace-folder rows. Session rows
902
+ // already carry the official status dot (pending/running/done) in the
903
+ // leading slot, and session sub-group rows have no icon either — so the
904
+ // icon grid is offered only where it actually displays.
905
+ const allowIcon = kind === 'folder' || kind === 'workspace'
906
+ const [color, setColor] = React.useState('')
907
+ const [glow, setGlow] = React.useState(0)
908
+ const [iconMode, setIconMode] = React.useState('solid')
909
+ const [weight, setWeight] = React.useState(0)
910
+ const [shadow, setShadow] = React.useState(false)
911
+ React.useEffect(() => {
912
+ if (!open) return
913
+ setColor(initial && initial.color ? initial.color : '')
914
+ setGlow(initial && initial.glow ? Number(initial.glow) || 0 : 0)
915
+ setIconMode(initial && initial.icon ? initial.icon : 'solid')
916
+ setWeight(initial && initial.weight ? Number(initial.weight) || 0 : 0)
917
+ setShadow(initial && initial.shadow === true)
918
+ }, [open, initial])
919
+ if (!open) return null
920
+ const commit = () => {
921
+ const empty = allowIcon
922
+ ? (color === '' && glow === 0 && iconMode === 'solid' && weight === 0 && !shadow)
923
+ : (color === '' && glow === 0 && weight === 0 && !shadow)
924
+ const style = empty
925
+ ? null
926
+ : { color, glow, weight: weight > 0 ? weight : undefined, shadow: shadow || undefined, ...(allowIcon ? { icon: iconMode } : {}) }
927
+ onChange(style)
928
+ onClose()
929
+ }
930
+ const channels = colorToRgb(color)
931
+ const setChannel = (index, raw) => {
932
+ const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
933
+ const base = channels || [0, 0, 0]
934
+ const next = base.slice()
935
+ next[index] = n
936
+ setColor(rgbToHex(next[0], next[1], next[2]))
937
+ }
938
+ const previewShadows = []
939
+ if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
940
+ if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
941
+ return E(ui.Modal, {
942
+ open: true,
943
+ onClose,
944
+ closeLabel: t('close'),
945
+ title: t('custom.title'),
946
+ footer: E('div', { className: 'bw-modal-actions' },
947
+ E(BTN, { variant: 'outline', onClick: () => { onReset(); onClose() } }, t('custom.reset')),
948
+ E(BTN, { variant: 'primary', onClick: commit }, t('custom.done')),
949
+ ),
950
+ },
951
+ E('div', { className: 'bw-modal-body' },
952
+ E('div', { className: 'bw-field' },
953
+ t('custom.color'),
954
+ E('div', { className: 'bw-dialog-input-row' },
955
+ SWATCHES.map((swatch) => E('button', {
956
+ key: swatch || 'none',
957
+ type: 'button',
958
+ className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
959
+ style: swatch === '' ? undefined : { background: swatch },
960
+ 'aria-label': swatch === '' ? t('custom.reset') : swatch,
961
+ onClick: () => setColor(swatch),
962
+ })),
963
+ E('input', {
964
+ type: 'color',
965
+ className: 'bw-color-input',
966
+ value: color || '#5b8def',
967
+ onChange: (e) => setColor(e.target.value),
968
+ }),
969
+ ),
970
+ E('div', { className: 'bw-rgb-row' },
971
+ ['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
972
+ label,
973
+ E('input', {
974
+ type: 'number',
975
+ className: 'bw-rgb-input',
976
+ min: 0,
977
+ max: 255,
978
+ value: channels ? channels[index] : '',
979
+ placeholder: '—',
980
+ onChange: (e) => setChannel(index, e.target.value),
981
+ }),
982
+ )),
983
+ ),
984
+ ),
985
+ E('div', { className: 'bw-field' },
986
+ t('custom.glow'),
987
+ E('div', { className: 'bw-glow-row' },
988
+ E('input', {
989
+ type: 'range',
990
+ className: 'bw-slider',
991
+ min: 0,
992
+ max: GLOW_MAX,
993
+ step: 1,
994
+ value: glow,
995
+ 'aria-label': t('custom.glow'),
996
+ onChange: (e) => setGlow(Number(e.target.value)),
997
+ }),
998
+ E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
999
+ ),
1000
+ ),
1001
+ E('div', { className: 'bw-field' },
1002
+ t('custom.weight'),
1003
+ E('div', { className: 'bw-seg' },
1004
+ [400, 500, 600, 700].map((wt) => E('button', {
1005
+ key: String(wt),
1006
+ type: 'button',
1007
+ className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
1008
+ onClick: () => setWeight(wt),
1009
+ }, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
1010
+ ),
1011
+ ),
1012
+ E('div', { className: 'bw-field' },
1013
+ t('custom.shadow'),
1014
+ E('div', { className: 'bw-seg' },
1015
+ E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => setShadow(false) }, t('custom.none')),
1016
+ E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => setShadow(true) }, t('settings.on')),
1017
+ ),
1018
+ ),
1019
+ allowIcon ? E('div', { className: 'bw-field' },
1020
+ t('custom.icon'),
1021
+ E('div', { className: 'bw-icon-grid' },
1022
+ ICON_CHOICES.map((mode) => E('button', {
1023
+ key: mode,
1024
+ type: 'button',
1025
+ className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
1026
+ title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1027
+ 'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1028
+ onClick: () => setIconMode(mode),
1029
+ }, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
1030
+ ),
1031
+ ) : null,
1032
+ E('div', { className: 'bw-field' },
1033
+ t('custom.preview'),
1034
+ E('div', {
1035
+ className: 'bw-preview',
1036
+ style: {
1037
+ color: color || undefined,
1038
+ fontWeight: weight > 0 ? weight : undefined,
1039
+ textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
1040
+ },
1041
+ },
1042
+ allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
1043
+ E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
1044
+ ),
1045
+ ),
1046
+ ),
1047
+ StyleNode(),
1048
+ )
1049
+ }
1050
+
1051
+ /* ------------------------- settings page ------------------------- */
1052
+
1053
+ function BetterWorkspaceSettings({ useStore, actions, t }) {
1054
+ const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1055
+ const compactChains = prefs.compactChains !== false
1056
+ return E('div', { className: 'bw-settings' },
1057
+ StyleNode(),
1058
+ E('div', { className: 'bw-setting-row' },
1059
+ E('div', { className: 'bw-setting-label' }, t('settings.compactChains')),
1060
+ E('button', {
1061
+ type: 'button',
1062
+ role: 'switch',
1063
+ 'aria-checked': compactChains,
1064
+ 'aria-label': t('settings.compactChains'),
1065
+ className: cls('bw-switch', compactChains && 'bw-switch-on'),
1066
+ onClick: () => { actions.setPref('compactChains', !compactChains) },
1067
+ }, E('span', { className: 'bw-switch-thumb' })),
1068
+ ),
1069
+ E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
1070
+ )
1071
+ }
1072
+
1073
+ /* --------- settings → plug-ins card (accordion like official cards) --------- */
1074
+
1075
+ function BetterWorkspacePluginCard({ useStore, actions, t }) {
1076
+ const [open, setOpen] = React.useState(false)
1077
+ const Chevron = ui.IconChevronDownOutline14
1078
+ return E('li', { className: cls('bw-plugin-card', open && 'bw-plugin-card-open') },
1079
+ E('button', {
1080
+ type: 'button',
1081
+ className: 'bw-plugin-head',
1082
+ 'aria-expanded': open,
1083
+ 'aria-label': (open ? t('settings.collapse') : t('settings.expand')) + ': ' + t('settings.title'),
1084
+ onClick: () => setOpen(!open),
1085
+ },
1086
+ E('span', { className: 'bw-plugin-headtext' },
1087
+ E('span', { className: 'bw-plugin-name' }, t('settings.title')),
1088
+ E('span', { className: 'bw-plugin-desc' }, t('settings.desc')),
1089
+ ),
1090
+ Chevron ? E(Chevron, { className: cls('bw-plugin-chevron', open && 'bw-plugin-chevron-open') }) : null,
1091
+ ),
1092
+ open ? E('div', { className: 'bw-plugin-body' },
1093
+ E(BetterWorkspaceSettings, { useStore, actions, t }),
1094
+ ) : null,
1095
+ )
1096
+ }
1097
+
1098
+ /* ============================= browser ============================ */
1099
+
1100
+ function BetterBrowser(props) {
1101
+ const {
1102
+ wide, expandSidebar,
1103
+ useSessions, useSessionPendingInteraction, useWorkspaces,
1104
+ useStore, actions,
1105
+ startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace,
1106
+ archiveSession, createWorkspace, pickDirectory, insertWorkspaceBefore, insertSessionBefore,
1107
+ t,
1108
+ } = props
1109
+
1110
+ if (typeof useWorkspaces !== 'function' || typeof useSessions !== 'function') {
1111
+ console.error('[dsh-better-workspace] standard snapshot hooks missing; browser renders nothing')
1112
+ return null
1113
+ }
1114
+
1115
+ const items = useWorkspaces(s => s.items)
1116
+ const phase = useWorkspaces(s => s.phase)
1117
+ const archivedSessionIds = useWorkspaces(s => s.archivedSessionIds) || []
1118
+ const list = useSessions(s => s)
1119
+ const pending = useSessionPendingInteraction ? useSessionPendingInteraction(s => s) : null
1120
+ const storeFolders = useStore ? (useStore(s => s.folders) || []) : []
1121
+ const expandedMap = useStore ? (useStore(s => s.expanded) || {}) : {}
1122
+ const sessionsExpandedMap = useStore ? (useStore(s => s.sessionsExpanded) || {}) : {}
1123
+ const sessionGroupsMap = useStore ? (useStore(s => s.sessionGroups) || {}) : {}
1124
+ const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1125
+ const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1126
+ const compactChains = prefsMap.compactChains !== false
1127
+ const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1128
+ const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1129
+
1130
+ const [query, setQuery] = React.useState('')
1131
+ const [searchOpen, setSearchOpen] = React.useState(false)
1132
+ const [flowOpen, setFlowOpen] = React.useState(false)
1133
+ const [dialog, setDialog] = React.useState(null) // { kind, ... }
1134
+ const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
1135
+ const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
1136
+ const [errorText, setErrorText] = React.useState(null)
1137
+ const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
1138
+ const normalizedQuery = query.trim().toLowerCase()
1139
+ const now = Date.now()
1140
+
1141
+ const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
1142
+
1143
+ const styleEntry = (key) => stylingMap[key] || null
1144
+ const rowStyleOf = (key) => {
1145
+ const entry = styleEntry(key)
1146
+ if (!entry || !entry.color) return null
1147
+ const glow = Number(entry.glow) || 0
1148
+ const weight = Number(entry.weight) || 0
1149
+ const shadow = entry.shadow === true
1150
+ const shadows = []
1151
+ if (glow > 0) shadows.push('0 0 ' + glow + 'px ' + entry.color)
1152
+ if (shadow) shadows.push('1px 1px 2px rgba(0,0,0,.85)')
1153
+ return {
1154
+ color: entry.color,
1155
+ fontWeight: weight > 0 ? weight : undefined,
1156
+ textShadow: shadows.length > 0 ? shadows.join(',') : undefined,
1157
+ }
1158
+ }
1159
+ const keyOf = (kind, payload) => {
1160
+ if (kind === 'folder') return 'folder:' + payload.path
1161
+ if (kind === 'workspace') return 'workspace:' + payload.workspaceId
1162
+ if (kind === 'session') return 'session:' + payload.id
1163
+ if (kind === 'sgroup') return 'sgroup:' + payload.workspaceId + '|' + payload.path
1164
+ return String(kind)
1165
+ }
1166
+
1167
+ const accounted = new Set()
1168
+ for (const workspace of items || []) for (const id of workspace.sessionIds || []) accounted.add(id)
1169
+ const ungrouped = []
1170
+ if (list && Array.isArray(list.ids)) {
1171
+ for (const id of list.ids) {
1172
+ const summary = list.byId[id]
1173
+ if (accounted.has(id) || !sessionVisible(summary, list.current, archivedSet)) continue
1174
+ ungrouped.push({
1175
+ id,
1176
+ title: sessionTitleOf(summary, t),
1177
+ leaf: sessionTitleOf(summary, t),
1178
+ blank: !!summary.blank,
1179
+ running: !!summary.running,
1180
+ completed: summary.completed === true,
1181
+ subagents: subCounts.get(id) || 0,
1182
+ updatedAt: summary.updatedAt || 0,
1183
+ pending: pendingKindOf(pending, id),
1184
+ })
1185
+ }
1186
+ ungrouped.sort((a, b) => b.updatedAt - a.updatedAt)
1187
+ }
1188
+
1189
+ const tree = React.useMemo(() => {
1190
+ const built = buildTree(items, storeFolders)
1191
+ if (!compactChains) return built
1192
+ return { ...built, folders: built.folders.map(compressTree), workspaces: built.workspaces }
1193
+ }, [items, storeFolders, compactChains])
1194
+
1195
+ const sessionsOf = (workspace) => {
1196
+ const rows = []
1197
+ for (const id of workspace.sessionIds || []) {
1198
+ const summary = list && list.byId ? list.byId[id] : undefined
1199
+ if (!sessionVisible(summary, list ? list.current : undefined, archivedSet)) continue
1200
+ rows.push({
1201
+ id,
1202
+ title: sessionTitleOf(summary, t),
1203
+ leaf: sessionTitleOf(summary, t),
1204
+ blank: !!summary.blank,
1205
+ running: !!summary.running,
1206
+ completed: summary.completed === true,
1207
+ subagents: subCounts.get(id) || 0,
1208
+ updatedAt: summary.updatedAt || 0,
1209
+ pending: pendingKindOf(pending, id),
1210
+ })
1211
+ }
1212
+ return rows
1213
+ }
1214
+
1215
+ const searchAgent = (agent) => {
1216
+ // Returns a pruned copy of the tree node, or null when nothing matches.
1217
+ if (!normalizedQuery) return agent
1218
+ if (agent.kind === 'ws') {
1219
+ const ws = agent.workspace
1220
+ const sessions = sessionsOf(ws)
1221
+ const wsHit = ws.leaf.toLowerCase().includes(normalizedQuery) || ws.title.toLowerCase().includes(normalizedQuery)
1222
+ if (wsHit || sessions.some(s => s.title.toLowerCase().includes(normalizedQuery))) {
1223
+ return { ...agent, node: agent, folders: [], workspaces: [ws] }
1224
+ }
1225
+ return null
1226
+ }
1227
+ const folders = []
1228
+ for (const folder of agent.folders) {
1229
+ const hit = searchAgent(folder)
1230
+ if (hit) folders.push(hit)
1231
+ }
1232
+ const workspaces = []
1233
+ for (const workspace of agent.workspaces) {
1234
+ const sessions = sessionsOf(workspace)
1235
+ const wsHit = workspace.leaf.toLowerCase().includes(normalizedQuery) || workspace.title.toLowerCase().includes(normalizedQuery)
1236
+ const matchedSessions = wsHit ? sessions : sessions.filter(s => s.title.toLowerCase().includes(normalizedQuery))
1237
+ if (wsHit || matchedSessions.length > 0) workspaces.push({ workspace, matchedSessions })
1238
+ }
1239
+ if (folders.length === 0 && workspaces.length === 0) return null
1240
+ return { node: agent, folders, workspaces }
1241
+ }
1242
+ const searched = normalizedQuery ? searchAgent(tree) : null
1243
+ const searching = normalizedQuery !== ''
1244
+
1245
+ const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
1246
+ const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
1247
+ const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
1248
+
1249
+ const openCtx = (kind, payload, e) => {
1250
+ if (e) e.preventDefault()
1251
+ setCtx({ kind, payload, x: e.clientX, y: e.clientY })
1252
+ }
1253
+
1254
+ /* ------------------------- drag & drop -------------------------- */
1255
+
1256
+ React.useEffect(() => {
1257
+ if (drag === null) return
1258
+ const accept = (event) => {
1259
+ event.preventDefault()
1260
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
1261
+ }
1262
+ const acceptDrop = (event) => { event.preventDefault() }
1263
+ document.addEventListener('dragover', accept)
1264
+ document.addEventListener('drop', acceptDrop)
1265
+ return () => {
1266
+ document.removeEventListener('dragover', accept)
1267
+ document.removeEventListener('drop', acceptDrop)
1268
+ }
1269
+ }, [drag === null])
1270
+
1271
+ const rowHalf = (event) => {
1272
+ const rect = event.currentTarget.getBoundingClientRect()
1273
+ return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
1274
+ }
1275
+ const dragMatches = (kind) => drag !== null && drag.kind === kind
1276
+ const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
1277
+
1278
+ /* --------------------- workspace drag & drop -------------------- */
1279
+
1280
+ const wsDropHalf = (workspaceId) => {
1281
+ if (!dragMatches('workspace')) return null
1282
+ const over = drag.over
1283
+ return over && over.kind === 'workspace' && over.target === workspaceId ? over.half : null
1284
+ }
1285
+ const wsDropInto = (path) => dragMatches('workspace') && drag.over && drag.over.kind === 'folder' && drag.over.target === path
1286
+ const workspaceDragEvents = (workspace) => ({
1287
+ draggable: !searching && canDragWorkspace,
1288
+ onDragStart: (event) => {
1289
+ if (searching || !canDragWorkspace) return
1290
+ event.stopPropagation()
1291
+ try {
1292
+ event.dataTransfer.effectAllowed = 'move'
1293
+ event.dataTransfer.setData('text/plain', workspace.workspaceId)
1294
+ } catch { /* drag payload is best-effort */ }
1295
+ setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: workspace.leaf, folderPath: workspace.folderPath || '' }, over: null })
1296
+ },
1297
+ onDragEnd: () => setDrag(null),
1298
+ onDragOver: (event) => {
1299
+ if (!dragMatches('workspace')) return
1300
+ event.preventDefault()
1301
+ event.stopPropagation()
1302
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
1303
+ const half = rowHalf(event)
1304
+ setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
1305
+ ? current
1306
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
1307
+ },
1308
+ onDrop: (event) => {
1309
+ if (!dragMatches('workspace')) return
1310
+ event.preventDefault()
1311
+ event.stopPropagation()
1312
+ const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
1313
+ commitWorkspaceDrop(workspace, half)
1314
+ },
1315
+ })
1316
+ const folderDropEvents = (path) => ({
1317
+ onDragOver: (event) => {
1318
+ if (!dragMatches('workspace')) return
1319
+ event.preventDefault()
1320
+ event.stopPropagation()
1321
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
1322
+ setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === path)
1323
+ ? current
1324
+ : (current ? { ...current, over: { kind: 'folder', target: path } } : current))
1325
+ },
1326
+ onDrop: (event) => {
1327
+ if (!dragMatches('workspace')) return
1328
+ event.preventDefault()
1329
+ event.stopPropagation()
1330
+ commitWorkspaceMoveInto(path)
1331
+ },
1332
+ })
1333
+ const nextWorkspaceAfter = (folderPath, workspaceId) => {
1334
+ const node = findTreeNode(tree, folderPath)
1335
+ if (!node) return undefined
1336
+ const index = node.workspaces.findIndex(w => w.workspaceId === workspaceId)
1337
+ return index === -1 ? undefined : (node.workspaces[index + 1] ? node.workspaces[index + 1].workspaceId : undefined)
1338
+ }
1339
+ const commitWorkspaceDrop = (targetWorkspace, half) => {
1340
+ const source = drag.source
1341
+ setDrag(null)
1342
+ if (source.workspaceId === targetWorkspace.workspaceId) return
1343
+ const sameFolder = (targetWorkspace.folderPath || '') === source.folderPath
1344
+ const anchor = half === 'after'
1345
+ ? nextWorkspaceAfter(targetWorkspace.folderPath || '', targetWorkspace.workspaceId)
1346
+ : targetWorkspace.workspaceId
1347
+ const chain = sameFolder
1348
+ ? Promise.resolve()
1349
+ : Promise.resolve().then(() => {
1350
+ const newTitle = (targetWorkspace.folderPath || '') !== '' ? (targetWorkspace.folderPath || '') + '/' + source.leaf : source.leaf
1351
+ return renameWorkspace(source.workspaceId, newTitle)
1352
+ })
1353
+ chain
1354
+ .then(() => (anchor !== undefined ? insertWorkspaceBefore(source.workspaceId, anchor) : insertWorkspaceBefore(source.workspaceId)))
1355
+ .catch(fail)
1356
+ }
1357
+ const commitWorkspaceMoveInto = (folderPath) => {
1358
+ const source = drag.source
1359
+ setDrag(null)
1360
+ if (source.folderPath === folderPath) return
1361
+ const newTitle = folderPath !== '' ? folderPath + '/' + source.leaf : source.leaf
1362
+ Promise.resolve()
1363
+ .then(() => renameWorkspace(source.workspaceId, newTitle))
1364
+ .then(() => insertWorkspaceBefore(source.workspaceId))
1365
+ .catch(fail)
1366
+ }
1367
+
1368
+ /* ---------------------- session drag & drop --------------------- */
1369
+
1370
+ const sessDropHalf = (sessionId) => {
1371
+ if (!dragMatches('session')) return null
1372
+ const over = drag.over
1373
+ return over && over.kind === 'session' && over.target === sessionId ? over.half : null
1374
+ }
1375
+ const sgroupDropInto = (workspaceId, path) => dragMatches('session') && drag.over && drag.over.kind === 'sgroup' && drag.over.target === path
1376
+ const sessionDragEvents = (session, workspaceId) => ({
1377
+ draggable: !searching,
1378
+ onDragStart: (event) => {
1379
+ if (searching) return
1380
+ event.stopPropagation()
1381
+ try {
1382
+ event.dataTransfer.effectAllowed = 'move'
1383
+ event.dataTransfer.setData('text/plain', session.id)
1384
+ } catch { }
1385
+ setDrag({ kind: 'session', source: { sessionId: session.id, workspaceId, title: session.title, leaf: session.leaf || session.title }, over: null })
1386
+ },
1387
+ onDragEnd: () => setDrag(null),
1388
+ onDragOver: (event) => {
1389
+ if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
1390
+ event.preventDefault()
1391
+ event.stopPropagation()
1392
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
1393
+ const half = rowHalf(event)
1394
+ setDrag(current => (current && current.over && current.over.kind === 'session' && current.over.target === session.id && current.over.half === half)
1395
+ ? current
1396
+ : (current ? { ...current, over: { kind: 'session', target: session.id, half } } : current))
1397
+ },
1398
+ onDrop: (event) => {
1399
+ if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
1400
+ event.preventDefault()
1401
+ event.stopPropagation()
1402
+ const half = drag.over && drag.over.kind === 'session' && drag.over.target === session.id ? drag.over.half : rowHalf(event)
1403
+ commitSessionDrop(workspaceId, session.id, half)
1404
+ },
1405
+ })
1406
+ const sgroupDropEvents = (workspaceId, path) => ({
1407
+ onDragOver: (event) => {
1408
+ if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
1409
+ event.preventDefault()
1410
+ event.stopPropagation()
1411
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
1412
+ setDrag(current => (current && current.over && current.over.kind === 'sgroup' && current.over.target === path)
1413
+ ? current
1414
+ : (current ? { ...current, over: { kind: 'sgroup', target: path } } : current))
1415
+ },
1416
+ onDrop: (event) => {
1417
+ if (!dragMatches('session') || drag.source.workspaceId !== workspaceId) return
1418
+ event.preventDefault()
1419
+ event.stopPropagation()
1420
+ commitSessionMoveInto(workspaceId, path)
1421
+ },
1422
+ })
1423
+ const commitSessionDrop = (workspaceId, targetSessionId, half) => {
1424
+ const source = drag.source
1425
+ setDrag(null)
1426
+ if (source.sessionId === targetSessionId) return
1427
+ const workspace = (items || []).find(w => w.workspaceId === workspaceId)
1428
+ if (!workspace) return
1429
+ const flat = sessionsOf(workspace)
1430
+ const index = flat.findIndex(s => s.id === targetSessionId)
1431
+ const anchor = half === 'after'
1432
+ ? (index === -1 ? undefined : (flat[index + 1] ? flat[index + 1].id : undefined))
1433
+ : targetSessionId
1434
+ Promise.resolve()
1435
+ .then(() => (anchor !== undefined ? insertSessionBefore(workspaceId, source.sessionId, anchor) : insertSessionBefore(workspaceId, source.sessionId)))
1436
+ .catch(fail)
1437
+ }
1438
+ const commitSessionMoveInto = (workspaceId, groupPath) => {
1439
+ const source = drag.source
1440
+ setDrag(null)
1441
+ const newTitle = groupPath !== '' ? groupPath + '/' + source.leaf : source.leaf
1442
+ if (newTitle === source.title) return
1443
+ Promise.resolve()
1444
+ .then(() => renameSession(source.sessionId, newTitle))
1445
+ .catch(fail)
1446
+ }
1447
+
1448
+ /* --------------------------- actions --------------------------- */
1449
+
1450
+ const submitWorkspaceRename = (workspace, nextTitle) => {
1451
+ const title = String(nextTitle || '').trim()
1452
+ if (title === '' || title === workspace.title) { setDialog(null); return }
1453
+ Promise.resolve()
1454
+ .then(() => renameWorkspace(workspace.workspaceId, title))
1455
+ .then(() => setDialog(null))
1456
+ .catch(fail)
1457
+ }
1458
+
1459
+ const submitWorkspaceDelete = (workspace) => {
1460
+ Promise.resolve()
1461
+ .then(() => deleteWorkspace(workspace.workspaceId))
1462
+ .then(() => setDialog(null))
1463
+ .catch(fail)
1464
+ }
1465
+
1466
+ const submitSessionRename = (session, nextTitle) => {
1467
+ const title = String(nextTitle || '').trim()
1468
+ if (title === '' || title === session.title) { setDialog(null); return }
1469
+ Promise.resolve()
1470
+ .then(() => renameSession(session.id, title))
1471
+ .then(() => setDialog(null))
1472
+ .catch(fail)
1473
+ }
1474
+
1475
+ /** Rename one session sub-group: rewrite the title prefix of every member. */
1476
+ const submitSessionGroupRename = (target, rawName) => {
1477
+ const name = normPath(rawName)
1478
+ if (name === '') { setErrorText(t('folder.error.empty')); return }
1479
+ if (name === target.name) { setDialog(null); return }
1480
+ const workspace = (items || []).find(w => w.workspaceId === target.workspaceId)
1481
+ if (!workspace) { setDialog(null); return }
1482
+ const node = findSessionGroup(buildSessionTree(sessionsOf(workspace)), target.path)
1483
+ if (!node) { setDialog(null); return }
1484
+ const parentPath = target.path.includes('/') ? target.path.slice(0, target.path.lastIndexOf('/')) : ''
1485
+ const nextPath = parentPath !== '' ? parentPath + '/' + name : name
1486
+ const affected = collectSessionRows(node)
1487
+ Promise.resolve()
1488
+ .then(async () => {
1489
+ for (const row of affected) {
1490
+ if (row.blank) continue
1491
+ const nextTitle = nextPath + row.title.slice(target.path.length)
1492
+ await renameSession(row.id, nextTitle)
1493
+ }
1494
+ })
1495
+ .then(() => setDialog(null))
1496
+ .catch(fail)
1497
+ }
1498
+
1499
+ const findTreeNode = (node, path) => {
1500
+ if (node.path === path) return node
1501
+ for (const folder of node.folders) {
1502
+ const hit = findTreeNode(folder, path)
1503
+ if (hit) return hit
1504
+ }
1505
+ return null
1506
+ }
1507
+
1508
+ const submitFolderNew = (rawPath) => {
1509
+ const p = normPath(rawPath)
1510
+ if (p === '') { setErrorText(t('folder.error.empty')); return }
1511
+ if (storeFolders.includes(p)) { setErrorText(t('folder.error.exists')); return }
1512
+ actions.addFolder(p)
1513
+ setDialog(null)
1514
+ }
1515
+
1516
+ const submitFolderRename = (oldPath, rawPath) => {
1517
+ const newPath = normPath(rawPath)
1518
+ if (newPath === '') { setErrorText(t('folder.error.empty')); return }
1519
+ if (newPath === oldPath) { setDialog(null); return }
1520
+ const node = findTreeNode(tree, oldPath)
1521
+ const hasChildren = node ? countWorkspaces(node) > 0 : false
1522
+ const prefix = oldPath + '/'
1523
+ const affected = hasChildren
1524
+ ? (items || []).filter(w => String(w.title || '').startsWith(prefix))
1525
+ : []
1526
+ Promise.resolve()
1527
+ .then(async () => {
1528
+ for (const w of affected) {
1529
+ const nextTitle = newPath + String(w.title).slice(oldPath.length)
1530
+ await renameWorkspace(w.workspaceId, nextTitle)
1531
+ }
1532
+ })
1533
+ .then(() => { actions.renameFolder(oldPath, newPath); setDialog(null) })
1534
+ .catch(fail)
1535
+ }
1536
+
1537
+ const submitFolderDelete = (path) => {
1538
+ const node = findTreeNode(tree, path)
1539
+ if (node && countWorkspaces(node) > 0) { setErrorText(t('folder.error.notEmpty')); return }
1540
+ actions.removeFolder(path)
1541
+ setDialog(null)
1542
+ }
1543
+
1544
+ /* ---------------------------- rows ----------------------------- */
1545
+
1546
+ const renderSessionTree = (workspace, depth) => {
1547
+ // Folder semantics: a closed workspace shows no sessions at all (the
1548
+ // row keeps its count badge); an open one shows the full session tree.
1549
+ if (!searching && !sessionsOpenOf(workspace.workspaceId)) return []
1550
+ const rows = sessionsOf(workspace)
1551
+ if (rows.length === 0) return []
1552
+ return renderSessionNode(buildSessionTree(rows), workspace.workspaceId, depth)
1553
+ }
1554
+ const searchSessionNode = (node) => {
1555
+ const groups = []
1556
+ for (const group of node.groups) {
1557
+ const hit = searchSessionNode(group)
1558
+ if (hit) groups.push(hit)
1559
+ }
1560
+ const sessions = node.sessions.filter(s => ((s.leaf || s.title) + ' ' + s.title).toLowerCase().includes(normalizedQuery))
1561
+ if (groups.length === 0 && sessions.length === 0) return null
1562
+ return { path: node.path, name: node.name, groups, sessions }
1563
+ }
1564
+ const renderSessionNode = (node, workspaceId, depth) => {
1565
+ const view = searching ? searchSessionNode(node) : node
1566
+ if (!view) return []
1567
+ const out = []
1568
+ for (const group of view.groups) {
1569
+ const key = workspaceId + '|' + group.path
1570
+ const open = searching || sessionGroupOpen(key)
1571
+ out.push(E(SessionGroupRow, {
1572
+ key: 'sg-' + key,
1573
+ name: group.name,
1574
+ depth,
1575
+ expanded: open,
1576
+ count: countSessionTree(group),
1577
+ onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
1578
+ onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
1579
+ dropInto: sgroupDropInto(workspaceId, group.path),
1580
+ dragEvents: sgroupDropEvents(workspaceId, group.path),
1581
+ custStyle: rowStyleOf('sgroup:' + workspaceId + '|' + group.path),
1582
+ t,
1583
+ }))
1584
+ if (open) out.push(...renderSessionNode(group, workspaceId, depth + 1))
1585
+ }
1586
+ for (const s of view.sessions) out.push(renderSessionRow(s, depth, workspaceId))
1587
+ return out
1588
+ }
1589
+
1590
+ const renderSessionRow = (session, depth, workspaceId) => E(SessionRow, {
1591
+ key: session.id,
1592
+ node: session,
1593
+ depth,
1594
+ current: list && list.current === session.id,
1595
+ now,
1596
+ onOpen: (id) => open(id),
1597
+ onContextMenu: (e) => openCtx('session', session, e),
1598
+ dropHalf: workspaceId ? sessDropHalf(session.id) : null,
1599
+ dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
1600
+ custStyle: rowStyleOf('session:' + session.id),
1601
+ t,
1602
+ })
1603
+
1604
+ const renderWorkspaceEntry = (entry, depth) => {
1605
+ const { workspace } = entry
1606
+ const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
1607
+ const rows = [E(WorkspaceRow, {
1608
+ key: 'ws-' + workspace.workspaceId,
1609
+ workspace,
1610
+ depth,
1611
+ count,
1612
+ sessionsOpen: searching ? true : sessionsOpenOf(workspace.workspaceId),
1613
+ currentInside: !!(list && list.current && (workspace.sessionIds || []).includes(list.current)),
1614
+ onToggle: () => { if (!searching) actions.setSessionsExpanded(workspace.workspaceId, !sessionsOpenOf(workspace.workspaceId)) },
1615
+ onStart: () => startSession(workspace.workspaceId),
1616
+ onContextMenu: (e) => openCtx('workspace', workspace, e),
1617
+ dropHalf: wsDropHalf(workspace.workspaceId),
1618
+ dragEvents: workspaceDragEvents(workspace),
1619
+ custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
1620
+ iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
1621
+ t,
1622
+ })]
1623
+ rows.push(...renderSessionTree(workspace, depth + 1))
1624
+ return rows
1625
+ }
1626
+
1627
+ const renderPlainFolder = (node, depth) => {
1628
+ if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
1629
+ const expanded = searching || folderExpanded(node.path)
1630
+ const rows = [E(FolderRow, {
1631
+ key: 'f-' + node.path,
1632
+ node,
1633
+ depth,
1634
+ expanded,
1635
+ onToggle: () => { if (!searching) actions.setExpanded(node.path, !expanded) },
1636
+ onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name }, e),
1637
+ dropInto: wsDropInto(node.path),
1638
+ dragEvents: folderDropEvents(node.path),
1639
+ custStyle: rowStyleOf('folder:' + node.path),
1640
+ iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
1641
+ t,
1642
+ })]
1643
+ if (expanded) {
1644
+ for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
1645
+ for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1))
1646
+ }
1647
+ return rows
1648
+ }
1649
+ const renderSearchedFolder = (hit, depth) => {
1650
+ if (hit.kind === 'ws') return renderWorkspaceEntry({ workspace: hit.workspace }, depth)
1651
+ const node = hit.node
1652
+ const rows = [E(FolderRow, {
1653
+ key: 'f-' + node.path,
1654
+ node,
1655
+ depth,
1656
+ expanded: true,
1657
+ onToggle: () => {},
1658
+ onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name }, e),
1659
+ dropInto: false,
1660
+ dragEvents: undefined,
1661
+ custStyle: rowStyleOf('folder:' + node.path),
1662
+ iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
1663
+ t,
1664
+ })]
1665
+ for (const child of hit.folders) rows.push(...renderSearchedFolder(child, depth + 1))
1666
+ for (const entry of hit.workspaces) rows.push(...renderWorkspaceEntry(entry, depth + 1))
1667
+ return rows
1668
+ }
1669
+
1670
+ let bodyRows = []
1671
+ if (searching) {
1672
+ if (searched) {
1673
+ for (const child of searched.folders) bodyRows.push(...renderSearchedFolder(child, 0))
1674
+ for (const entry of searched.workspaces) bodyRows.push(...renderWorkspaceEntry(entry, 0))
1675
+ }
1676
+ } else {
1677
+ for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
1678
+ for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0))
1679
+ if (ungrouped.length > 0) {
1680
+ bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
1681
+ for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
1682
+ }
1683
+ }
1684
+ const isEmpty = bodyRows.length === 0
1685
+ if (isEmpty) {
1686
+ bodyRows = [E('div', { key: 'empty', className: 'bw-empty' }, searching ? t('empty.search') : (phase === 'pending' ? '…' : t('empty')))]
1687
+ }
1688
+
1689
+ /* ------------------------- context menu ------------------------ */
1690
+
1691
+ const ctxItems = () => {
1692
+ if (ctx === null) return []
1693
+ if (ctx.kind === 'folder') {
1694
+ const items = [{ id: 'rename-folder', label: t('menu.renameFolder') }]
1695
+ if (storeFolders.includes(ctx.payload.path)) items.push({ id: 'remove-folder', label: t('menu.removeFolder'), danger: true })
1696
+ items.push({ sep: true })
1697
+ items.push({ id: 'customize', label: t('custom.title') })
1698
+ return items
1699
+ }
1700
+ if (ctx.kind === 'workspace') return [
1701
+ { id: 'rename', label: t('menu.rename') },
1702
+ { id: 'delete', label: t('menu.delete'), danger: true },
1703
+ { sep: true },
1704
+ { id: 'customize', label: t('custom.title') },
1705
+ ]
1706
+ if (ctx.kind === 'session') return [
1707
+ { id: 'rename', label: t('menu.rename') },
1708
+ { id: 'fork', label: t('menu.fork') },
1709
+ { id: 'archive', label: t('menu.archive'), danger: true },
1710
+ { sep: true },
1711
+ { id: 'customize', label: t('custom.title') },
1712
+ ]
1713
+ return [
1714
+ { id: 'rename-sgroup', label: t('menu.renameSgroup') },
1715
+ { sep: true },
1716
+ { id: 'customize', label: t('custom.title') },
1717
+ ]
1718
+ }
1719
+ const handleCtxPick = (id) => {
1720
+ const current = ctx
1721
+ if (current === null) return
1722
+ if (id === 'customize') {
1723
+ const payload = current.payload
1724
+ const name = current.kind === 'workspace' ? (payload.title || payload.leaf)
1725
+ : (current.kind === 'session' ? payload.title : payload.name)
1726
+ setCustomize({ kind: current.kind, entryKey: keyOf(current.kind, payload), name })
1727
+ setCtx(null)
1728
+ return
1729
+ }
1730
+ const { kind, payload } = current
1731
+ setCtx(null)
1732
+ if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
1733
+ else if (kind === 'folder' && id === 'remove-folder') setDialog({ kind: 'folder-delete', path: payload.path })
1734
+ else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
1735
+ else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
1736
+ else if (kind === 'sgroup' && id === 'rename-sgroup') setDialog({ kind: 'sgroup-rename', target: payload })
1737
+ else if (kind === 'session' && id === 'rename') setDialog({ kind: 'sess-rename', session: payload })
1738
+ else if (kind === 'session' && id === 'fork') forkSession(payload.id)
1739
+ else if (kind === 'session' && id === 'archive') { Promise.resolve().then(() => archiveSession(payload.id)).catch(fail) }
1740
+ }
1741
+
1742
+ /* --------------------------- dialogs --------------------------- */
1743
+ // TextDialog / ConfirmDialog are module-level components: a per-render
1744
+ // inline definition would remount on every parent tick and drop input.
1745
+
1746
+ /* ---------------------------- render --------------------------- */
1747
+
1748
+ if (!wide) {
1749
+ return E('div', { className: 'bw-rail' },
1750
+ StyleNode(),
1751
+ E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.search'), onClick: () => { expandSidebar(); setSearchOpen(true) } }, icon('IconSearchOutline16', 18)),
1752
+ E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
1753
+ )
1754
+ }
1755
+
1756
+ const dialogElement = (() => {
1757
+ if (dialog === null) return null
1758
+ if (dialog.kind === 'ws-rename') return E(TextDialog, {
1759
+ key: 'ws-rename',
1760
+ title: t('ws.rename.title'),
1761
+ hint: t('ws.rename.hint'),
1762
+ initial: dialog.workspace.title || dialog.workspace.leaf,
1763
+ onConfirm: (v) => submitWorkspaceRename(dialog.workspace, v),
1764
+ onClose: () => setDialog(null),
1765
+ t,
1766
+ })
1767
+ if (dialog.kind === 'ws-delete') return E(ConfirmDialog, {
1768
+ key: 'ws-delete',
1769
+ title: t('ws.delete.title'),
1770
+ body: t('ws.delete.body', { name: dialog.workspace.title || dialog.workspace.leaf }),
1771
+ onConfirm: () => submitWorkspaceDelete(dialog.workspace),
1772
+ onClose: () => setDialog(null),
1773
+ t,
1774
+ })
1775
+ if (dialog.kind === 'sess-rename') return E(TextDialog, {
1776
+ key: 'sess-rename',
1777
+ title: t('menu.rename'),
1778
+ initial: dialog.session.title,
1779
+ onConfirm: (v) => submitSessionRename(dialog.session, v),
1780
+ onClose: () => setDialog(null),
1781
+ t,
1782
+ })
1783
+ if (dialog.kind === 'sgroup-rename') return E(TextDialog, {
1784
+ key: 'sgroup-rename',
1785
+ title: t('menu.renameSgroup'),
1786
+ hint: t('ws.rename.hint'),
1787
+ initial: dialog.target.name,
1788
+ onConfirm: (v) => submitSessionGroupRename(dialog.target, v),
1789
+ onClose: () => setDialog(null),
1790
+ t,
1791
+ })
1792
+ if (dialog.kind === 'folder-new') return E(TextDialog, {
1793
+ key: 'folder-new',
1794
+ title: t('folder.new.title'),
1795
+ hint: t('folder.new.hint'),
1796
+ initial: '',
1797
+ onConfirm: submitFolderNew,
1798
+ onClose: () => setDialog(null),
1799
+ t,
1800
+ })
1801
+ if (dialog.kind === 'folder-rename') return E(TextDialog, {
1802
+ key: 'folder-rename',
1803
+ title: t('folder.rename.title'),
1804
+ hint: t('folder.rename.hint'),
1805
+ initial: dialog.path,
1806
+ onConfirm: (v) => submitFolderRename(dialog.path, v),
1807
+ onClose: () => setDialog(null),
1808
+ t,
1809
+ })
1810
+ if (dialog.kind === 'folder-delete') {
1811
+ return E(ConfirmDialog, {
1812
+ key: 'folder-delete',
1813
+ title: t('menu.removeFolder'),
1814
+ body: t('folder.delete.body', { name: dialog.path }),
1815
+ onConfirm: () => submitFolderDelete(dialog.path),
1816
+ onClose: () => setDialog(null),
1817
+ t,
1818
+ })
1819
+ }
1820
+ return null
1821
+ })()
1822
+
1823
+ const flowOwner = {
1824
+ open: flowOpen,
1825
+ busy: false,
1826
+ onPicked: () => {},
1827
+ onCancel: () => setFlowOpen(false),
1828
+ onError: fail,
1829
+ }
1830
+
1831
+ return E('div', { className: 'bw-root' },
1832
+ StyleNode(),
1833
+ E('div', { className: 'bw-header' },
1834
+ E('div', { className: 'bw-header-title' }, t('title')),
1835
+ (searchOpen || query !== '') ? E('input', {
1836
+ className: 'bw-input',
1837
+ style: { width: 130, flex: 'none' },
1838
+ value: query,
1839
+ autoFocus: true,
1840
+ placeholder: t('search.placeholder'),
1841
+ onChange: (e) => setQuery(e.target.value),
1842
+ onKeyDown: (e) => { if (e.key === 'Escape') { setQuery(''); setSearchOpen(false) } },
1843
+ onBlur: () => { if (query === '') setSearchOpen(false) },
1844
+ }) : null,
1845
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
1846
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('newFolder'), onClick: () => setDialog({ kind: 'folder-new' }) }, E(FolderPlusIcon, { size: 16 })),
1847
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => setFlowOpen(true) }, icon('IconProjectAddOutline16')),
1848
+ ),
1849
+ E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
1850
+ E(BetterFlow, {
1851
+ open: flowOpen,
1852
+ busy: false,
1853
+ onPicked: flowOwner.onPicked,
1854
+ onCancel: flowOwner.onCancel,
1855
+ onError: flowOwner.onError,
1856
+ createWorkspace,
1857
+ renameWorkspace,
1858
+ pickDirectory,
1859
+ useWorkspaces,
1860
+ actions,
1861
+ useStore,
1862
+ t,
1863
+ }),
1864
+ dialogElement,
1865
+ ctx !== null ? E('div', {
1866
+ className: 'bw-ctx-overlay',
1867
+ onMouseDown: () => setCtx(null),
1868
+ onContextMenu: (e) => e.preventDefault(),
1869
+ },
1870
+ E('div', {
1871
+ className: 'bw-ctx-menu',
1872
+ style: { left: Math.min(ctx.x, window.innerWidth - 190), top: Math.min(ctx.y, window.innerHeight - 240) },
1873
+ onMouseDown: (e) => e.stopPropagation(),
1874
+ onContextMenu: (e) => e.preventDefault(),
1875
+ },
1876
+ ctxItems().map((item, index) => item.sep
1877
+ ? E('div', { key: 'sep-' + index, className: 'bw-ctx-sep' })
1878
+ : E('button', {
1879
+ key: item.id,
1880
+ type: 'button',
1881
+ className: cls('bw-ctx-item', item.danger && 'bw-ctx-danger'),
1882
+ onClick: () => handleCtxPick(item.id),
1883
+ }, item.label),
1884
+ ),
1885
+ ),
1886
+ ) : null,
1887
+ E(CustomizeDialog, {
1888
+ open: customize !== null,
1889
+ kind: customize ? customize.kind : undefined,
1890
+ initial: customize ? styleEntry(customize.entryKey) : undefined,
1891
+ onChange: (style) => { if (customize) actions.setStyling(customize.entryKey, style) },
1892
+ onReset: () => { if (customize) actions.setStyling(customize.entryKey, null) },
1893
+ onClose: () => setCustomize(null),
1894
+ t,
1895
+ }),
1896
+ E(ui.Modal, {
1897
+ open: errorText !== null,
1898
+ onClose: () => setErrorText(null),
1899
+ closeLabel: t('close'),
1900
+ title: t('error.title'),
1901
+ footer: E('div', { className: 'bw-modal-actions' }, E(BTN, { variant: 'primary', onClick: () => setErrorText(null) }, t('close'))),
1902
+ }, E('div', { className: 'bw-modal-body' }, E('div', { className: 'bw-error-text', role: 'alert' }, errorText || '')), StyleNode()),
1903
+ )
1904
+ }
1905
+
1906
+ /* ============================ plugin ============================== */
1907
+
1908
+ const flowSource = (slots, hole) => ({
1909
+ getSnapshot: () => {
1910
+ try { return slots.entries(hole).length > 0 } catch { return false }
1911
+ },
1912
+ subscribe: (listener) => {
1913
+ try { return slots.subscribe(hole, listener) } catch { return () => {} }
1914
+ },
1915
+ })
1916
+
1917
+ function apply(ctx) {
1918
+ const slots = ctx.get('slots')
1919
+ if (slots === undefined || typeof slots.register !== 'function') {
1920
+ console.error('[dsh-better-workspace] slots service unavailable; plugin idle')
1921
+ return
1922
+ }
1923
+ const sessions = ctx.get('sessions')
1924
+ const workspaces = ctx.get('workspaces')
1925
+ const uiWorkspace = ctx.get('uiWorkspace')
1926
+ const connection = ctx.get('connection')
1927
+ if (!sessions || !workspaces || !uiWorkspace) {
1928
+ console.error('[dsh-better-workspace] required services missing', {
1929
+ sessions: !!sessions, workspaces: !!workspaces, uiWorkspace: !!uiWorkspace,
1930
+ })
1931
+ return
1932
+ }
1933
+
1934
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
1935
+ ctx.effect(() => {
1936
+ try {
1937
+ return ctx.locale.register(NS, { zh, en })
1938
+ } catch (localeError) {
1939
+ console.warn('[dsh-better-workspace] dictionary registration failed', localeError)
1940
+ return () => {}
1941
+ }
1942
+ }, 'better-workspace: dictionaries')
1943
+ }
1944
+
1945
+ const searchSessions = async (query, signal) => {
1946
+ const result = await sessions.search(query, signal)
1947
+ if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session search failed')
1948
+ return result.value
1949
+ }
1950
+ const renameSession = async (sessionId, title) => {
1951
+ const binding = sessions.binding(sessionId)
1952
+ const session = binding && binding.session
1953
+ if (!session) throw new Error('unknown session "' + sessionId + '"')
1954
+ const result = await session.rename(title)
1955
+ if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
1956
+ }
1957
+ const forkSession = (sessionId) => {
1958
+ sessions.fork({ sessionId, increaseTitle: true })
1959
+ .then((childId) => sessions.open(childId))
1960
+ .catch(() => { /* keep current selection */ })
1961
+ }
1962
+
1963
+ const browserInjected = () => ({
1964
+ startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
1965
+ open: (sessionId) => { sessions.open(sessionId) },
1966
+ searchSessions,
1967
+ searchResultLimit: sessions.searchResultLimit !== undefined ? sessions.searchResultLimit : 20,
1968
+ renameSession,
1969
+ forkSession,
1970
+ renameWorkspace: (workspaceId, title) => workspaces.rename(workspaceId, title),
1971
+ deleteWorkspace: (workspaceId) => workspaces.delete(workspaceId),
1972
+ insertWorkspaceBefore: typeof workspaces.insertBefore === 'function'
1973
+ ? (workspaceId, beforeWorkspaceId) => workspaces.insertBefore(workspaceId, beforeWorkspaceId)
1974
+ : undefined,
1975
+ archiveSession: (sessionId) => uiWorkspace.archiveSession(sessionId),
1976
+ createWorkspace: (input) => workspaces.create(input),
1977
+ pickDirectory: () => uiWorkspace.pickDirectory(),
1978
+ hooks: {
1979
+ directoryFlow: flowSource(slots, 'sidebar.workspaces.directoryFlow'),
1980
+ connectionGeneration: connection ? connection.generation : undefined,
1981
+ },
1982
+ })
1983
+ const flowInjected = (hole) => () => ({
1984
+ createWorkspace: (input) => workspaces.create(input),
1985
+ renameWorkspace: (workspaceId, title) => workspaces.rename(workspaceId, title),
1986
+ pickDirectory: () => uiWorkspace.pickDirectory(),
1987
+ hooks: { directoryFlow: flowSource(slots, hole) },
1988
+ })
1989
+
1990
+ // Registration helper: a thrown register (semantics drift, vanishing
1991
+ // hole declaration mid-transition) degrades this one seat, never the
1992
+ // plugin fiber — the whole web boot is all-or-nothing.
1993
+ const guarded = (slotKey, options, component) => () => {
1994
+ try {
1995
+ return slots.register(options, (props) => E(QuietBoundary, null, E(component, props)))
1996
+ } catch (registerError) {
1997
+ console.warn('[dsh-better-workspace] register skipped for ' + slotKey, registerError)
1998
+ return undefined
1999
+ }
2000
+ }
2001
+
2002
+ // 1+2. the two directory-flow holes (hero picker, shipped sidebar browser).
2003
+ slots.inject('conversation.hero.workspace.directoryFlow', guarded(
2004
+ 'conversation.hero.workspace.directoryFlow',
2005
+ { name: 'conversation.hero.workspace.directoryFlow', inject: flowInjected('conversation.hero.workspace.directoryFlow'), locale: NS },
2006
+ BetterFlow,
2007
+ ))
2008
+ slots.inject('sidebar.workspaces.directoryFlow', guarded(
2009
+ 'sidebar.workspaces.directoryFlow',
2010
+ { name: 'sidebar.workspaces.directoryFlow', inject: flowInjected('sidebar.workspaces.directoryFlow'), locale: NS },
2011
+ BetterFlow,
2012
+ ))
2013
+
2014
+ // One shared store handle: the browser and the settings page must see the
2015
+ // same persisted state (expansion, folder list, prefs, styling).
2016
+ const viewStore = createViewStore()
2017
+
2018
+ // Settings → Plugins card only (the tab dispatches the intersection of
2019
+ // served namespaces — registered host-side — and settings.plugin.item
2020
+ // cards). The old left-nav settings.section entry was removed: the
2021
+ // plugins-section card is the single settings surface now.
2022
+ slots.inject('settings.plugin.item', guarded(
2023
+ 'settings.plugin.item',
2024
+ {
2025
+ name: 'settings.plugin.item',
2026
+ key: 'better-workspace',
2027
+ locale: NS,
2028
+ store: viewStore,
2029
+ },
2030
+ BetterWorkspacePluginCard,
2031
+ ))
2032
+
2033
+ // 3. the browser itself — lowest priority renders, shadowing the shipped entry.
2034
+ slots.inject('sidebar.workspaces', guarded(
2035
+ 'sidebar.workspaces',
2036
+ {
2037
+ name: 'sidebar.workspaces',
2038
+ priority: -1,
2039
+ store: viewStore,
2040
+ inject: browserInjected,
2041
+ locale: NS,
2042
+ },
2043
+ BetterBrowser,
2044
+ ))
2045
+ }
2046
+
2047
+ return {
2048
+ name: 'dsh-better-workspace',
2049
+ inject: ['slots', 'sessions', 'workspaces', 'locale', 'connection', 'uiWorkspace'],
2050
+ apply,
2051
+ }
2052
+ },
2053
+ })