dsh-better-workspace 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,9 +23,9 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
23
23
 
24
24
  点「添加工作区」并选完文件夹后,弹出一个小对话框,一行选择工作区所属分组——直接输入(如 `web`)、从下拉里选已有层级(datalist 原生补全),留空就是根分组。确认后插件创建工作区并把分组前缀写进名称,树上自然落位。
25
25
 
26
- ### 新建分组
26
+ ### 分组的增删
27
27
 
28
- 标题栏的「新建分组」按钮可以创建**空分组**(比如先把 `web` 占好位),分组路径支持多级。空分组持久保存在浏览器本地;一旦有工作区落进去,分组就由名称自然派生。
28
+ 右键任意分组行即可:**新增子分组**(路径自动带上父分组前缀)、**新增子工作区**(选完目录后所属分组输入框自动预填)、重命名分组、删除空分组;标题栏不再放独立按钮。空分组持久保存在浏览器本地;一旦有工作区落进去,分组就由名称自然派生。
29
29
 
30
30
  ### 会话也吃这套规则
31
31
 
@@ -45,14 +45,14 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
45
45
  <table>
46
46
  <tr>
47
47
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="右键菜单"/></td>
48
- <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观;工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 删除空分组 / 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观。</td>
48
+ <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观;工作区分组行——新增子分组 / 新增子工作区 / 重命名分组(同步更新组内所有工作区名称)/ 删除空分组 / 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观。</td>
49
49
  </tr>
50
50
  </table>
51
51
 
52
52
  <table>
53
53
  <tr>
54
54
  <td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="插件设置卡片"/></td>
55
- <td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易);展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
55
+ <td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>完成提醒浮窗</b>开关(工作区折叠时其中的会话完成会在侧边栏弹出数秒非阻塞提醒,默认开);展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
56
56
  </tr>
57
57
  </table>
58
58
 
@@ -60,7 +60,8 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
60
60
 
61
61
  - **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标);会话分组同样可收起;搜索时自动全部展开。
62
62
  - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组;会话行可拖拽——同工作区拖到另一会话上/下调整顺序,拖到会话子分组即移动进该分组。显示顺序 = 宿主手动序,拖拽结果立即生效。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
63
- - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
63
+ - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**完成提醒浮窗**(折叠的工作区里有会话完成时在侧边栏弹数秒非阻塞提醒,不打断输入,可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
64
+ - **URL 标题感知**:会话/工作区标题里的 URL(如 `https://host/api/v1/x`)按 URL 语义分层——域名与协议合成一段、路径各段自成层级,同前缀 URL 自动聚到同一分组下,不再被撕成伪分组。
64
65
  - **对话空态页同样生效**:会话空态页的「添加工作区」菜单走的也是本插件的拾取交互,同样带所属分组弹窗。
65
66
 
66
67
  ## 安装
package/README_EN.md CHANGED
@@ -21,9 +21,9 @@ web/ <- virtual folder (naming only, not a real directory)
21
21
 
22
22
  After you pick a directory for a new workspace, a small dialog asks for the parent group — type one (`web`), pick an existing level from the datalist, or leave it empty for the root. The plugin creates the workspace and writes the prefix into its title, so it lands on the right branch of the tree.
23
23
 
24
- ### New-folder button
24
+ ### Folder create/delete via context menu
25
25
 
26
- Create explicit empty folders (multi-level paths welcome) that persist in the browser until workspaces live inside them.
26
+ Right-click any folder row: new subfolder (parent prefix pre-filled), new workspace here (the add-flow group field pre-fills with this folder), rename folder, delete empty folder — no standalone header button anymore. Explicit empty folders persist in the browser until workspaces live inside them.
27
27
 
28
28
  ### Sessions follow the same rule
29
29
 
@@ -43,7 +43,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
43
43
  <table>
44
44
  <tr>
45
45
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="Context menu"/></td>
46
- <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / delete empty group / customize; session &amp; session-group rows — rename / fork / archive / customize.</td>
46
+ <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — new subfolder / new workspace here / rename group (updates all descendant workspaces) / delete empty group / customize; session &amp; session-group rows — rename / fork / archive / customize.</td>
47
47
  </tr>
48
48
  </table>
49
49
 
@@ -58,7 +58,8 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
58
58
 
59
59
  - **Folder-style collapse**: clicking a workspace row collapses/expands all of its sessions (the row keeps a session counter when collapsed); session groups collapse too; search force-expands everything.
60
60
  - **Native drag restored**: drag a workspace row above/below another to reorder (writes back the host registry order) or onto a folder row to move it into that group; drag session rows to reorder within the same workspace or onto a session group to move. Display order = host manual order, drag results are visible immediately. While dragging a workspace, merged single-chain rows (e.g. work/A) temporarily re-expand into folder rows — every level of the path becomes a drop target, and chains merge back when the drag ends.
61
- - **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session, current-session highlight, official status dots (blue running ring / amber pending / green done), **active-schedule badge** (alarm clock icon for sessions with active Schedule records, matching the official rows), ungrouped-session fallback, zh/en localization.
61
+ - **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session (the + button always expands the workspace first so the new session is immediately visible), current-session highlight, official status dots (blue running ring / amber pending / green done), **completion toasts** (a session finishing inside a collapsed workspace pops a brief non-blocking toast in the sidebar; never intercepts typing, off-able in settings), **active-schedule badge** (alarm clock icon for sessions with active Schedule records, matching the official rows), ungrouped-session fallback, zh/en localization.
62
+ - **URL-aware titles**: URLs in session/workspace titles (e.g. `https://host/api/v1/x`) split the way URLs mean it — scheme://host as one segment, each path segment a level — so same-prefix URLs nest together instead of shredding into pseudo-folders.
62
63
  - **Conversation hero too**: the empty-state “Add workspace” menu uses the same picking interaction with the same group popup.
63
64
 
64
65
  ## Install
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区层级树:名称中的 / 即虚拟分组(web/前端 · web/后端),添加工作区的目录流附所属分组弹窗,重命名即时重排层级,可新建空分组。Hierarchy tree for the DSH sidebar workspace list.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "DSH web plugin: a hierarchical workspace tree for the sidebar. Workspace titles containing \"/\" group into virtual folders (web/前端 · web/后端); the add-workspace directory flow gains a parent-group popup; renames re-derive the tree; a new-folder button creates empty levels.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -31,7 +31,6 @@ window.__ModuleLoader__.load({
31
31
  'title': '工作区',
32
32
  'search.placeholder': '搜索工作区或会话',
33
33
  'add': '添加工作区',
34
- 'newFolder': '新建分组',
35
34
  'rail.search': '搜索',
36
35
  'rail.add': '添加工作区',
37
36
  'empty': '暂无工作区',
@@ -48,6 +47,7 @@ window.__ModuleLoader__.load({
48
47
  'time.years': '{n} 年',
49
48
  'status.running': '生成中',
50
49
  'status.completed': '已完成',
50
+ 'toast.completed': '会话已完成',
51
51
  'status.approval': '等待批准',
52
52
  'status.planReview': '等待计划确认',
53
53
  'status.question': '等待回答',
@@ -57,6 +57,8 @@ window.__ModuleLoader__.load({
57
57
  'menu.delete': '删除',
58
58
  'menu.fork': '分叉',
59
59
  'menu.archive': '归档',
60
+ 'menu.newSubfolder': '新增子分组',
61
+ 'menu.newSubWorkspace': '新增子工作区',
60
62
  'menu.renameFolder': '重命名分组',
61
63
  'menu.removeFolder': '删除分组',
62
64
  'menu.renameSgroup': '重命名会话分组',
@@ -66,6 +68,8 @@ window.__ModuleLoader__.load({
66
68
  'settings.collapse': '收起',
67
69
  'settings.compactChains': '单链分组折叠显示',
68
70
  'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
71
+ 'settings.completionToasts': '完成提醒浮窗',
72
+ 'settings.completionToasts.hint': '工作区处于折叠状态时,其中的会话完成后在侧边栏弹出数秒的非阻塞提醒(不打断输入等任何操作);默认开启,可在此关闭。',
69
73
  'custom.title': '自定义外观',
70
74
  'custom.color': '颜色',
71
75
  'custom.glow': '发光',
@@ -117,7 +121,6 @@ window.__ModuleLoader__.load({
117
121
  'title': 'Workspaces',
118
122
  'search.placeholder': 'Search workspaces or sessions',
119
123
  'add': 'Add workspace',
120
- 'newFolder': 'New folder',
121
124
  'rail.search': 'Search',
122
125
  'rail.add': 'Add workspace',
123
126
  'empty': 'No workspaces yet',
@@ -134,6 +137,7 @@ window.__ModuleLoader__.load({
134
137
  'time.years': '{n}y',
135
138
  'status.running': 'Running',
136
139
  'status.completed': 'Completed',
140
+ 'toast.completed': 'Session completed',
137
141
  'status.approval': 'Waiting for approval',
138
142
  'status.planReview': 'Waiting for plan review',
139
143
  'status.question': 'Waiting for answer',
@@ -143,6 +147,8 @@ window.__ModuleLoader__.load({
143
147
  'menu.delete': 'Delete',
144
148
  'menu.fork': 'Fork',
145
149
  'menu.archive': 'Archive',
150
+ 'menu.newSubfolder': 'New subfolder',
151
+ 'menu.newSubWorkspace': 'New workspace here',
146
152
  'menu.renameFolder': 'Rename folder',
147
153
  'menu.removeFolder': 'Delete folder',
148
154
  'menu.renameSgroup': 'Rename session group',
@@ -152,6 +158,8 @@ window.__ModuleLoader__.load({
152
158
  'settings.collapse': 'Collapse',
153
159
  'settings.compactChains': 'Merge single-child chains',
154
160
  'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. Chains re-expand into folder rows while you drag a workspace, so it can drop into any level. State and custom styling persist in this browser.',
161
+ 'settings.completionToasts': 'Completion toasts',
162
+ 'settings.completionToasts.hint': 'When its workspace row is collapsed, a finishing session pops a brief non-blocking toast in the sidebar (never intercepts typing); on by default, turn it off here.',
155
163
  'custom.title': 'Customize',
156
164
  'custom.color': 'Color',
157
165
  'custom.glow': 'Glow',
@@ -210,8 +218,47 @@ window.__ModuleLoader__.load({
210
218
  const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
211
219
  return i === -1 ? s : s.slice(i + 1)
212
220
  }
213
- const splitTitleSegs = (title) => String(title || '').split('/').map(s => s.trim()).filter(Boolean)
214
- const normPath = (p) => String(p || '').split('/').map(s => s.trim()).filter(Boolean).join('/')
221
+ /**
222
+ * URL-aware segment splitting for titles. A bare "/" split shreds URLs
223
+ * ("https:" → empty → host → path...), so when a "://" shows up the tail is
224
+ * parsed as a URL and split the way URLs mean it: scheme://host is ONE
225
+ * segment (the authority is not a hierarchy level), each pathname segment
226
+ * is a level, and query/hash glue to the last segment. Consequences:
227
+ * several sessions sharing a URL prefix (".../v1/m", ".../v1/n") nest
228
+ * under the same derived folder instead of piling up as long flat rows,
229
+ * while "docs/see https://x/a/b" keeps the plain "docs" head. Titles whose
230
+ * URL-looking tail does not parse stay opaque from the last "/" before the
231
+ * scheme marker. Grouping is a pure projection of titles, so sessions that
232
+ * were previously shredded into pseudo-folders re-flow on reload.
233
+ */
234
+ const urlSegsOf = (raw) => {
235
+ let u = null
236
+ try { u = new URL(raw) } catch { return null }
237
+ const host = u.protocol + '//' + u.host
238
+ const pathSegs = u.pathname.split('/').filter(Boolean)
239
+ const tail = (u.search || '') + (u.hash || '')
240
+ if (pathSegs.length === 0) return [host + tail]
241
+ if (tail !== '') pathSegs[pathSegs.length - 1] += tail
242
+ return [host].concat(pathSegs)
243
+ }
244
+
245
+ const splitTitleSegs = (title) => {
246
+ const s = String(title || '')
247
+ const schemeAt = s.indexOf('://')
248
+ if (schemeAt === -1) return s.split('/').map(x => x.trim()).filter(Boolean)
249
+ let start = schemeAt
250
+ while (start > 0 && /[a-zA-Z0-9+.-]/.test(s[start - 1])) start--
251
+ const head = s.slice(0, start).split('/').map(x => x.trim()).filter(Boolean)
252
+ const urlSegs = urlSegsOf(s.slice(start).trim())
253
+ if (urlSegs !== null) return head.concat(urlSegs)
254
+ // Not a parseable URL after all: opaque from the last "/" before the marker.
255
+ const cut = s.lastIndexOf('/', schemeAt)
256
+ const segs = (cut === -1 ? '' : s.slice(0, cut)).split('/').map(x => x.trim()).filter(Boolean)
257
+ const tailText = s.slice(cut + 1).trim()
258
+ if (tailText !== '') segs.push(tailText)
259
+ return segs
260
+ }
261
+ const normPath = (p) => splitTitleSegs(p).join('/')
215
262
 
216
263
  /** Render a primitives icon by name; unknown names degrade to null, never crash. */
217
264
  const icon = (name, size) => {
@@ -219,22 +266,6 @@ window.__ModuleLoader__.load({
219
266
  return C ? E(C, { size: size || 16 }) : null
220
267
  }
221
268
 
222
- /** Folder-with-plus glyph for the new-group header button (absent from the primitives set). */
223
- const FolderPlusIcon = (props) => E('svg', {
224
- width: props.size || 16,
225
- height: props.size || 16,
226
- viewBox: '0 0 16 16',
227
- fill: 'none',
228
- stroke: 'currentColor',
229
- strokeWidth: 1.2,
230
- strokeLinecap: 'round',
231
- strokeLinejoin: 'round',
232
- 'aria-hidden': 'true',
233
- },
234
- 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' }),
235
- E('path', { d: 'M8 7.2v3.6M6.2 9h3.6' }),
236
- )
237
-
238
269
  const FALLBACK_UNITS = { minutes: 'm', hours: 'h', days: 'd', months: 'mo', years: 'y' }
239
270
  const timeLabel = (updatedAt, now, t) => {
240
271
  if (typeof ui.relativeTime !== 'function') return ''
@@ -326,22 +357,31 @@ window.__ModuleLoader__.load({
326
357
  function buildSessionTree(rows) {
327
358
  const root = { path: '', name: '', groups: [], sessions: [] }
328
359
  const byPath = new Map([['', root]])
329
- const ensure = (path) => {
330
- if (path === '') return root
331
- const known = byPath.get(path)
332
- if (known) return known
333
- const segs = path.split('/')
334
- const parent = ensure(segs.slice(0, -1).join('/'))
335
- const node = { path, name: segs[segs.length - 1], groups: [], sessions: [] }
336
- byPath.set(path, node)
337
- parent.groups.push(node)
360
+ // SEGMENT-driven: paths arrive as already-split segment arrays (URL-aware
361
+ // splitTitleSegs can yield segments containing "//", e.g. the
362
+ // "scheme://host" authority segment) and are joined into the path KEY
363
+ // verbatim never re-split on "/", which would shred an authority
364
+ // segment into "https:" + "" + host.
365
+ const ensure = (segs) => {
366
+ let node = root
367
+ let key = ''
368
+ for (const seg of segs) {
369
+ key = key === '' ? seg : key + '/' + seg
370
+ let next = byPath.get(key)
371
+ if (!next) {
372
+ next = { path: key, name: seg, groups: [], sessions: [] }
373
+ byPath.set(key, next)
374
+ node.groups.push(next)
375
+ }
376
+ node = next
377
+ }
338
378
  return node
339
379
  }
340
380
  for (const row of rows || []) {
341
381
  const segs = splitTitleSegs(row.title)
342
382
  const folderPath = segs.slice(0, -1).join('/')
343
383
  const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
344
- ensure(folderPath).sessions.push({ ...row, leaf })
384
+ ensure(segs.slice(0, -1)).sessions.push({ ...row, leaf })
345
385
  }
346
386
  const sortRec = (node) => {
347
387
  node.groups.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
@@ -377,26 +417,32 @@ window.__ModuleLoader__.load({
377
417
  function buildTree(items, explicitFolders) {
378
418
  const root = { path: '', name: '', folders: [], workspaces: [] }
379
419
  const byPath = new Map([['', root]])
380
- const ensure = (path) => {
381
- if (path === '') return root
382
- const known = byPath.get(path)
383
- if (known) return known
384
- const segs = path.split('/')
385
- const parent = ensure(segs.slice(0, -1).join('/'))
386
- const node = { path, name: segs[segs.length - 1], folders: [], workspaces: [] }
387
- byPath.set(path, node)
388
- parent.folders.push(node)
420
+ // SEGMENT-driven, same as buildSessionTree: the joined path is only a
421
+ // KEY; parsing it back on "/" would break URL authority segments.
422
+ const ensure = (segs) => {
423
+ let node = root
424
+ let key = ''
425
+ for (const seg of segs) {
426
+ key = key === '' ? seg : key + '/' + seg
427
+ let next = byPath.get(key)
428
+ if (!next) {
429
+ next = { path: key, name: seg, folders: [], workspaces: [] }
430
+ byPath.set(key, next)
431
+ node.folders.push(next)
432
+ }
433
+ node = next
434
+ }
389
435
  return node
390
436
  }
391
437
  for (const folder of explicitFolders || []) {
392
- const p = normPath(folder)
393
- if (p !== '') ensure(p)
438
+ const segs = splitTitleSegs(normPath(folder))
439
+ if (segs.length > 0) ensure(segs)
394
440
  }
395
441
  for (const workspace of items || []) {
396
442
  const segs = splitTitleSegs(workspace.title)
397
443
  const folderPath = segs.slice(0, -1).join('/')
398
444
  const leaf = segs.length > 0 ? segs[segs.length - 1] : (basename(workspace.path) || String(workspace.title || '') || String(workspace.workspaceId || ''))
399
- ensure(folderPath).workspaces.push({
445
+ ensure(segs.slice(0, -1)).workspaces.push({
400
446
  workspaceId: workspace.workspaceId,
401
447
  title: String(workspace.title || ''),
402
448
  path: String(workspace.path || ''),
@@ -418,40 +464,59 @@ window.__ModuleLoader__.load({
418
464
  /**
419
465
  * VS Code-style single-child chain compression (preference-controlled):
420
466
  * a folder level holding exactly ONE child and nothing else merges into a
421
- * single display row folder chains join names with "/" (keeps the DEEPEST
422
- * path as its expansion identity), and a folder whose only child is one
423
- * workspace becomes that workspace row with the merged label. Only
424
- * presentation changes; the underlying workspace/title data is untouched.
467
+ * single display row; a chain ending in one workspace becomes that
468
+ * workspace row with the merged label. Only presentation changes; the
469
+ * underlying workspace/title data is untouched.
470
+ *
471
+ * The merged label is the chain RELATIVE to the first un-compressed
472
+ * ancestor (VS Code explorer behaviour): a chain a/b/c holding only
473
+ * workspace W shows "a/b/c/W" at the root, but the same chain nested
474
+ * inside a populated folder "a" shows "b/c/W". Labels are therefore
475
+ * assembled from RELATIVE segments (segs + pure leaf name) and only
476
+ * materialised into a display node at the chain's top — the pre-0.8 code
477
+ * prefixed each recursion level with the FULL node.path, so nested chains
478
+ * rendered duplicated prefixes like "1/2/1/2/3/A".
425
479
  */
480
+ const materializeChain = (chain) => chain.kind === 'ws'
481
+ ? {
482
+ kind: 'ws',
483
+ path: chain.path,
484
+ workspace: { ...chain.workspace, leaf: chain.segs.join('/') + '/' + chain.pure, title: chain.workspace.title, folderPath: '' },
485
+ folders: [],
486
+ workspaces: [],
487
+ }
488
+ : { kind: 'folder', path: chain.path, name: chain.segs.join('/'), folders: chain.folders, workspaces: chain.workspaces }
489
+
426
490
  function compressTree(node) {
427
491
  const folders = (node.folders || []).map(compressTree)
428
492
  const workspaces = node.workspaces || []
429
493
  if (workspaces.length === 0 && folders.length === 1) {
494
+ // Continue the chain upward: one more relative segment in front of
495
+ // whatever the child chain accumulated. path stays the DEEPEST full
496
+ // path (expansion identity); the label is joined only at the top.
430
497
  const child = folders[0]
431
- if (child.kind === 'ws') {
432
- const ws = child.workspace
433
- return {
434
- kind: 'ws',
435
- path: child.path,
436
- workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' },
437
- folders: [],
438
- workspaces: [],
439
- dropPath: child.path,
440
- }
498
+ return {
499
+ kind: child.kind,
500
+ path: child.path,
501
+ segs: [node.name].concat(child.segs || []),
502
+ pure: child.pure,
503
+ workspace: child.workspace,
504
+ folders: child.folders,
505
+ workspaces: child.workspaces,
441
506
  }
442
- return { kind: 'folder', path: child.path, name: node.name + '/' + child.name, folders: child.folders || [], workspaces: child.workspaces || [], dropPath: child.path || node.path }
443
507
  }
444
508
  if (folders.length === 0 && workspaces.length === 1) {
445
- const ws = workspaces[0]
446
- return { kind: 'ws', path: node.path, workspace: { ...ws, leaf: (node.path !== '' ? node.path + '/' : '') + ws.leaf, title: ws.title, folderPath: '' }, folders: [], workspaces: [], dropPath: node.path }
509
+ return { kind: 'ws', path: node.path, segs: [node.name], pure: workspaces[0].leaf, workspace: workspaces[0], folders: [], workspaces: [] }
447
510
  }
448
- return { kind: 'folder', path: node.path, name: node.name, folders, workspaces, dropPath: node.path }
511
+ // Chain top (multiple children, or a mix): children are materialised
512
+ // relative to this node; this node itself keeps its single name.
513
+ return { kind: 'folder', path: node.path, segs: [node.name], folders: folders.map(materializeChain), workspaces }
449
514
  }
450
515
 
451
516
  /* ============================== styles ============================ */
452
517
 
453
518
  const CSS_TEXT = [
454
- '.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;color:var(--dsw-alias-label-primary,#e6e6e6)}',
519
+ '.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;position:relative;color:var(--dsw-alias-label-primary,#e6e6e6)}',
455
520
  '.bw-header{display:flex;align-items:center;gap:2px;padding:10px 10px 4px;flex:none}',
456
521
  '.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}',
457
522
  '.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}',
@@ -550,6 +615,11 @@ window.__ModuleLoader__.load({
550
615
  '.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
551
616
  '.bw-preview-icon svg{width:18px;height:18px}',
552
617
  '.bw-preview-label{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:18px}',
618
+ '.bw-toasts{position:absolute;right:10px;bottom:14px;display:flex;flex-direction:column;gap:6px;z-index:30;pointer-events:none;max-width:280px}',
619
+ '.bw-toast{display:flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px;border-radius:8px;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));box-shadow:0 8px 24px rgba(0,0,0,.35);animation:bw-toast-in .18s ease}',
620
+ '@keyframes bw-toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}',
621
+ '.bw-toast-title{font-size:12.5px;color:var(--dsw-alias-label-primary,#e6e6e6);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
622
+ '.bw-toast-kind{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
553
623
  ].join('')
554
624
 
555
625
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -670,6 +740,7 @@ window.__ModuleLoader__.load({
670
740
  */
671
741
  function BetterFlow(props) {
672
742
  const { open, busy, onPicked, onCancel, onError, createWorkspace, renameWorkspace, pickDirectory, useWorkspaces, useStore, t } = props
743
+ const initialParent = props.initialParent || ''
673
744
  const actions = props.actions
674
745
  const [phase, setPhase] = React.useState('idle') // idle | picking | picked | submitting
675
746
  const [pickedPath, setPickedPath] = React.useState('')
@@ -694,7 +765,7 @@ window.__ModuleLoader__.load({
694
765
  if (!alive) return
695
766
  if (!path) { onCancel(); return }
696
767
  setPickedPath(String(path))
697
- setParentInput('')
768
+ setParentInput(initialParent)
698
769
  setPhase('picked')
699
770
  })
700
771
  .catch((reason) => {
@@ -1075,6 +1146,7 @@ window.__ModuleLoader__.load({
1075
1146
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1076
1147
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1077
1148
  const compactChains = prefs.compactChains !== false
1149
+ const completionToasts = prefs.completionToasts !== false
1078
1150
  return E('div', { className: 'bw-settings' },
1079
1151
  StyleNode(),
1080
1152
  E('div', { className: 'bw-setting-row' },
@@ -1089,6 +1161,18 @@ window.__ModuleLoader__.load({
1089
1161
  }, E('span', { className: 'bw-switch-thumb' })),
1090
1162
  ),
1091
1163
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
1164
+ E('div', { className: 'bw-setting-row', style: { marginTop: 10 } },
1165
+ E('div', { className: 'bw-setting-label' }, t('settings.completionToasts')),
1166
+ E('button', {
1167
+ type: 'button',
1168
+ role: 'switch',
1169
+ 'aria-checked': completionToasts,
1170
+ 'aria-label': t('settings.completionToasts'),
1171
+ className: cls('bw-switch', completionToasts && 'bw-switch-on'),
1172
+ onClick: () => { actions.setPref('completionToasts', !completionToasts) },
1173
+ }, E('span', { className: 'bw-switch-thumb' })),
1174
+ ),
1175
+ E('div', { className: 'bw-hint' }, t('settings.completionToasts.hint')),
1092
1176
  )
1093
1177
  }
1094
1178
 
@@ -1146,16 +1230,20 @@ window.__ModuleLoader__.load({
1146
1230
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1147
1231
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1148
1232
  const compactChains = prefsMap.compactChains !== false
1233
+ const completionToasts = prefsMap.completionToasts !== false
1149
1234
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1150
1235
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1151
1236
 
1152
1237
  const [query, setQuery] = React.useState('')
1153
1238
  const [searchOpen, setSearchOpen] = React.useState(false)
1154
1239
  const [flowOpen, setFlowOpen] = React.useState(false)
1240
+ const [flowParent, setFlowParent] = React.useState('') // parent path prefill for the add-workspace flow (context menu entry)
1155
1241
  const [dialog, setDialog] = React.useState(null) // { kind, ... }
1156
1242
  const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
1157
1243
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
1158
1244
  const [errorText, setErrorText] = React.useState(null)
1245
+ const [toasts, setToasts] = React.useState([]) // completion relays: { key, title }
1246
+ const toastTimers = React.useRef([])
1159
1247
  const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
1160
1248
  // Workspace drags arm their state one frame LATE (see workspaceDragEvents):
1161
1249
  // arming synchronously re-renders during the dragstart dispatch, the chain
@@ -1163,9 +1251,43 @@ window.__ModuleLoader__.load({
1163
1251
  // source element, and Chromium cancels the whole gesture. dragEnd clears
1164
1252
  // the timer so a same-tick cancel never leaves a ghost drag behind.
1165
1253
  const wsDragArmTimer = React.useRef(null)
1254
+ const completedSeenRef = React.useRef(null) // previous render's completed session ids
1255
+ // Declared BEFORE the completion effect: its dependency array evaluates
1256
+ // during render, so referencing a later const would be a TDZ crash.
1166
1257
  const normalizedQuery = query.trim().toLowerCase()
1167
1258
  const now = Date.now()
1168
1259
 
1260
+ // Completion relay (preference-controlled, default ON): when a session
1261
+ // flips to completed===true while its workspace row is COLLAPSED, the
1262
+ // official green dot is invisible, so a brief non-blocking toast pops in
1263
+ // the sidebar. Edge-triggered against the previous snapshot (no storm on
1264
+ // reload), skipped while searching (rows are force-expanded there), and
1265
+ // pointer-events:none keeps typing and clicks completely uninterrupted.
1266
+ React.useEffect(() => {
1267
+ const cur = new Set()
1268
+ if (list && list.byId) {
1269
+ for (const id of Object.keys(list.byId)) {
1270
+ const summary = list.byId[id]
1271
+ if (summary && summary.completed === true && sessionVisible(summary, list.current, archivedSet)) cur.add(id)
1272
+ }
1273
+ }
1274
+ const prev = completedSeenRef.current
1275
+ completedSeenRef.current = cur
1276
+ if (prev === null || !completionToasts) return // first snapshot records only
1277
+ for (const id of cur) {
1278
+ if (prev.has(id)) continue
1279
+ const workspace = (items || []).find(w => (w.sessionIds || []).includes(id))
1280
+ if (!workspace) continue
1281
+ if (normalizedQuery === '' && sessionsOpenOf(workspace.workspaceId)) continue // green dot already visible
1282
+ const toast = { key: id + ':' + String(Date.now()), title: sessionTitleOf(list.byId[id], t) }
1283
+ setToasts(current => current.concat([toast]))
1284
+ toastTimers.current.push(setTimeout(() => {
1285
+ setToasts(current => current.filter(x => x.key !== toast.key))
1286
+ }, 5200))
1287
+ }
1288
+ }, [list, items, normalizedQuery !== '', completionToasts, sessionsExpandedMap, archivedSet])
1289
+ React.useEffect(() => () => { for (const timer of toastTimers.current) clearTimeout(timer) }, [])
1290
+
1169
1291
  const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
1170
1292
 
1171
1293
  const styleEntry = (key) => stylingMap[key] || null
@@ -1226,7 +1348,7 @@ window.__ModuleLoader__.load({
1226
1348
  const tree = React.useMemo(() => {
1227
1349
  const built = buildTree(items, storeFolders)
1228
1350
  if (!compactChains || draggingWorkspace) return built
1229
- return { ...built, folders: built.folders.map(compressTree), workspaces: built.workspaces }
1351
+ return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
1230
1352
  }, [items, storeFolders, compactChains, draggingWorkspace])
1231
1353
 
1232
1354
  const sessionsOf = (workspace) => {
@@ -1566,10 +1688,19 @@ window.__ModuleLoader__.load({
1566
1688
  return null
1567
1689
  }
1568
1690
 
1569
- const submitFolderNew = (rawPath) => {
1570
- const p = normPath(rawPath)
1571
- if (p === '') { setErrorText(t('folder.error.empty')); return }
1572
- if (storeFolders.includes(p)) { setErrorText(t('folder.error.exists')); return }
1691
+ const submitFolderNew = (parentPath, rawPath) => {
1692
+ const parent = normPath(parentPath)
1693
+ const sub = normPath(rawPath)
1694
+ if (sub === '') { setErrorText(t('folder.error.empty')); return }
1695
+ const p = parent !== '' ? parent + '/' + sub : sub
1696
+ // Existence check covers explicit folders AND every folder derived
1697
+ // from workspace titles, so "already exists" means either kind.
1698
+ const derived = new Set()
1699
+ for (const w of items || []) {
1700
+ const segs = splitTitleSegs(w.title)
1701
+ for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
1702
+ }
1703
+ if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
1573
1704
  actions.addFolder(p)
1574
1705
  setDialog(null)
1575
1706
  }
@@ -1673,7 +1804,9 @@ window.__ModuleLoader__.load({
1673
1804
  sessionsOpen: searching ? true : sessionsOpenOf(workspace.workspaceId),
1674
1805
  currentInside: !!(list && list.current && (workspace.sessionIds || []).includes(list.current)),
1675
1806
  onToggle: () => { if (!searching) actions.setSessionsExpanded(workspace.workspaceId, !sessionsOpenOf(workspace.workspaceId)) },
1676
- onStart: () => startSession(workspace.workspaceId),
1807
+ // Starting a session force-expands the workspace row: the user must
1808
+ // SEE the new session appear, even if the row was collapsed.
1809
+ onStart: () => { actions.setSessionsExpanded(workspace.workspaceId, true); startSession(workspace.workspaceId) },
1677
1810
  onContextMenu: (e) => openCtx('workspace', workspace, e),
1678
1811
  dropHalf: wsDropHalf(workspace.workspaceId),
1679
1812
  dragEvents: workspaceDragEvents(workspace),
@@ -1752,7 +1885,11 @@ window.__ModuleLoader__.load({
1752
1885
  const ctxItems = () => {
1753
1886
  if (ctx === null) return []
1754
1887
  if (ctx.kind === 'folder') {
1755
- const items = [{ id: 'rename-folder', label: t('menu.renameFolder') }]
1888
+ const items = [
1889
+ { id: 'new-subfolder', label: t('menu.newSubfolder') },
1890
+ { id: 'new-subworkspace', label: t('menu.newSubWorkspace') },
1891
+ { id: 'rename-folder', label: t('menu.renameFolder') },
1892
+ ]
1756
1893
  if (storeFolders.includes(ctx.payload.path)) items.push({ id: 'remove-folder', label: t('menu.removeFolder'), danger: true })
1757
1894
  items.push({ sep: true })
1758
1895
  items.push({ id: 'customize', label: t('custom.title') })
@@ -1790,7 +1927,9 @@ window.__ModuleLoader__.load({
1790
1927
  }
1791
1928
  const { kind, payload } = current
1792
1929
  setCtx(null)
1793
- if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
1930
+ if (kind === 'folder' && id === 'new-subfolder') setDialog({ kind: 'folder-new', parentPath: payload.path })
1931
+ else if (kind === 'folder' && id === 'new-subworkspace') { setFlowParent(payload.path); setFlowOpen(true) }
1932
+ else if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
1794
1933
  else if (kind === 'folder' && id === 'remove-folder') setDialog({ kind: 'folder-delete', path: payload.path })
1795
1934
  else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
1796
1935
  else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
@@ -1810,7 +1949,7 @@ window.__ModuleLoader__.load({
1810
1949
  return E('div', { className: 'bw-rail' },
1811
1950
  StyleNode(),
1812
1951
  E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.search'), onClick: () => { expandSidebar(); setSearchOpen(true) } }, icon('IconSearchOutline16', 18)),
1813
- E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
1952
+ E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
1814
1953
  )
1815
1954
  }
1816
1955
 
@@ -1854,8 +1993,8 @@ window.__ModuleLoader__.load({
1854
1993
  key: 'folder-new',
1855
1994
  title: t('folder.new.title'),
1856
1995
  hint: t('folder.new.hint'),
1857
- initial: '',
1858
- onConfirm: submitFolderNew,
1996
+ initial: dialog.parentPath ? dialog.parentPath + '/' : '',
1997
+ onConfirm: (v) => submitFolderNew(dialog.parentPath || '', v),
1859
1998
  onClose: () => setDialog(null),
1860
1999
  t,
1861
2000
  })
@@ -1904,13 +2043,13 @@ window.__ModuleLoader__.load({
1904
2043
  onBlur: () => { if (query === '') setSearchOpen(false) },
1905
2044
  }) : null,
1906
2045
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
1907
- E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('newFolder'), onClick: () => setDialog({ kind: 'folder-new' }) }, E(FolderPlusIcon, { size: 16 })),
1908
- E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => setFlowOpen(true) }, icon('IconProjectAddOutline16')),
2046
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => { setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16')),
1909
2047
  ),
1910
2048
  E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
1911
2049
  E(BetterFlow, {
1912
2050
  open: flowOpen,
1913
2051
  busy: false,
2052
+ initialParent: flowParent,
1914
2053
  onPicked: flowOwner.onPicked,
1915
2054
  onCancel: flowOwner.onCancel,
1916
2055
  onError: flowOwner.onError,
@@ -1954,6 +2093,13 @@ window.__ModuleLoader__.load({
1954
2093
  onClose: () => setCustomize(null),
1955
2094
  t,
1956
2095
  }),
2096
+ toasts.length > 0 ? E('div', { className: 'bw-toasts', role: 'status', 'aria-live': 'polite' },
2097
+ toasts.map(x => E('div', { key: x.key, className: 'bw-toast' },
2098
+ typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: 'done', size: 10 }) : null,
2099
+ E('span', { className: 'bw-toast-title', title: x.title }, x.title),
2100
+ E('span', { className: 'bw-toast-kind' }, t('toast.completed')),
2101
+ )),
2102
+ ) : null,
1957
2103
  E(ui.Modal, {
1958
2104
  open: errorText !== null,
1959
2105
  onClose: () => setErrorText(null),