dsh-better-workspace 0.8.0 → 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
@@ -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,7 +60,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
60
60
 
61
61
  - **文件夹式收起**:点工作区行 = 收起/展开该工作区的全部会话(收起后行上保留会话数角标);会话分组同样可收起;搜索时自动全部展开。
62
62
  - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行则移动进该分组;会话行可拖拽——同工作区拖到另一会话上/下调整顺序,拖到会话子分组即移动进该分组。显示顺序 = 宿主手动序,拖拽结果立即生效。拖拽工作区期间,单链合并显示的行(如 工作/A)临时展开回文件夹树——路径里的每一级都是可投放的分组行,松手后自动合并回去。
63
- - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**完成提醒浮窗**(折叠的工作区里有会话完成时在侧边栏弹数秒非阻塞提醒,不打断输入,可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
63
+ - **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标,与官方行一致)、未分组会话兜底、中英双语跟随界面语言。
64
64
  - **URL 标题感知**:会话/工作区标题里的 URL(如 `https://host/api/v1/x`)按 URL 语义分层——域名与协议合成一段、路径各段自成层级,同前缀 URL 自动聚到同一分组下,不再被撕成伪分组。
65
65
  - **对话空态页同样生效**:会话空态页的「添加工作区」菜单走的也是本插件的拾取交互,同样带所属分组弹窗。
66
66
 
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,7 +58,7 @@ 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.
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
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.
63
63
  - **Conversation hero too**: the empty-state “Add workspace” menu uses the same picking interaction with the same group popup.
64
64
 
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.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.8.0",
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
@@ -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',
@@ -615,11 +613,10 @@ window.__ModuleLoader__.load({
615
613
  '.bw-preview-icon{flex:none;display:grid;place-items:center;width:20px;height:20px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
616
614
  '.bw-preview-icon svg{width:18px;height:18px}',
617
615
  '.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)}',
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}}',
623
620
  ].join('')
624
621
 
625
622
  const StyleNode = () => E('style', null, CSS_TEXT)
@@ -868,11 +865,17 @@ window.__ModuleLoader__.load({
868
865
  .map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
869
866
  .join('')
870
867
 
871
- 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 }) {
872
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
873
876
  return E('div', {
874
877
  className: cls('bw-row', dropInto && 'bw-drop-into'),
875
- style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}) },
878
+ style: { paddingLeft: 4 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
876
879
  onClick: onToggle,
877
880
  onContextMenu: onContextMenu,
878
881
  role: 'treeitem',
@@ -880,16 +883,21 @@ window.__ModuleLoader__.load({
880
883
  ...(dragEvents || {}),
881
884
  },
882
885
  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),
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),
885
889
  total > 0 ? E('span', { className: 'bw-row-count' }, String(total)) : null,
886
890
  )
887
891
  }
888
892
 
889
- 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
890
898
  return E('div', {
891
899
  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 || {}) },
900
+ style: { paddingLeft: 6 + depth * 12, ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
893
901
  onClick: onToggle,
894
902
  onContextMenu: onContextMenu,
895
903
  role: 'treeitem',
@@ -897,8 +905,8 @@ window.__ModuleLoader__.load({
897
905
  ...(dragEvents || {}),
898
906
  },
899
907
  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),
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),
902
910
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
903
911
  E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
904
912
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('session.new'), onClick: (e) => { e.stopPropagation(); onStart() } }, icon('IconPlusOutline16')),
@@ -906,16 +914,53 @@ window.__ModuleLoader__.load({
906
914
  )
907
915
  }
908
916
 
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') }
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'),
919
964
  }
920
965
  return E('div', {
921
966
  className: cls('bw-row', 'bw-session-row', current && 'bw-row-current', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
@@ -927,7 +972,11 @@ window.__ModuleLoader__.load({
927
972
  },
928
973
  E('span', { className: 'bw-row-icon', title: status ? status.title : undefined },
929
974
  status && typeof ui.StateDot === 'function'
930
- ? 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 }))
931
980
  : E('span', { className: 'bw-dot' }),
932
981
  ),
933
982
  E('span', { className: 'bw-row-label' }, node.leaf || node.title),
@@ -943,7 +992,7 @@ window.__ModuleLoader__.load({
943
992
  * titles). Deliberately NOT styled like a workspace folder — no folder
944
993
  * icon, tertiary color — so a session level never reads as a workspace.
945
994
  */
946
- 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 }) {
947
996
  return E('div', {
948
997
  className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into'),
949
998
  style: { paddingLeft: 10 + depth * 12, ...(custStyle || {}) },
@@ -954,6 +1003,9 @@ window.__ModuleLoader__.load({
954
1003
  ...(dragEvents || {}),
955
1004
  },
956
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,
957
1009
  E('span', { className: 'bw-row-label' }, name),
958
1010
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
959
1011
  )
@@ -1146,7 +1198,7 @@ window.__ModuleLoader__.load({
1146
1198
  function BetterWorkspaceSettings({ useStore, actions, t }) {
1147
1199
  const prefs = useStore ? (useStore(s => s.prefs) || {}) : {}
1148
1200
  const compactChains = prefs.compactChains !== false
1149
- const completionToasts = prefs.completionToasts !== false
1201
+ const statusPulse = prefs.statusPulse !== false
1150
1202
  return E('div', { className: 'bw-settings' },
1151
1203
  StyleNode(),
1152
1204
  E('div', { className: 'bw-setting-row' },
@@ -1162,17 +1214,17 @@ window.__ModuleLoader__.load({
1162
1214
  ),
1163
1215
  E('div', { className: 'bw-hint' }, t('settings.compactChains.hint')),
1164
1216
  E('div', { className: 'bw-setting-row', style: { marginTop: 10 } },
1165
- E('div', { className: 'bw-setting-label' }, t('settings.completionToasts')),
1217
+ E('div', { className: 'bw-setting-label' }, t('settings.statusPulse')),
1166
1218
  E('button', {
1167
1219
  type: 'button',
1168
1220
  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) },
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) },
1173
1225
  }, E('span', { className: 'bw-switch-thumb' })),
1174
1226
  ),
1175
- E('div', { className: 'bw-hint' }, t('settings.completionToasts.hint')),
1227
+ E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1176
1228
  )
1177
1229
  }
1178
1230
 
@@ -1230,7 +1282,7 @@ window.__ModuleLoader__.load({
1230
1282
  const prefsMap = useStore ? (useStore(s => s.prefs) || {}) : {}
1231
1283
  const stylingMap = useStore ? (useStore(s => s.styling) || {}) : {}
1232
1284
  const compactChains = prefsMap.compactChains !== false
1233
- const completionToasts = prefsMap.completionToasts !== false
1285
+ const statusPulse = prefsMap.statusPulse !== false
1234
1286
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
1235
1287
  const subCounts = React.useMemo(() => subagentRunningCounts(list ? list.byId : {}), [list ? list.byId : null])
1236
1288
 
@@ -1242,8 +1294,6 @@ window.__ModuleLoader__.load({
1242
1294
  const [ctx, setCtx] = React.useState(null) // context menu { kind, payload, x, y }
1243
1295
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
1244
1296
  const [errorText, setErrorText] = React.useState(null)
1245
- const [toasts, setToasts] = React.useState([]) // completion relays: { key, title }
1246
- const toastTimers = React.useRef([])
1247
1297
  const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
1248
1298
  // Workspace drags arm their state one frame LATE (see workspaceDragEvents):
1249
1299
  // arming synchronously re-renders during the dragstart dispatch, the chain
@@ -1251,43 +1301,9 @@ window.__ModuleLoader__.load({
1251
1301
  // source element, and Chromium cancels the whole gesture. dragEnd clears
1252
1302
  // the timer so a same-tick cancel never leaves a ghost drag behind.
1253
1303
  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
1304
  const normalizedQuery = query.trim().toLowerCase()
1258
1305
  const now = Date.now()
1259
1306
 
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
-
1291
1307
  const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
1292
1308
 
1293
1309
  const styleEntry = (key) => stylingMap[key] || null
@@ -1402,6 +1418,45 @@ window.__ModuleLoader__.load({
1402
1418
  const searched = normalizedQuery ? searchAgent(tree) : null
1403
1419
  const searching = normalizedQuery !== ''
1404
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
+
1405
1460
  const folderExpanded = (path) => (expandedMap ? expandedMap[path] !== false : true)
1406
1461
  const sessionsOpenOf = (workspaceId) => (sessionsExpandedMap ? sessionsExpandedMap[workspaceId] !== false : true)
1407
1462
  const sessionGroupOpen = (key) => (sessionGroupsMap ? sessionGroupsMap[key] !== false : true)
@@ -1765,6 +1820,7 @@ window.__ModuleLoader__.load({
1765
1820
  name: group.name,
1766
1821
  depth,
1767
1822
  expanded: open,
1823
+ pulse: (statusPulse && !open) ? nodePulseOf(group) : null,
1768
1824
  count: countSessionTree(group),
1769
1825
  onToggle: () => { if (!searching) actions.setSessionGroupExpanded(key, !open) },
1770
1826
  onContextMenu: (e) => openCtx('sgroup', { workspaceId, path: group.path, name: group.name }, e),
@@ -1790,10 +1846,11 @@ window.__ModuleLoader__.load({
1790
1846
  dropHalf: workspaceId ? sessDropHalf(session.id) : null,
1791
1847
  dragEvents: workspaceId ? sessionDragEvents(session, workspaceId) : undefined,
1792
1848
  custStyle: rowStyleOf('session:' + session.id),
1849
+ breathing: statusPulse,
1793
1850
  t,
1794
1851
  })
1795
1852
 
1796
- const renderWorkspaceEntry = (entry, depth) => {
1853
+ const renderWorkspaceEntry = (entry, depth, pulse) => {
1797
1854
  const { workspace } = entry
1798
1855
  const count = countSessionTree(buildSessionTree(sessionsOf(workspace)))
1799
1856
  const rows = [E(WorkspaceRow, {
@@ -1812,6 +1869,7 @@ window.__ModuleLoader__.load({
1812
1869
  dragEvents: workspaceDragEvents(workspace),
1813
1870
  custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
1814
1871
  iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
1872
+ pulse,
1815
1873
  t,
1816
1874
  })]
1817
1875
  rows.push(...renderSessionTree(workspace, depth + 1))
@@ -1819,7 +1877,7 @@ window.__ModuleLoader__.load({
1819
1877
  }
1820
1878
 
1821
1879
  const renderPlainFolder = (node, depth) => {
1822
- if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth)
1880
+ if (node.kind === 'ws') return renderWorkspaceEntry({ workspace: node.workspace }, depth, wsPulseOf(node.workspace))
1823
1881
  const expanded = searching || folderExpanded(node.path)
1824
1882
  const rows = [E(FolderRow, {
1825
1883
  key: 'f-' + node.path,
@@ -1832,11 +1890,12 @@ window.__ModuleLoader__.load({
1832
1890
  dragEvents: folderDropEvents(node.path),
1833
1891
  custStyle: rowStyleOf('folder:' + node.path),
1834
1892
  iconMode: (styleEntry('folder:' + node.path) || {}).icon || 'solid',
1893
+ pulse: expanded ? null : folderPulseOf(node),
1835
1894
  t,
1836
1895
  })]
1837
1896
  if (expanded) {
1838
1897
  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))
1898
+ for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
1840
1899
  }
1841
1900
  return rows
1842
1901
  }
@@ -1869,7 +1928,7 @@ window.__ModuleLoader__.load({
1869
1928
  }
1870
1929
  } else {
1871
1930
  for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
1872
- 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)))
1873
1932
  if (ungrouped.length > 0) {
1874
1933
  bodyRows.push(E('div', { key: 'ungrouped-label', className: 'bw-header-title', style: { padding: '10px 6px 2px' } }, t('group.ungrouped')))
1875
1934
  for (const s of ungrouped) bodyRows.push(renderSessionRow(s, 0))
@@ -2093,13 +2152,6 @@ window.__ModuleLoader__.load({
2093
2152
  onClose: () => setCustomize(null),
2094
2153
  t,
2095
2154
  }),
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
2155
  E(ui.Modal, {
2104
2156
  open: errorText !== null,
2105
2157
  onClose: () => setErrorText(null),