dsh-better-workspace 0.8.0 → 0.9.1

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
@@ -52,7 +52,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
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交易)与<b>完成提醒浮窗</b>开关(工作区折叠时其中的会话完成会在侧边栏弹出数秒非阻塞提醒,默认开);展开状态与外观自定义经 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,8 +60,8 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
60
60
 
61
61
  - **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标);会话分组同样可收起;搜索时自动全部展开。
62
62
  - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组;会话行可拖拽——同工作区拖到另一会话上/下调整顺序,拖到会话子分组即移动进该分组。显示顺序 = 宿主手动序,拖拽结果立即生效。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
63
- - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**完成提醒浮窗**(折叠的工作区里有会话完成时在侧边栏弹数秒非阻塞提醒,不打断输入,可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
64
- - **URL 标题感知**:会话/工作区标题里的 URL(如 `https://host/api/v1/x`) URL 语义分层——域名与协议合成一段、路径各段自成层级,同前缀 URL 自动聚到同一分组下,不再被撕成伪分组。
63
+ - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
64
+ - **URL/斜杠标题不拆层**:宿主自动生成的会话标题落地时若含 `/`(如复述了 URL),整串自动包上 `“”` 原样显示,绝不解析成层级;引号内(含手动添加) `/` 永不切分;无引号但含 URL 的旧标题也有平铺兜底。你手动命名的 `/` 分组标题不受影响。
65
65
  - **对话空态页同样生效**:会话空态页的「添加工作区」菜单走的也是本插件的拾取交互,同样带所属分组弹窗。
66
66
 
67
67
  ## 安装
package/README_EN.md CHANGED
@@ -50,7 +50,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
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,8 +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 (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 itscheme://host as one segment, each path segment a level so same-prefix URLs nest together instead of shredding into pseudo-folders.
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
+ - **Slash-bearing titles never nest from URLs**: when a host-generated session title lands containing `/` (e.g. echoing a pasted URL), it is auto-wrapped in `“”` and shown verbatim quotes (manual ones too) never split on `/`; unquoted URL tails still fall back flat. Your manually named `/` groupings are untouched.
63
63
  - **Conversation hero too**: the empty-state “Add workspace” menu uses the same picking interaction with the same group popup.
64
64
 
65
65
  ## Install
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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.8.0",
3
+ "version": "0.9.1",
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
@@ -47,7 +47,6 @@ window.__ModuleLoader__.load({
47
47
  'time.years': '{n} 年',
48
48
  'status.running': '生成中',
49
49
  'status.completed': '已完成',
50
- 'toast.completed': '会话已完成',
51
50
  'status.approval': '等待批准',
52
51
  'status.planReview': '等待计划确认',
53
52
  'status.question': '等待回答',
@@ -68,8 +67,8 @@ window.__ModuleLoader__.load({
68
67
  'settings.collapse': '收起',
69
68
  'settings.compactChains': '单链分组折叠显示',
70
69
  'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
71
- 'settings.completionToasts': '完成提醒浮窗',
72
- 'settings.completionToasts.hint': '工作区处于折叠状态时,其中的会话完成后在侧边栏弹出数秒的非阻塞提醒(不打断输入等任何操作);默认开启,可在此关闭。',
70
+ 'settings.statusPulse': '状态呼吸灯',
71
+ 'settings.statusPulse.hint': '被折叠藏起的状态灯(完成绿 / 运行蓝 / 待交互琥珀)沿层级向外冒泡:工作区与分组行以图标呼吸发光(颜色随状态,自定义过发光的标题一起呼吸),会话分组行显示呼吸状态灯;默认开启,可在此关闭。',
73
72
  'custom.title': '自定义外观',
74
73
  'custom.color': '颜色',
75
74
  'custom.glow': '发光',
@@ -137,7 +136,6 @@ window.__ModuleLoader__.load({
137
136
  'time.years': '{n}y',
138
137
  'status.running': 'Running',
139
138
  'status.completed': 'Completed',
140
- 'toast.completed': 'Session completed',
141
139
  'status.approval': 'Waiting for approval',
142
140
  'status.planReview': 'Waiting for plan review',
143
141
  'status.question': 'Waiting for answer',
@@ -158,8 +156,8 @@ window.__ModuleLoader__.load({
158
156
  'settings.collapse': 'Collapse',
159
157
  'settings.compactChains': 'Merge single-child chains',
160
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.',
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.',
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.',
163
161
  'custom.title': 'Customize',
164
162
  'custom.color': 'Color',
165
163
  'custom.glow': 'Glow',
@@ -219,45 +217,58 @@ window.__ModuleLoader__.load({
219
217
  return i === -1 ? s : s.slice(i + 1)
220
218
  }
221
219
  /**
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.
220
+ * Plain-text splitting with the URL tail kept opaque: a bare "/" split
221
+ * shreds URLs ("https:" → empty → host → path...), so from the first
222
+ * "://" onward the tail is ONE leaf; text before the marker splits
223
+ * normally at the last "/" before it. This is the FALLBACK guard —
224
+ * freshly generated titles get their whole "/"-bearing title wrapped in
225
+ * quotes by the quote-on-land effect (see BetterBrowser), which is the
226
+ * primary mechanism.
233
227
  */
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 || '')
228
+ const splitPlainSegs = (text) => {
229
+ const s = String(text || '')
247
230
  const schemeAt = s.indexOf('://')
248
231
  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
232
  const cut = s.lastIndexOf('/', schemeAt)
256
233
  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)
234
+ const tail = s.slice(cut + 1).trim()
235
+ if (tail !== '') segs.push(tail)
259
236
  return segs
260
237
  }
238
+
239
+ /**
240
+ * Title → hierarchy segments, QUOTE-AWARE: spans wrapped in “…” (or "…")
241
+ * are verbatim — slashes inside quotes never split. Quoted spans stay one
242
+ * leaf including the quotes; text between quoted spans splits through
243
+ * splitPlainSegs (URL-tail opaque). An unterminated opening quote makes
244
+ * the rest of the title one verbatim span. Grouping is a pure projection,
245
+ * so previously shredded titles re-flow on reload.
246
+ */
247
+ const splitTitleSegs = (title) => {
248
+ const s = String(title || '')
249
+ if (!/["“]/.test(s)) return splitPlainSegs(s)
250
+ const out = []
251
+ let plain = ''
252
+ let i = 0
253
+ while (i < s.length) {
254
+ const ch = s[i]
255
+ if (ch === '"' || ch === '“') {
256
+ for (const seg of splitPlainSegs(plain)) out.push(seg)
257
+ plain = ''
258
+ const close = ch === '“' ? '”' : '"'
259
+ const j = s.indexOf(close, i + 1)
260
+ const end = j === -1 ? s.length : j + 1
261
+ const quoted = s.slice(i, end).trim()
262
+ if (quoted !== '') out.push(quoted)
263
+ i = end
264
+ continue
265
+ }
266
+ plain += ch
267
+ i += 1
268
+ }
269
+ for (const seg of splitPlainSegs(plain)) out.push(seg)
270
+ return out
271
+ }
261
272
  const normPath = (p) => splitTitleSegs(p).join('/')
262
273
 
263
274
  /** Render a primitives icon by name; unknown names degrade to null, never crash. */
@@ -615,11 +626,10 @@ window.__ModuleLoader__.load({
615
626
  '.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
616
627
  '.bw-preview-icon svg{width:18px;height:18px}',
617
628
  '.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)}',
629
+ '.bw-pulse{animation:bw-breathe 1.8s ease-in-out infinite}',
630
+ '@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}}',
631
+ '.bw-pulse-text{animation:bw-breathe-text 1.8s ease-in-out infinite}',
632
+ '@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}}',
623
633
  ].join('')
624
634
 
625
635
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -868,11 +878,17 @@ window.__ModuleLoader__.load({
868
878
  .map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
869
879
  .join('')
870
880
 
871
- function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, t }) {
881
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, t }) {
872
882
  const total = countWorkspaces(node)
883
+ const iconEl = iconOf(iconMode, expanded)
884
+ // Hidden status dots breathe on the icon; with the icon hidden (mode
885
+ // "none" or a missing primitive) the official StateDot stands in.
886
+ const iconChild = pulse
887
+ ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
888
+ : iconEl
873
889
  return E('div', {
874
890
  className: cls('bw-row', dropInto && 'bw-drop-into'),
875
- style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
891
+ style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
876
892
  onClick: onToggle,
877
893
  onContextMenu: onContextMenu,
878
894
  role: 'treeitem',
@@ -880,16 +896,21 @@ window.__ModuleLoader__.load({
880
896
  ...(dragEvents || {}),
881
897
  },
882
898
  E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
883
- E('span', { className: 'bw-row-icon' }, iconOf(iconMode, expanded)),
884
- E('span', { className: 'bw-row-label' }, node.name),
899
+ E('span', { className: 'bw-row-icon' }, iconChild),
900
+ // A row that already carries a custom label glow breathes in sync.
901
+ E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text') }, node.name),
885
902
  total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
886
903
  )
887
904
  }
888
905
 
889
- function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, t }) {
906
+ function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, dropHalf, dragEvents, custStyle, iconMode, pulse, t }) {
907
+ const iconEl = iconOf(iconMode, sessionsOpen)
908
+ const iconChild = pulse
909
+ ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
910
+ : iconEl
890
911
  return E('div', {
891
912
  className: cls('bw-row', currentInside && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
892
- style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}) },
913
+ style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
893
914
  onClick: onToggle,
894
915
  onContextMenu: onContextMenu,
895
916
  role: 'treeitem',
@@ -897,8 +918,8 @@ window.__ModuleLoader__.load({
897
918
  ...(dragEvents || {}),
898
919
  },
899
920
  E('span', { className: cls('bw-chevron', sessionsOpen && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
900
- E('span', { className: 'bw-row-icon' }, iconOf(iconMode, sessionsOpen)),
901
- E('span', { className: 'bw-row-label', title: workspace.title || workspace.leaf }, workspace.leaf),
921
+ E('span', { className: 'bw-row-icon' }, iconChild),
922
+ E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text'), title: workspace.title || workspace.leaf }, workspace.leaf),
902
923
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
903
924
  E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
904
925
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
@@ -906,16 +927,53 @@ window.__ModuleLoader__.load({
906
927
  )
907
928
  }
908
929
 
909
- function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, t }) {
910
- // Official status priority: pending interaction > running > running
911
- // subagents > completed reminder; idle rows show no dot at all.
912
- let status = null
913
- if (node.pending === 'approval' || node.pending === 'plan-review' || node.pending === 'question') {
914
- status = { state: 'warning', title: t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending)) }
915
- } else if (node.running || node.subagents > 0) {
916
- status = { state: 'ongoing', title: node.running ? t('status.running') : t('status.subagents', { n: node.subagents }) }
917
- } else if (node.completed) {
918
- status = { state: 'done', title: t('status.completed') }
930
+ // Official status priority: pending interaction > running > running
931
+ // subagents > completed reminder; idle rows show no dot at all.
932
+ const sessionStateOf = (row) => {
933
+ if (!row) return null
934
+ if (row.pending === 'approval' || row.pending === 'plan-review' || row.pending === 'question') return 'warning'
935
+ if (row.running || row.subagents > 0) return 'ongoing'
936
+ if (row.completed) return 'done'
937
+ return null
938
+ }
939
+
940
+ /**
941
+ * Status breathing light (preference-controlled, default ON): status dots
942
+ * hidden by a collapse bubble outward to the nearest visible container
943
+ * row. Workspace/folder rows breathe on their icon (drop-shadow glow in
944
+ * the status color; a hidden/no icon falls back to the official StateDot);
945
+ * session-group rows carry a breathing StateDot in front of the label.
946
+ * Colors mirror the official dots: warning amber > ongoing blue > done
947
+ * green, so the glow always matches the lamp it relays.
948
+ */
949
+ const PULSE_RANK = { warning: 3, ongoing: 2, done: 1 }
950
+ const PULSE_COLORS = { warning: '#d29922', ongoing: '#5b8def', done: '#3fb950' }
951
+ const pulseRank = (state) => (state ? (PULSE_RANK[state] || 0) : 0)
952
+
953
+ function PulseGlow({ state, children }) {
954
+ return E('span', {
955
+ className: 'bw-pulse',
956
+ style: { '--bw-pulse-color': PULSE_COLORS[state] || PULSE_COLORS.ongoing },
957
+ }, children)
958
+ }
959
+
960
+ // RUNNING deliberately stays on the session row itself (its official ring
961
+ // breathes blue there) and does NOT bubble outward — the user found the
962
+ // ongoing relay noisy. Only pending-amber and done-green propagate.
963
+ const relayStateOf = (row) => {
964
+ const s = sessionStateOf(row)
965
+ return s === 'ongoing' ? null : s
966
+ }
967
+
968
+ function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, breathing, t }) {
969
+ const state = sessionStateOf(node)
970
+ const status = state === null ? null : {
971
+ state,
972
+ title: state === 'warning'
973
+ ? t('status.' + (node.pending === 'plan-review' ? 'planReview' : node.pending))
974
+ : state === 'ongoing'
975
+ ? (node.running ? t('status.running') : t('status.subagents', { n: node.subagents }))
976
+ : t('status.completed'),
919
977
  }
920
978
  return E('div', {
921
979
  className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
@@ -927,7 +985,11 @@ window.__ModuleLoader__.load({
927
985
  },
928
986
  E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
929
987
  status && typeof ui.StateDot === 'function'
930
- ? E(ui.StateDot, { state: status.state, size: 10 })
988
+ ? (state === 'ongoing' && breathing
989
+ // Running breathes blue ON the session row only (never relays);
990
+ // the settings toggle covers this breathing too.
991
+ ? E(PulseGlow, { state: 'ongoing' }, E(ui.StateDot, { state: status.state, size: 10 }))
992
+ : E(ui.StateDot, { state: status.state, size: 10 }))
931
993
  : E('span', { className: 'bw-dot' }),
932
994
  ),
933
995
  E('span', { className: 'bw-row-label' }, node.leaf || node.title),
@@ -943,7 +1005,7 @@ window.__ModuleLoader__.load({
943
1005
  * titles). Deliberately NOT styled like a workspace folder — no folder
944
1006
  * icon, tertiary color — so a session level never reads as a workspace.
945
1007
  */
946
- function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, t }) {
1008
+ function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, pulse, t }) {
947
1009
  return E('div', {
948
1010
  className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
949
1011
  style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
@@ -954,6 +1016,9 @@ window.__ModuleLoader__.load({
954
1016
  ...(dragEvents || {}),
955
1017
  },
956
1018
  E('span', { className: cls('bw-chevron', expanded && 'bw-chevron-open') }, icon('IconTriangleRightFill14', 14)),
1019
+ // Session groups have no icon slot: a collapsed group with hidden
1020
+ // status relays through a breathing official StateDot instead.
1021
+ pulse ? E(PulseGlow, { state: pulse }, typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null) : null,
957
1022
  E('span', { className: 'bw-row-label' }, name),
958
1023
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
959
1024
  )
@@ -1146,7 +1211,7 @@ window.__ModuleLoader__.load({
1146
1211
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1147
1212
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1148
1213
  const compactChains = prefs.compactChains !== false
1149
- const completionToasts = prefs.completionToasts !== false
1214
+ const statusPulse = prefs.statusPulse !== false
1150
1215
  return E('div', { className: 'bw-settings' },
1151
1216
  StyleNode(),
1152
1217
  E('div', { className: 'bw-setting-row' },
@@ -1162,17 +1227,17 @@ window.__ModuleLoader__.load({
1162
1227
  ),
1163
1228
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
1164
1229
  E('div', { className: 'bw-setting-row', style: { marginTop: 10 } },
1165
- E('div', { className: 'bw-setting-label' }, t('settings.completionToasts')),
1230
+ E('div', { className: 'bw-setting-label' }, t('settings.statusPulse')),
1166
1231
  E('button', {
1167
1232
  type: 'button',
1168
1233
  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) },
1234
+ 'aria-checked': statusPulse,
1235
+ 'aria-label': t('settings.statusPulse'),
1236
+ className: cls('bw-switch', statusPulse && 'bw-switch-on'),
1237
+ onClick: () => { actions.setPref('statusPulse', !statusPulse) },
1173
1238
  }, E('span', { className: 'bw-switch-thumb' })),
1174
1239
  ),
1175
- E('div', { className: 'bw-hint' }, t('settings.completionToasts.hint')),
1240
+ E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1176
1241
  )
1177
1242
  }
1178
1243
 
@@ -1230,7 +1295,7 @@ window.__ModuleLoader__.load({
1230
1295
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1231
1296
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1232
1297
  const compactChains = prefsMap.compactChains !== false
1233
- const completionToasts = prefsMap.completionToasts !== false
1298
+ const statusPulse = prefsMap.statusPulse !== false
1234
1299
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1235
1300
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1236
1301
 
@@ -1242,8 +1307,6 @@ window.__ModuleLoader__.load({
1242
1307
  const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
1243
1308
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
1244
1309
  const [errorText, setErrorText] = React.useState(null)
1245
- const [toasts, setToasts] = React.useState([]) // completion relays: { key, title }
1246
- const toastTimers = React.useRef([])
1247
1310
  const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
1248
1311
  // Workspace drags arm their state one frame LATE (see workspaceDragEvents):
1249
1312
  // arming synchronously re-renders during the dragstart dispatch, the chain
@@ -1251,42 +1314,46 @@ window.__ModuleLoader__.load({
1251
1314
  // source element, and Chromium cancels the whole gesture. dragEnd clears
1252
1315
  // the timer so a same-tick cancel never leaves a ghost drag behind.
1253
1316
  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.
1257
- const normalizedQuery = query.trim().toLowerCase()
1258
- const now = Date.now()
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.
1317
+ // Session ids that have EVER had a durable title this mount. The
1318
+ // blank→titled transition marks the host's automatic title landing
1319
+ // (LLM provider or deterministic fallback the wire shape is the
1320
+ // same); user renames always happen AFTER landing and never re-trigger.
1321
+ // The FIRST snapshot only registers: titles that predate this mount may
1322
+ // be deliberate user groupings and must never be touched.
1323
+ const titledSeenRef = React.useRef(null)
1324
+
1325
+ // Quote-on-land: when a freshly landed automatic title contains "/",
1326
+ // wrap the WHOLE title in “…” so splitTitleSegs keeps it verbatim (the
1327
+ // AI/fallback title echoing a URL must not nest). Idempotent — quoted
1328
+ // titles and already-seen sessions are skipped — and best-effort: a
1329
+ // failed rename never retries (the unquoted URL-tail fallback in
1330
+ // splitPlainSegs still keeps the display flat).
1266
1331
  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))
1332
+ if (!list || !list.byId || typeof renameSession !== 'function') return
1333
+ let seen = titledSeenRef.current
1334
+ const first = seen === null
1335
+ if (first) { seen = new Set(); titledSeenRef.current = seen }
1336
+ const fixes = []
1337
+ for (const id of Object.keys(list.byId)) {
1338
+ const summary = list.byId[id]
1339
+ if (!summary || summary.blank) continue
1340
+ const title = summary.title
1341
+ if (!title || String(title).trim() === '') continue
1342
+ if (seen.has(id)) continue
1343
+ seen.add(id)
1344
+ if (first) continue // pre-existing on load: register only
1345
+ const text = String(title)
1346
+ if (!text.includes('/')) continue
1347
+ if (text.includes('') || text.includes('"')) continue
1348
+ fixes.push([id, '“' + text + '”'])
1287
1349
  }
1288
- }, [list, items, normalizedQuery !== '', completionToasts, sessionsExpandedMap, archivedSet])
1289
- React.useEffect(() => () => { for (const timer of toastTimers.current) clearTimeout(timer) }, [])
1350
+ if (fixes.length === 0) return
1351
+ Promise.resolve()
1352
+ .then(async () => { for (const [id, next] of fixes) await renameSession(id, next) })
1353
+ .catch(() => { /* keep the flat fallback display */ })
1354
+ }, [list])
1355
+ const normalizedQuery = query.trim().toLowerCase()
1356
+ const now = Date.now()
1290
1357
 
1291
1358
  const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
1292
1359
 
@@ -1402,6 +1469,45 @@ window.__ModuleLoader__.load({
1402
1469
  const searched = normalizedQuery ? searchAgent(tree) : null
1403
1470
  const searching = normalizedQuery !== ''
1404
1471
 
1472
+ /* --------------------- status breathing relay -------------------- */
1473
+
1474
+ // Highest-priority status across a whole session subtree (groups + rows).
1475
+ const nodePulseOf = (sessionNode) => {
1476
+ let best = null
1477
+ for (const group of sessionNode.groups || []) {
1478
+ const s = nodePulseOf(group)
1479
+ if (pulseRank(s) > pulseRank(best)) best = s
1480
+ }
1481
+ for (const row of sessionNode.sessions || []) {
1482
+ const s = relayStateOf(row)
1483
+ if (pulseRank(s) > pulseRank(best)) best = s
1484
+ }
1485
+ return best
1486
+ }
1487
+ // A COLLAPSED workspace row relays its whole session tree; an open row
1488
+ // shows the real dots (deeper collapsed groups relay on their own rows).
1489
+ const wsPulseOf = (workspace) => (!statusPulse || searching || sessionsOpenOf(workspace.workspaceId))
1490
+ ? null
1491
+ : nodePulseOf(buildSessionTree(sessionsOf(workspace)))
1492
+ // A collapsed FOLDER hides everything below — including open workspaces —
1493
+ // so its aggregation ignores inner expansion states entirely.
1494
+ const wsAllPulseOf = (workspace) => (!statusPulse || searching)
1495
+ ? null
1496
+ : nodePulseOf(buildSessionTree(sessionsOf(workspace)))
1497
+ const folderPulseOf = (node) => {
1498
+ if (node.kind === 'ws') return wsAllPulseOf(node.workspace)
1499
+ let best = null
1500
+ for (const child of node.folders) {
1501
+ const s = folderPulseOf(child)
1502
+ if (pulseRank(s) > pulseRank(best)) best = s
1503
+ }
1504
+ for (const w of node.workspaces) {
1505
+ const s = wsAllPulseOf(w)
1506
+ if (pulseRank(s) > pulseRank(best)) best = s
1507
+ }
1508
+ return best
1509
+ }
1510
+
1405
1511
  const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
1406
1512
  const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
1407
1513
  const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
@@ -1765,6 +1871,7 @@ window.__ModuleLoader__.load({
1765
1871
  name: group.name,
1766
1872
  depth,
1767
1873
  expanded: open,
1874
+ pulse: (statusPulse && !open) ? nodePulseOf(group) : null,
1768
1875
  count: countSessionTree(group),
1769
1876
  onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
1770
1877
  onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
@@ -1790,10 +1897,11 @@ window.__ModuleLoader__.load({
1790
1897
  dropHalf: workspaceId ? sessDropHalf(session.id) : null,
1791
1898
  dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
1792
1899
  custStyle: rowStyleOf('session:' + session.id),
1900
+ breathing: statusPulse,
1793
1901
  t,
1794
1902
  })
1795
1903
 
1796
- const renderWorkspaceEntry = (entry, depth) => {
1904
+ const renderWorkspaceEntry = (entry, depth, pulse) => {
1797
1905
  const { workspace } = entry
1798
1906
  const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
1799
1907
  const rows = [E(WorkspaceRow, {
@@ -1812,6 +1920,7 @@ window.__ModuleLoader__.load({
1812
1920
  dragEvents: workspaceDragEvents(workspace),
1813
1921
  custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
1814
1922
  iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
1923
+ pulse,
1815
1924
  t,
1816
1925
  })]
1817
1926
  rows.push(...renderSessionTree(workspace, depth + 1))
@@ -1819,7 +1928,7 @@ window.__ModuleLoader__.load({
1819
1928
  }
1820
1929
 
1821
1930
  const renderPlainFolder = (node, depth) => {
1822
- if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
1931
+ if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth, wsPulseOf(node.workspace))
1823
1932
  const expanded = searching || folderExpanded(node.path)
1824
1933
  const rows = [E(FolderRow, {
1825
1934
  key: 'f-' + node.path,
@@ -1832,11 +1941,12 @@ window.__ModuleLoader__.load({
1832
1941
  dragEvents: folderDropEvents(node.path),
1833
1942
  custStyle: rowStyleOf('folder:' + node.path),
1834
1943
  iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
1944
+ pulse: expanded ? null : folderPulseOf(node),
1835
1945
  t,
1836
1946
  })]
1837
1947
  if (expanded) {
1838
1948
  for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
1839
- for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1))
1949
+ for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
1840
1950
  }
1841
1951
  return rows
1842
1952
  }
@@ -1869,7 +1979,7 @@ window.__ModuleLoader__.load({
1869
1979
  }
1870
1980
  } else {
1871
1981
  for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
1872
- for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0))
1982
+ for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
1873
1983
  if (ungrouped.length > 0) {
1874
1984
  bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
1875
1985
  for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
@@ -2093,13 +2203,6 @@ window.__ModuleLoader__.load({
2093
2203
  onClose: () => setCustomize(null),
2094
2204
  t,
2095
2205
  }),
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,
2103
2206
  E(ui.Modal, {
2104
2207
  open: errorText !== null,
2105
2208
  onClose: () => setErrorText(null),