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 +3 -3
- package/README_EN.md +3 -3
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +221 -118
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
|
|
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
|
-
- **不丢原生能力**:会话行(打开/重命名/分叉/归档)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)
|
|
64
|
-
- **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), **
|
|
62
|
-
- **
|
|
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.
|
|
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.
|
|
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.
|
|
72
|
-
'settings.
|
|
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.
|
|
162
|
-
'settings.
|
|
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
|
-
*
|
|
223
|
-
* ("https:" → empty → host → path...), so
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
|
235
|
-
|
|
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
|
|
258
|
-
if (
|
|
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-
|
|
619
|
-
'
|
|
620
|
-
'
|
|
621
|
-
'
|
|
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' },
|
|
884
|
-
|
|
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' },
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
?
|
|
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
|
|
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.
|
|
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':
|
|
1170
|
-
'aria-label': t('settings.
|
|
1171
|
-
className: cls('bw-switch',
|
|
1172
|
-
onClick: () => { actions.setPref('
|
|
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.
|
|
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
|
|
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
|
-
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
//
|
|
1263
|
-
// the
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
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),
|