dsh-better-workspace 0.7.2 → 0.9.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,14 +43,14 @@ 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
 
50
50
  <table>
51
51
  <tr>
52
52
  <td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="Settings card"/></td>
53
- <td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade); expansion state and styling persist via the dsh client store in the browser.</td>
53
+ <td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default); expansion state and styling persist via the dsh client store in the browser.</td>
54
54
  </tr>
55
55
  </table>
56
56
 
@@ -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), **status breathing lights** (dots hidden by collapse bubble outward: workspace/folder rows breathe on their icon in the status color — amber pending / green done, custom-glow labels breathe along; running breathes blue only on the session row itself and never relays; 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.9.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.9.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': '暂无工作区',
@@ -57,6 +56,8 @@ window.__ModuleLoader__.load({
57
56
  'menu.delete': '删除',
58
57
  'menu.fork': '分叉',
59
58
  'menu.archive': '归档',
59
+ 'menu.newSubfolder': '新增子分组',
60
+ 'menu.newSubWorkspace': '新增子工作区',
60
61
  'menu.renameFolder': '重命名分组',
61
62
  'menu.removeFolder': '删除分组',
62
63
  'menu.renameSgroup': '重命名会话分组',
@@ -66,6 +67,8 @@ window.__ModuleLoader__.load({
66
67
  'settings.collapse': '收起',
67
68
  'settings.compactChains': '单链分组折叠显示',
68
69
  'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
70
+ 'settings.statusPulse': '状态呼吸灯',
71
+ 'settings.statusPulse.hint': '被折叠藏起的状态灯(完成绿 / 运行蓝 / 待交互琥珀)沿层级向外冒泡:工作区与分组行以图标呼吸发光(颜色随状态,自定义过发光的标题一起呼吸),会话分组行显示呼吸状态灯;默认开启,可在此关闭。',
69
72
  'custom.title': '自定义外观',
70
73
  'custom.color': '颜色',
71
74
  'custom.glow': '发光',
@@ -117,7 +120,6 @@ window.__ModuleLoader__.load({
117
120
  'title': 'Workspaces',
118
121
  'search.placeholder': 'Search workspaces or sessions',
119
122
  'add': 'Add workspace',
120
- 'newFolder': 'New folder',
121
123
  'rail.search': 'Search',
122
124
  'rail.add': 'Add workspace',
123
125
  'empty': 'No workspaces yet',
@@ -143,6 +145,8 @@ window.__ModuleLoader__.load({
143
145
  'menu.delete': 'Delete',
144
146
  'menu.fork': 'Fork',
145
147
  'menu.archive': 'Archive',
148
+ 'menu.newSubfolder': 'New subfolder',
149
+ 'menu.newSubWorkspace': 'New workspace here',
146
150
  'menu.renameFolder': 'Rename folder',
147
151
  'menu.removeFolder': 'Delete folder',
148
152
  'menu.renameSgroup': 'Rename session group',
@@ -152,6 +156,8 @@ window.__ModuleLoader__.load({
152
156
  'settings.collapse': 'Collapse',
153
157
  'settings.compactChains': 'Merge single-child chains',
154
158
  '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.',
159
+ 'settings.statusPulse': 'Status breathing light',
160
+ 'settings.statusPulse.hint': 'Status dots hidden by collapse (done green / running blue / pending amber) bubble outward: workspace and folder rows breathe on their icon in the status color (custom-glow labels breathe along), session-group rows show a breathing dot; on by default, turn it off here.',
155
161
  'custom.title': 'Customize',
156
162
  'custom.color': 'Color',
157
163
  'custom.glow': 'Glow',
@@ -210,8 +216,47 @@ window.__ModuleLoader__.load({
210
216
  const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
211
217
  return i === -1 ? s : s.slice(i + 1)
212
218
  }
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('/')
219
+ /**
220
+ * URL-aware segment splitting for titles. A bare "/" split shreds URLs
221
+ * ("https:" → empty → host → path...), so when a "://" shows up the tail is
222
+ * parsed as a URL and split the way URLs mean it: scheme://host is ONE
223
+ * segment (the authority is not a hierarchy level), each pathname segment
224
+ * is a level, and query/hash glue to the last segment. Consequences:
225
+ * several sessions sharing a URL prefix (".../v1/m", ".../v1/n") nest
226
+ * under the same derived folder instead of piling up as long flat rows,
227
+ * while "docs/see https://x/a/b" keeps the plain "docs" head. Titles whose
228
+ * URL-looking tail does not parse stay opaque from the last "/" before the
229
+ * scheme marker. Grouping is a pure projection of titles, so sessions that
230
+ * were previously shredded into pseudo-folders re-flow on reload.
231
+ */
232
+ const urlSegsOf = (raw) => {
233
+ let u = null
234
+ try { u = new URL(raw) } catch { return null }
235
+ const host = u.protocol + '//' + u.host
236
+ const pathSegs = u.pathname.split('/').filter(Boolean)
237
+ const tail = (u.search || '') + (u.hash || '')
238
+ if (pathSegs.length === 0) return [host + tail]
239
+ if (tail !== '') pathSegs[pathSegs.length - 1] += tail
240
+ return [host].concat(pathSegs)
241
+ }
242
+
243
+ const splitTitleSegs = (title) => {
244
+ const s = String(title || '')
245
+ const schemeAt = s.indexOf('://')
246
+ if (schemeAt === -1) return s.split('/').map(x => x.trim()).filter(Boolean)
247
+ let start = schemeAt
248
+ while (start > 0 && /[a-zA-Z0-9+.-]/.test(s[start - 1])) start--
249
+ const head = s.slice(0, start).split('/').map(x => x.trim()).filter(Boolean)
250
+ const urlSegs = urlSegsOf(s.slice(start).trim())
251
+ if (urlSegs !== null) return head.concat(urlSegs)
252
+ // Not a parseable URL after all: opaque from the last "/" before the marker.
253
+ const cut = s.lastIndexOf('/', schemeAt)
254
+ const segs = (cut === -1 ? '' : s.slice(0, cut)).split('/').map(x => x.trim()).filter(Boolean)
255
+ const tailText = s.slice(cut + 1).trim()
256
+ if (tailText !== '') segs.push(tailText)
257
+ return segs
258
+ }
259
+ const normPath = (p) => splitTitleSegs(p).join('/')
215
260
 
216
261
  /** Render a primitives icon by name; unknown names degrade to null, never crash. */
217
262
  const icon = (name, size) => {
@@ -219,22 +264,6 @@ window.__ModuleLoader__.load({
219
264
  return C ? E(C, { size: size || 16 }) : null
220
265
  }
221
266
 
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
267
  const FALLBACK_UNITS = { minutes: 'm', hours: 'h', days: 'd', months: 'mo', years: 'y' }
239
268
  const timeLabel = (updatedAt, now, t) => {
240
269
  if (typeof ui.relativeTime !== 'function') return ''
@@ -326,22 +355,31 @@ window.__ModuleLoader__.load({
326
355
  function buildSessionTree(rows) {
327
356
  const root = { path: '', name: '', groups: [], sessions: [] }
328
357
  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)
358
+ // SEGMENT-driven: paths arrive as already-split segment arrays (URL-aware
359
+ // splitTitleSegs can yield segments containing "//", e.g. the
360
+ // "scheme://host" authority segment) and are joined into the path KEY
361
+ // verbatim never re-split on "/", which would shred an authority
362
+ // segment into "https:" + "" + host.
363
+ const ensure = (segs) => {
364
+ let node = root
365
+ let key = ''
366
+ for (const seg of segs) {
367
+ key = key === '' ? seg : key + '/' + seg
368
+ let next = byPath.get(key)
369
+ if (!next) {
370
+ next = { path: key, name: seg, groups: [], sessions: [] }
371
+ byPath.set(key, next)
372
+ node.groups.push(next)
373
+ }
374
+ node = next
375
+ }
338
376
  return node
339
377
  }
340
378
  for (const row of rows || []) {
341
379
  const segs = splitTitleSegs(row.title)
342
380
  const folderPath = segs.slice(0, -1).join('/')
343
381
  const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
344
- ensure(folderPath).sessions.push({ ...row, leaf })
382
+ ensure(segs.slice(0, -1)).sessions.push({ ...row, leaf })
345
383
  }
346
384
  const sortRec = (node) => {
347
385
  node.groups.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
@@ -377,26 +415,32 @@ window.__ModuleLoader__.load({
377
415
  function buildTree(items, explicitFolders) {
378
416
  const root = { path: '', name: '', folders: [], workspaces: [] }
379
417
  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)
418
+ // SEGMENT-driven, same as buildSessionTree: the joined path is only a
419
+ // KEY; parsing it back on "/" would break URL authority segments.
420
+ const ensure = (segs) => {
421
+ let node = root
422
+ let key = ''
423
+ for (const seg of segs) {
424
+ key = key === '' ? seg : key + '/' + seg
425
+ let next = byPath.get(key)
426
+ if (!next) {
427
+ next = { path: key, name: seg, folders: [], workspaces: [] }
428
+ byPath.set(key, next)
429
+ node.folders.push(next)
430
+ }
431
+ node = next
432
+ }
389
433
  return node
390
434
  }
391
435
  for (const folder of explicitFolders || []) {
392
- const p = normPath(folder)
393
- if (p !== '') ensure(p)
436
+ const segs = splitTitleSegs(normPath(folder))
437
+ if (segs.length > 0) ensure(segs)
394
438
  }
395
439
  for (const workspace of items || []) {
396
440
  const segs = splitTitleSegs(workspace.title)
397
441
  const folderPath = segs.slice(0, -1).join('/')
398
442
  const leaf = segs.length > 0 ? segs[segs.length - 1] : (basename(workspace.path) || String(workspace.title || '') || String(workspace.workspaceId || ''))
399
- ensure(folderPath).workspaces.push({
443
+ ensure(segs.slice(0, -1)).workspaces.push({
400
444
  workspaceId: workspace.workspaceId,
401
445
  title: String(workspace.title || ''),
402
446
  path: String(workspace.path || ''),
@@ -418,40 +462,59 @@ window.__ModuleLoader__.load({
418
462
  /**
419
463
  * VS Code-style single-child chain compression (preference-controlled):
420
464
  * 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.
465
+ * single display row; a chain ending in one workspace becomes that
466
+ * workspace row with the merged label. Only presentation changes; the
467
+ * underlying workspace/title data is untouched.
468
+ *
469
+ * The merged label is the chain RELATIVE to the first un-compressed
470
+ * ancestor (VS Code explorer behaviour): a chain a/b/c holding only
471
+ * workspace W shows "a/b/c/W" at the root, but the same chain nested
472
+ * inside a populated folder "a" shows "b/c/W". Labels are therefore
473
+ * assembled from RELATIVE segments (segs + pure leaf name) and only
474
+ * materialised into a display node at the chain's top — the pre-0.8 code
475
+ * prefixed each recursion level with the FULL node.path, so nested chains
476
+ * rendered duplicated prefixes like "1/2/1/2/3/A".
425
477
  */
478
+ const materializeChain = (chain) => chain.kind === 'ws'
479
+ ? {
480
+ kind: 'ws',
481
+ path: chain.path,
482
+ workspace: { ...chain.workspace, leaf: chain.segs.join('/') + '/' + chain.pure, title: chain.workspace.title, folderPath: '' },
483
+ folders: [],
484
+ workspaces: [],
485
+ }
486
+ : { kind: 'folder', path: chain.path, name: chain.segs.join('/'), folders: chain.folders, workspaces: chain.workspaces }
487
+
426
488
  function compressTree(node) {
427
489
  const folders = (node.folders || []).map(compressTree)
428
490
  const workspaces = node.workspaces || []
429
491
  if (workspaces.length === 0 && folders.length === 1) {
492
+ // Continue the chain upward: one more relative segment in front of
493
+ // whatever the child chain accumulated. path stays the DEEPEST full
494
+ // path (expansion identity); the label is joined only at the top.
430
495
  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
- }
496
+ return {
497
+ kind: child.kind,
498
+ path: child.path,
499
+ segs: [node.name].concat(child.segs || []),
500
+ pure: child.pure,
501
+ workspace: child.workspace,
502
+ folders: child.folders,
503
+ workspaces: child.workspaces,
441
504
  }
442
- return { kind: 'folder', path: child.path, name: node.name + '/' + child.name, folders: child.folders || [], workspaces: child.workspaces || [], dropPath: child.path || node.path }
443
505
  }
444
506
  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 }
507
+ return { kind: 'ws', path: node.path, segs: [node.name], pure: workspaces[0].leaf, workspace: workspaces[0], folders: [], workspaces: [] }
447
508
  }
448
- return { kind: 'folder', path: node.path, name: node.name, folders, workspaces, dropPath: node.path }
509
+ // Chain top (multiple children, or a mix): children are materialised
510
+ // relative to this node; this node itself keeps its single name.
511
+ return { kind: 'folder', path: node.path, segs: [node.name], folders: folders.map(materializeChain), workspaces }
449
512
  }
450
513
 
451
514
  /* ============================== styles ============================ */
452
515
 
453
516
  const CSS_TEXT = [
454
- '.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;color:var(--dsw-alias-label-primary,#e6e6e6)}',
517
+ '.bw-root{height:100%;display:flex;flex-direction:column;min-height:0;position:relative;color:var(--dsw-alias-label-primary,#e6e6e6)}',
455
518
  '.bw-header{display:flex;align-items:center;gap:2px;padding:10px 10px 4px;flex:none}',
456
519
  '.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
520
  '.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 +613,10 @@ window.__ModuleLoader__.load({
550
613
  '.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
551
614
  '.bw-preview-icon svg{width:18px;height:18px}',
552
615
  '.bw-preview-label{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:18px}',
616
+ '.bw-pulse{animation:bw-breathe 1.8s ease-in-out infinite}',
617
+ '@keyframes bw-breathe{0%,100%{filter:drop-shadow(0 0 1px var(--bw-pulse-color));opacity:.55}50%{filter:drop-shadow(0 0 6px var(--bw-pulse-color));opacity:1}}',
618
+ '.bw-pulse-text{animation:bw-breathe-text 1.8s ease-in-out infinite}',
619
+ '@keyframes bw-breathe-text{0%,100%{text-shadow:0 0 1px var(--bw-pulse-color);opacity:.65}50%{text-shadow:0 0 7px var(--bw-pulse-color);opacity:1}}',
553
620
  ].join('')
554
621
 
555
622
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -670,6 +737,7 @@ window.__ModuleLoader__.load({
670
737
  */
671
738
  function BetterFlow(props) {
672
739
  const { open, busy, onPicked, onCancel, onError, createWorkspace, renameWorkspace, pickDirectory, useWorkspaces, useStore, t } = props
740
+ const initialParent = props.initialParent || ''
673
741
  const actions = props.actions
674
742
  const [phase, setPhase] = React.useState('idle') // idle | picking | picked | submitting
675
743
  const [pickedPath, setPickedPath] = React.useState('')
@@ -694,7 +762,7 @@ window.__ModuleLoader__.load({
694
762
  if (!alive) return
695
763
  if (!path) { onCancel(); return }
696
764
  setPickedPath(String(path))
697
- setParentInput('')
765
+ setParentInput(initialParent)
698
766
  setPhase('picked')
699
767
  })
700
768
  .catch((reason) => {
@@ -797,11 +865,17 @@ window.__ModuleLoader__.load({
797
865
  .map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
798
866
  .join('')
799
867
 
800
- function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, t }) {
868
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, t }) {
801
869
  const total = countWorkspaces(node)
870
+ const iconEl = iconOf(iconMode, expanded)
871
+ // Hidden status dots breathe on the icon; with the icon hidden (mode
872
+ // "none" or a missing primitive) the official StateDot stands in.
873
+ const iconChild = pulse
874
+ ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
875
+ : iconEl
802
876
  return E('div', {
803
877
  className: cls('bw-row', dropInto && 'bw-drop-into'),
804
- style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
878
+ style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
805
879
  onClick: onToggle,
806
880
  onContextMenu: onContextMenu,
807
881
  role: 'treeitem',
@@ -809,16 +883,21 @@ window.__ModuleLoader__.load({
809
883
  ...(dragEvents || {}),
810
884
  },
811
885
  E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
812
- E('span', { className: 'bw-row-icon' }, iconOf(iconMode, expanded)),
813
- E('span', { className: 'bw-row-label' }, node.name),
886
+ E('span', { className: 'bw-row-icon' }, iconChild),
887
+ // A row that already carries a custom label glow breathes in sync.
888
+ E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text') }, node.name),
814
889
  total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
815
890
  )
816
891
  }
817
892
 
818
- function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, t }) {
893
+ function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, pulse, t }) {
894
+ const iconEl = iconOf(iconMode, sessionsOpen)
895
+ const iconChild = pulse
896
+ ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
897
+ : iconEl
819
898
  return E('div', {
820
899
  className: cls('bw-row', currentInside && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
821
- style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}) },
900
+ style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
822
901
  onClick: onToggle,
823
902
  onContextMenu: onContextMenu,
824
903
  role: 'treeitem',
@@ -826,8 +905,8 @@ window.__ModuleLoader__.load({
826
905
  ...(dragEvents || {}),
827
906
  },
828
907
  E('span', { className: cls('bw-chevron', sessionsOpen && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
829
- E('span', { className: 'bw-row-icon' }, iconOf(iconMode, sessionsOpen)),
830
- E('span', { className: 'bw-row-label', title: workspace.title || workspace.leaf }, workspace.leaf),
908
+ E('span', { className: 'bw-row-icon' }, iconChild),
909
+ E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text'), title: workspace.title || workspace.leaf }, workspace.leaf),
831
910
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
832
911
  E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
833
912
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
@@ -835,16 +914,53 @@ window.__ModuleLoader__.load({
835
914
  )
836
915
  }
837
916
 
838
- function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, t }) {
839
- // Official status priority: pending interaction > running > running
840
- // subagents > completed reminder; idle rows show no dot at all.
841
- let status = null
842
- if (node.pending === 'approval' || node.pending === 'plan-review' || node.pending === 'question') {
843
- status = { state: 'warning', title: t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending)) }
844
- } else if (node.running || node.subagents > 0) {
845
- status = { state: 'ongoing', title: node.running ? t('status.running') : t('status.subagents', { n: node.subagents }) }
846
- } else if (node.completed) {
847
- status = { state: 'done', title: t('status.completed') }
917
+ // Official status priority: pending interaction > running > running
918
+ // subagents > completed reminder; idle rows show no dot at all.
919
+ const sessionStateOf = (row) => {
920
+ if (!row) return null
921
+ if (row.pending === 'approval' || row.pending === 'plan-review' || row.pending === 'question') return 'warning'
922
+ if (row.running || row.subagents > 0) return 'ongoing'
923
+ if (row.completed) return 'done'
924
+ return null
925
+ }
926
+
927
+ /**
928
+ * Status breathing light (preference-controlled, default ON): status dots
929
+ * hidden by a collapse bubble outward to the nearest visible container
930
+ * row. Workspace/folder rows breathe on their icon (drop-shadow glow in
931
+ * the status color; a hidden/no icon falls back to the official StateDot);
932
+ * session-group rows carry a breathing StateDot in front of the label.
933
+ * Colors mirror the official dots: warning amber > ongoing blue > done
934
+ * green, so the glow always matches the lamp it relays.
935
+ */
936
+ const PULSE_RANK = { warning: 3, ongoing: 2, done: 1 }
937
+ const PULSE_COLORS = { warning: '#d29922', ongoing: '#5b8def', done: '#3fb950' }
938
+ const pulseRank = (state) => (state ? (PULSE_RANK[state] || 0) : 0)
939
+
940
+ function PulseGlow({ state, children }) {
941
+ return E('span', {
942
+ className: 'bw-pulse',
943
+ style: { '--bw-pulse-color': PULSE_COLORS[state] || PULSE_COLORS.ongoing },
944
+ }, children)
945
+ }
946
+
947
+ // RUNNING deliberately stays on the session row itself (its official ring
948
+ // breathes blue there) and does NOT bubble outward — the user found the
949
+ // ongoing relay noisy. Only pending-amber and done-green propagate.
950
+ const relayStateOf = (row) => {
951
+ const s = sessionStateOf(row)
952
+ return s === 'ongoing' ? null : s
953
+ }
954
+
955
+ function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, breathing, t }) {
956
+ const state = sessionStateOf(node)
957
+ const status = state === null ? null : {
958
+ state,
959
+ title: state === 'warning'
960
+ ? t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending))
961
+ : state === 'ongoing'
962
+ ? (node.running ? t('status.running') : t('status.subagents', { n: node.subagents }))
963
+ : t('status.completed'),
848
964
  }
849
965
  return E('div', {
850
966
  className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
@@ -856,7 +972,11 @@ window.__ModuleLoader__.load({
856
972
  },
857
973
  E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
858
974
  status && typeof ui.StateDot === 'function'
859
- ? E(ui.StateDot, { state: status.state, size: 10 })
975
+ ? (state === 'ongoing' && breathing
976
+ // Running breathes blue ON the session row only (never relays);
977
+ // the settings toggle covers this breathing too.
978
+ ? E(PulseGlow, { state: 'ongoing' }, E(ui.StateDot, { state: status.state, size: 10 }))
979
+ : E(ui.StateDot, { state: status.state, size: 10 }))
860
980
  : E('span', { className: 'bw-dot' }),
861
981
  ),
862
982
  E('span', { className: 'bw-row-label' }, node.leaf || node.title),
@@ -872,7 +992,7 @@ window.__ModuleLoader__.load({
872
992
  * titles). Deliberately NOT styled like a workspace folder — no folder
873
993
  * icon, tertiary color — so a session level never reads as a workspace.
874
994
  */
875
- function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, t }) {
995
+ function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, pulse, t }) {
876
996
  return E('div', {
877
997
  className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
878
998
  style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
@@ -883,6 +1003,9 @@ window.__ModuleLoader__.load({
883
1003
  ...(dragEvents || {}),
884
1004
  },
885
1005
  E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
1006
+ // Session groups have no icon slot: a collapsed group with hidden
1007
+ // status relays through a breathing official StateDot instead.
1008
+ pulse ? E(PulseGlow, { state: pulse }, typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null) : null,
886
1009
  E('span', { className: 'bw-row-label' }, name),
887
1010
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
888
1011
  )
@@ -1075,6 +1198,7 @@ window.__ModuleLoader__.load({
1075
1198
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1076
1199
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1077
1200
  const compactChains = prefs.compactChains !== false
1201
+ const statusPulse = prefs.statusPulse !== false
1078
1202
  return E('div', { className: 'bw-settings' },
1079
1203
  StyleNode(),
1080
1204
  E('div', { className: 'bw-setting-row' },
@@ -1089,6 +1213,18 @@ window.__ModuleLoader__.load({
1089
1213
  }, E('span', { className: 'bw-switch-thumb' })),
1090
1214
  ),
1091
1215
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
1216
+ E('div', { className: 'bw-setting-row', style: { marginTop: 10 } },
1217
+ E('div', { className: 'bw-setting-label' }, t('settings.statusPulse')),
1218
+ E('button', {
1219
+ type: 'button',
1220
+ role: 'switch',
1221
+ 'aria-checked': statusPulse,
1222
+ 'aria-label': t('settings.statusPulse'),
1223
+ className: cls('bw-switch', statusPulse && 'bw-switch-on'),
1224
+ onClick: () => { actions.setPref('statusPulse', !statusPulse) },
1225
+ }, E('span', { className: 'bw-switch-thumb' })),
1226
+ ),
1227
+ E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1092
1228
  )
1093
1229
  }
1094
1230
 
@@ -1146,12 +1282,14 @@ window.__ModuleLoader__.load({
1146
1282
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1147
1283
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1148
1284
  const compactChains = prefsMap.compactChains !== false
1285
+ const statusPulse = prefsMap.statusPulse !== false
1149
1286
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1150
1287
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1151
1288
 
1152
1289
  const [query, setQuery] = React.useState('')
1153
1290
  const [searchOpen, setSearchOpen] = React.useState(false)
1154
1291
  const [flowOpen, setFlowOpen] = React.useState(false)
1292
+ const [flowParent, setFlowParent] = React.useState('') // parent path prefill for the add-workspace flow (context menu entry)
1155
1293
  const [dialog, setDialog] = React.useState(null) // { kind, ... }
1156
1294
  const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
1157
1295
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
@@ -1226,7 +1364,7 @@ window.__ModuleLoader__.load({
1226
1364
  const tree = React.useMemo(() => {
1227
1365
  const built = buildTree(items, storeFolders)
1228
1366
  if (!compactChains || draggingWorkspace) return built
1229
- return { ...built, folders: built.folders.map(compressTree), workspaces: built.workspaces }
1367
+ return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
1230
1368
  }, [items, storeFolders, compactChains, draggingWorkspace])
1231
1369
 
1232
1370
  const sessionsOf = (workspace) => {
@@ -1280,6 +1418,45 @@ window.__ModuleLoader__.load({
1280
1418
  const searched = normalizedQuery ? searchAgent(tree) : null
1281
1419
  const searching = normalizedQuery !== ''
1282
1420
 
1421
+ /* --------------------- status breathing relay -------------------- */
1422
+
1423
+ // Highest-priority status across a whole session subtree (groups + rows).
1424
+ const nodePulseOf = (sessionNode) => {
1425
+ let best = null
1426
+ for (const group of sessionNode.groups || []) {
1427
+ const s = nodePulseOf(group)
1428
+ if (pulseRank(s) > pulseRank(best)) best = s
1429
+ }
1430
+ for (const row of sessionNode.sessions || []) {
1431
+ const s = relayStateOf(row)
1432
+ if (pulseRank(s) > pulseRank(best)) best = s
1433
+ }
1434
+ return best
1435
+ }
1436
+ // A COLLAPSED workspace row relays its whole session tree; an open row
1437
+ // shows the real dots (deeper collapsed groups relay on their own rows).
1438
+ const wsPulseOf = (workspace) => (!statusPulse || searching || sessionsOpenOf(workspace.workspaceId))
1439
+ ? null
1440
+ : nodePulseOf(buildSessionTree(sessionsOf(workspace)))
1441
+ // A collapsed FOLDER hides everything below — including open workspaces —
1442
+ // so its aggregation ignores inner expansion states entirely.
1443
+ const wsAllPulseOf = (workspace) => (!statusPulse || searching)
1444
+ ? null
1445
+ : nodePulseOf(buildSessionTree(sessionsOf(workspace)))
1446
+ const folderPulseOf = (node) => {
1447
+ if (node.kind === 'ws') return wsAllPulseOf(node.workspace)
1448
+ let best = null
1449
+ for (const child of node.folders) {
1450
+ const s = folderPulseOf(child)
1451
+ if (pulseRank(s) > pulseRank(best)) best = s
1452
+ }
1453
+ for (const w of node.workspaces) {
1454
+ const s = wsAllPulseOf(w)
1455
+ if (pulseRank(s) > pulseRank(best)) best = s
1456
+ }
1457
+ return best
1458
+ }
1459
+
1283
1460
  const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
1284
1461
  const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
1285
1462
  const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
@@ -1566,10 +1743,19 @@ window.__ModuleLoader__.load({
1566
1743
  return null
1567
1744
  }
1568
1745
 
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 }
1746
+ const submitFolderNew = (parentPath, rawPath) => {
1747
+ const parent = normPath(parentPath)
1748
+ const sub = normPath(rawPath)
1749
+ if (sub === '') { setErrorText(t('folder.error.empty')); return }
1750
+ const p = parent !== '' ? parent + '/' + sub : sub
1751
+ // Existence check covers explicit folders AND every folder derived
1752
+ // from workspace titles, so "already exists" means either kind.
1753
+ const derived = new Set()
1754
+ for (const w of items || []) {
1755
+ const segs = splitTitleSegs(w.title)
1756
+ for (let i = 1; i < segs.length; i++) derived.add(segs.slice(0, i).join('/'))
1757
+ }
1758
+ if (storeFolders.includes(p) || derived.has(p)) { setErrorText(t('folder.error.exists')); return }
1573
1759
  actions.addFolder(p)
1574
1760
  setDialog(null)
1575
1761
  }
@@ -1634,6 +1820,7 @@ window.__ModuleLoader__.load({
1634
1820
  name: group.name,
1635
1821
  depth,
1636
1822
  expanded: open,
1823
+ pulse: (statusPulse && !open) ? nodePulseOf(group) : null,
1637
1824
  count: countSessionTree(group),
1638
1825
  onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
1639
1826
  onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
@@ -1659,10 +1846,11 @@ window.__ModuleLoader__.load({
1659
1846
  dropHalf: workspaceId ? sessDropHalf(session.id) : null,
1660
1847
  dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
1661
1848
  custStyle: rowStyleOf('session:' + session.id),
1849
+ breathing: statusPulse,
1662
1850
  t,
1663
1851
  })
1664
1852
 
1665
- const renderWorkspaceEntry = (entry, depth) => {
1853
+ const renderWorkspaceEntry = (entry, depth, pulse) => {
1666
1854
  const { workspace } = entry
1667
1855
  const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
1668
1856
  const rows = [E(WorkspaceRow, {
@@ -1673,12 +1861,15 @@ window.__ModuleLoader__.load({
1673
1861
  sessionsOpen: searching ? true : sessionsOpenOf(workspace.workspaceId),
1674
1862
  currentInside: !!(list && list.current && (workspace.sessionIds || []).includes(list.current)),
1675
1863
  onToggle: () => { if (!searching) actions.setSessionsExpanded(workspace.workspaceId, !sessionsOpenOf(workspace.workspaceId)) },
1676
- onStart: () => startSession(workspace.workspaceId),
1864
+ // Starting a session force-expands the workspace row: the user must
1865
+ // SEE the new session appear, even if the row was collapsed.
1866
+ onStart: () => { actions.setSessionsExpanded(workspace.workspaceId, true); startSession(workspace.workspaceId) },
1677
1867
  onContextMenu: (e) => openCtx('workspace', workspace, e),
1678
1868
  dropHalf: wsDropHalf(workspace.workspaceId),
1679
1869
  dragEvents: workspaceDragEvents(workspace),
1680
1870
  custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
1681
1871
  iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
1872
+ pulse,
1682
1873
  t,
1683
1874
  })]
1684
1875
  rows.push(...renderSessionTree(workspace, depth + 1))
@@ -1686,7 +1877,7 @@ window.__ModuleLoader__.load({
1686
1877
  }
1687
1878
 
1688
1879
  const renderPlainFolder = (node, depth) => {
1689
- if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
1880
+ if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth, wsPulseOf(node.workspace))
1690
1881
  const expanded = searching || folderExpanded(node.path)
1691
1882
  const rows = [E(FolderRow, {
1692
1883
  key: 'f-' + node.path,
@@ -1699,11 +1890,12 @@ window.__ModuleLoader__.load({
1699
1890
  dragEvents: folderDropEvents(node.path),
1700
1891
  custStyle: rowStyleOf('folder:' + node.path),
1701
1892
  iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
1893
+ pulse: expanded ? null : folderPulseOf(node),
1702
1894
  t,
1703
1895
  })]
1704
1896
  if (expanded) {
1705
1897
  for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
1706
- for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1))
1898
+ for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
1707
1899
  }
1708
1900
  return rows
1709
1901
  }
@@ -1736,7 +1928,7 @@ window.__ModuleLoader__.load({
1736
1928
  }
1737
1929
  } else {
1738
1930
  for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
1739
- for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0))
1931
+ for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
1740
1932
  if (ungrouped.length > 0) {
1741
1933
  bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
1742
1934
  for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
@@ -1752,7 +1944,11 @@ window.__ModuleLoader__.load({
1752
1944
  const ctxItems = () => {
1753
1945
  if (ctx === null) return []
1754
1946
  if (ctx.kind === 'folder') {
1755
- const items = [{ id: 'rename-folder', label: t('menu.renameFolder') }]
1947
+ const items = [
1948
+ { id: 'new-subfolder', label: t('menu.newSubfolder') },
1949
+ { id: 'new-subworkspace', label: t('menu.newSubWorkspace') },
1950
+ { id: 'rename-folder', label: t('menu.renameFolder') },
1951
+ ]
1756
1952
  if (storeFolders.includes(ctx.payload.path)) items.push({ id: 'remove-folder', label: t('menu.removeFolder'), danger: true })
1757
1953
  items.push({ sep: true })
1758
1954
  items.push({ id: 'customize', label: t('custom.title') })
@@ -1790,7 +1986,9 @@ window.__ModuleLoader__.load({
1790
1986
  }
1791
1987
  const { kind, payload } = current
1792
1988
  setCtx(null)
1793
- if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
1989
+ if (kind === 'folder' && id === 'new-subfolder') setDialog({ kind: 'folder-new', parentPath: payload.path })
1990
+ else if (kind === 'folder' && id === 'new-subworkspace') { setFlowParent(payload.path); setFlowOpen(true) }
1991
+ else if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
1794
1992
  else if (kind === 'folder' && id === 'remove-folder') setDialog({ kind: 'folder-delete', path: payload.path })
1795
1993
  else if (kind === 'workspace' && id === 'rename') setDialog({ kind: 'ws-rename', workspace: payload })
1796
1994
  else if (kind === 'workspace' && id === 'delete') setDialog({ kind: 'ws-delete', workspace: payload })
@@ -1810,7 +2008,7 @@ window.__ModuleLoader__.load({
1810
2008
  return E('div', { className: 'bw-rail' },
1811
2009
  StyleNode(),
1812
2010
  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)),
2011
+ E('button', { type: 'button', className: 'bw-rail-btn', 'aria-label': t('rail.add'), onClick: () => { expandSidebar(); setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16', 18)),
1814
2012
  )
1815
2013
  }
1816
2014
 
@@ -1854,8 +2052,8 @@ window.__ModuleLoader__.load({
1854
2052
  key: 'folder-new',
1855
2053
  title: t('folder.new.title'),
1856
2054
  hint: t('folder.new.hint'),
1857
- initial: '',
1858
- onConfirm: submitFolderNew,
2055
+ initial: dialog.parentPath ? dialog.parentPath + '/' : '',
2056
+ onConfirm: (v) => submitFolderNew(dialog.parentPath || '', v),
1859
2057
  onClose: () => setDialog(null),
1860
2058
  t,
1861
2059
  })
@@ -1904,13 +2102,13 @@ window.__ModuleLoader__.load({
1904
2102
  onBlur: () => { if (query === '') setSearchOpen(false) },
1905
2103
  }) : null,
1906
2104
  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')),
2105
+ E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => { setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16')),
1909
2106
  ),
1910
2107
  E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
1911
2108
  E(BetterFlow, {
1912
2109
  open: flowOpen,
1913
2110
  busy: false,
2111
+ initialParent: flowParent,
1914
2112
  onPicked: flowOwner.onPicked,
1915
2113
  onCancel: flowOwner.onCancel,
1916
2114
  onError: flowOwner.onError,