dsh-folder-sidebar 0.17.1 → 0.18.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +181 -180
  3. package/client.js +1837 -1766
  4. package/package.json +1 -1
package/client.js CHANGED
@@ -1,1766 +1,1837 @@
1
- /**
2
- * Pure logic for dsh-folder-sidebar.
3
- *
4
- * This block lives at module scope, outside the Client factory, so that the
5
- * exact shipped source can be exercised by `node` tests: with `window` absent
6
- * the browser bootstrap below is skipped and `module.exports` exposes these
7
- * functions. Nothing here touches React, the DOM, or a specific Cordis context.
8
- */
9
-
10
- const INDICATOR_PRIORITY = ['waiting', 'running', 'error', 'completed', 'stopped']
11
-
12
- const INDICATOR_LABEL_KEY = {
13
- running: 'status.running',
14
- completed: 'status.completed',
15
- waiting: 'status.waiting',
16
- error: 'status.error',
17
- stopped: 'status.stopped',
18
- }
19
-
20
- const GLYPH_COLOR = {
21
- waiting: 'var(--dsw-alias-state-warn-primary)',
22
- error: 'var(--dsw-alias-state-error-primary)',
23
- completed: 'var(--dsw-alias-state-success-primary)',
24
- }
25
-
26
- const SORT_MODES = ['manual', 'name', 'recent']
27
- const SORT_DIRS = ['asc', 'desc']
28
-
29
- /** Each level keeps its own mode and direction. */
30
- const SORT_KEY = 'dsh.folder-sidebar.sort'
31
-
32
- const SORT_DEFAULT = {
33
- folders: { mode: 'manual', dir: 'asc' },
34
- sessions: { mode: 'recent', dir: 'desc' },
35
- }
36
-
37
- /** A mode's natural order; picking a mode resets the direction to it. */
38
- const SORT_NATURAL_DIR = { manual: 'asc', name: 'asc', recent: 'desc' }
39
-
40
- /** Archived visibility mirrors the shipped view options. */
41
- const ARCHIVED_KEY = 'dsh.folder-sidebar.archived'
42
- const ARCHIVED_FILTERS = ['hide', 'all', 'only']
43
-
44
- /** Sentinel for the pseudo Workspace that owns Sessions outside every Workspace. */
45
- const UNGROUPED = '__ungrouped__'
46
-
47
- function safeStorage(storage) {
48
- if (storage !== undefined && storage !== null) return storage
49
- try {
50
- return globalThis.localStorage
51
- } catch {
52
- return undefined
53
- }
54
- }
55
-
56
- /** Comparator sign for a direction. */
57
- function directionSign(dir) {
58
- return dir === 'desc' ? -1 : 1
59
- }
60
-
61
- function normalizeSort(value, fallback) {
62
- const mode = value !== null && typeof value === 'object' && SORT_MODES.includes(value.mode) ? value.mode : fallback.mode
63
- const dir = value !== null && typeof value === 'object' && SORT_DIRS.includes(value.dir) ? value.dir : fallback.dir
64
- return { mode, dir }
65
- }
66
-
67
- function readSortState(storage) {
68
- const store = safeStorage(storage)
69
- try {
70
- const parsed = JSON.parse(store?.getItem(SORT_KEY) ?? 'null')
71
- return {
72
- folders: normalizeSort(parsed?.folders, SORT_DEFAULT.folders),
73
- sessions: normalizeSort(parsed?.sessions, SORT_DEFAULT.sessions),
74
- }
75
- } catch {
76
- return { folders: { ...SORT_DEFAULT.folders }, sessions: { ...SORT_DEFAULT.sessions } }
77
- }
78
- }
79
-
80
- function writeSortState(state, storage) {
81
- try {
82
- safeStorage(storage)?.setItem(SORT_KEY, JSON.stringify(state))
83
- } catch {
84
- // Storage may be unavailable; the choice still applies this session.
85
- }
86
- }
87
-
88
- function readArchivedFilter(storage) {
89
- try {
90
- const saved = safeStorage(storage)?.getItem(ARCHIVED_KEY)
91
- return ARCHIVED_FILTERS.includes(saved) ? saved : 'hide'
92
- } catch {
93
- return 'hide'
94
- }
95
- }
96
-
97
- function writeArchivedFilter(value, storage) {
98
- try {
99
- safeStorage(storage)?.setItem(ARCHIVED_KEY, value)
100
- } catch {
101
- // Storage may be unavailable; the filter still applies this session.
102
- }
103
- }
104
-
105
- /**
106
- * Order Workspaces for the folder list.
107
- * @param items - Workspaces in Host order.
108
- * @param sort - `{ mode, dir }`.
109
- * @param accessors - `nameOf(item)` and `activityOf(item)`.
110
- */
111
- function sortWorkspaces(items, sort, accessors) {
112
- const ordered = items.slice()
113
- const sign = directionSign(sort.dir)
114
- if (sort.mode === 'name') {
115
- ordered.sort((a, b) => accessors.nameOf(a).localeCompare(accessors.nameOf(b), undefined, { numeric: true }) * sign)
116
- } else if (sort.mode === 'recent') {
117
- ordered.sort((a, b) => (accessors.activityOf(a) - accessors.activityOf(b)) * sign)
118
- } else if (sign < 0) {
119
- ordered.reverse()
120
- }
121
- return ordered
122
- }
123
-
124
- /**
125
- * Order Sessions inside one Workspace. Pinned rows stay first in every mode;
126
- * the direction applies inside a group. Descending updates are what the caller
127
- * already derives, so only ascending needs a sort.
128
- */
129
- function sortSessions(rows, sort, accessors) {
130
- const ordered = rows.slice()
131
- const sign = directionSign(sort.dir)
132
- const pinOf = (row) => (accessors.isPinned(row) ? 1 : 0)
133
- if (sort.mode === 'name') {
134
- ordered.sort((a, b) => {
135
- const pin = pinOf(b) - pinOf(a)
136
- if (pin !== 0) return pin
137
- return accessors.titleOf(a).localeCompare(accessors.titleOf(b), undefined, { numeric: true }) * sign
138
- })
139
- return ordered
140
- }
141
- // Both "manual" and "recent" order Sessions by recency; the direction only
142
- // flips the comparison, so the result never depends on the caller's order.
143
- ordered.sort((a, b) => {
144
- const pin = pinOf(b) - pinOf(a)
145
- if (pin !== 0) return pin
146
- return ((accessors.updatedAtOf(a) ?? 0) - (accessors.updatedAtOf(b) ?? 0)) * sign
147
- })
148
- return ordered
149
- }
150
-
151
- /**
152
- * A Workspace shows one status: highest precedence first, then the state that
153
- * began earliest.
154
- * @param candidates - `{ kind, since }` pairs for the Workspace's Sessions.
155
- */
156
- function pickFolderKind(candidates) {
157
- let best
158
- for (const candidate of candidates) {
159
- const priority = INDICATOR_PRIORITY.indexOf(candidate.kind)
160
- if (priority < 0) continue
161
- if (best === undefined || priority < best.priority || (priority === best.priority && candidate.since < best.since)) {
162
- best = { kind: candidate.kind, priority, since: candidate.since }
163
- }
164
- }
165
- return best === undefined ? undefined : best.kind
166
- }
167
-
168
- /**
169
- * One row's leading indicator. Green follows the client's completion-unread
170
- * rule; failure comes from this bundle's projection and is not cleared by
171
- * viewing. Running wins over everything, a pending question wins over activity.
172
- */
173
- function indicatorOf(status, projection) {
174
- const waiting = status !== undefined && status !== null
175
- && status.pendingInteraction !== undefined && status.pendingInteraction !== null
176
- if (waiting) return 'waiting'
177
- const running = status !== undefined && status !== null && status.running === true
178
- if (running) return 'running'
179
- const state = projection === undefined || projection === null ? undefined : projection.state
180
- if (state === 'failed' || state === 'interrupted') return 'error'
181
- if (status !== undefined && status !== null && status.completionUnread === true) return 'completed'
182
- if (state === 'running') return 'running'
183
- if (state === 'stopped') return 'stopped'
184
- return undefined
185
- }
186
-
187
- function relativeTime(ts, now = Date.now()) {
188
- if (typeof ts !== 'number' || !Number.isFinite(ts)) return ''
189
- const delta = now - ts
190
- if (delta < 60_000) return '<1m'
191
- if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m`
192
- if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h`
193
- if (delta < 7 * 86_400_000) return `${Math.floor(delta / 86_400_000)}d`
194
- const date = new Date(ts)
195
- return `${date.getMonth() + 1}/${date.getDate()}`
196
- }
197
-
198
- function baseName(path) {
199
- if (typeof path !== 'string' || path === '') return ''
200
- const parts = path.split(/[\\/]+/).filter(Boolean)
201
- return parts.length > 0 ? parts[parts.length - 1] : path
202
- }
203
-
204
- /**
205
- * Web Client half of dsh-folder-sidebar.
206
- *
207
- * Replaces the sidebar's `sidebar.workspaces` region (shipped browser:
208
- * ui-workspace) with a drill-down "folder" view:
209
- * 1. folder list — one row per Workspace; the folder icon itself is that
210
- * Workspace's single status cell, showing its oldest Session state;
211
- * 2. enter a folder — only that Workspace's Sessions, each with its own
212
- * state colour, a "…" menu, inline rename, and a back row.
213
- * A search field switches the region to a flat result list across Workspaces.
214
- *
215
- * Data comes from the slot's standard projection props (`useWorkspaces`,
216
- * `useSessions`, `useSessionStatus`); actions go through Client services
217
- * (`uiWorkspace`, `workspaces`, `sessions`). Failure state is read from this
218
- * bundle's Host projection (`folder-sidebar/turn`), because a Session summary
219
- * carries no failure field.
220
- */
221
-
222
- if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
223
- window.__ModuleLoader__.load({
224
- id: 'dsh-folder-sidebar',
225
- factory(require) {
226
- const React = require('react')
227
- const h = React.createElement
228
-
229
- const NS = 'folder-sidebar'
230
-
231
- /** Host projection key: this bundle's per-Session last-turn state. */
232
- const TURN_KEY = 'folder-sidebar/turn'
233
-
234
- const DICTS = {
235
- zh: {
236
- 'section.title': '工作区',
237
- 'action.back': '全部工作区',
238
- 'action.newSession': '新会话',
239
- 'action.addWorkspace': '添加工作区',
240
- 'action.search': '搜索会话',
241
- 'action.close': '关闭',
242
- 'action.menu': '更多操作',
243
- 'action.pin': '置顶',
244
- 'action.unpin': '取消置顶',
245
- 'action.rename': '重命名',
246
- 'action.archive': '归档',
247
- 'action.fork': '分叉',
248
- 'action.copyId': '复制会话 ID',
249
- 'action.renameWorkspace': '重命名工作区',
250
- 'action.deleteWorkspace': '删除工作区',
251
- 'action.newSessionHere': '在此新建会话',
252
- 'action.moveUp': '上移',
253
- 'action.moveDown': '下移',
254
- 'action.sort': '排序',
255
- 'level.folders': '文件夹',
256
- 'level.sessions': '会话',
257
- 'sort.manual': '手动顺序',
258
- 'sort.name': '按名称',
259
- 'sort.recent': '按最近活动',
260
- 'sort.asc': '正序',
261
- 'sort.desc': '倒序',
262
- 'confirm.archive': '该会话仍在运行,停止并归档?',
263
- 'confirm.deleteWorkspace': '删除这个工作区?会话与文件不会被删除。',
264
- 'confirm.yes': '确认',
265
- 'confirm.cancel': '取消',
266
- 'empty.workspaces': '还没有工作区',
267
- 'empty.sessions': '这个工作区还没有会话',
268
- 'empty.search': '没有匹配的会话',
269
- 'empty.archived': '暂无已归档会话',
270
- 'group.ungrouped': '未分组',
271
- 'action.undo': '撤销',
272
- 'action.unarchive': '取消归档',
273
- 'action.view': '视图选项',
274
- 'view.hide': '隐藏已归档',
275
- 'view.all': '全部会话',
276
- 'view.only': '仅显示已归档',
277
- 'notice.archived': '已归档',
278
- 'notice.archivedNotOpenable': '已归档,取消归档后才能打开',
279
- 'notice.pinFailed': '置顶操作失败',
280
- 'notice.archiveFailed': '归档失败',
281
- 'notice.unarchiveFailed': '取消归档失败',
282
- 'notice.renameFailed': '重命名失败',
283
- 'notice.forkFailed': '分叉失败',
284
- 'notice.moveFailed': '调整顺序失败',
285
- 'notice.deleteFailed': '删除工作区失败',
286
- 'notice.addWorkspaceFailed': '添加工作区失败',
287
- 'session.untitled': '未命名会话',
288
- 'copied': '已复制',
289
- 'unit.sessions': '个会话',
290
- 'rail.expand': '展开侧栏',
291
- 'search.placeholder': '搜索会话…',
292
- 'search.content': '内容匹配',
293
- 'status.running': '运行中',
294
- 'status.completed': '已完成',
295
- 'status.error': '失败或中断',
296
- 'status.waiting': '等待你的回答',
297
- 'status.waitingApproval': '等待审批',
298
- 'status.waitingPlanReview': '等待计划确认',
299
- 'status.waitingAnswer': '等待回答',
300
- 'status.stopped': '已停止',
301
- 'status.idle': '空闲',
302
- },
303
- en: {
304
- 'section.title': 'Workspaces',
305
- 'action.back': 'All workspaces',
306
- 'action.newSession': 'New session',
307
- 'action.addWorkspace': 'Add workspace',
308
- 'action.search': 'Search sessions',
309
- 'action.close': 'Close',
310
- 'action.menu': 'More actions',
311
- 'action.pin': 'Pin',
312
- 'action.unpin': 'Unpin',
313
- 'action.rename': 'Rename',
314
- 'action.archive': 'Archive',
315
- 'action.fork': 'Fork',
316
- 'action.copyId': 'Copy session ID',
317
- 'action.renameWorkspace': 'Rename workspace',
318
- 'action.deleteWorkspace': 'Delete workspace',
319
- 'action.newSessionHere': 'New session here',
320
- 'action.moveUp': 'Move up',
321
- 'action.moveDown': 'Move down',
322
- 'action.sort': 'Sort',
323
- 'level.folders': 'folders',
324
- 'level.sessions': 'sessions',
325
- 'sort.manual': 'Manual order',
326
- 'sort.name': 'By name',
327
- 'sort.recent': 'By recent activity',
328
- 'sort.asc': 'Ascending',
329
- 'sort.desc': 'Descending',
330
- 'confirm.archive': 'This session is still running. Stop it and archive?',
331
- 'confirm.deleteWorkspace': 'Delete this workspace? Sessions and files are kept.',
332
- 'confirm.yes': 'Confirm',
333
- 'confirm.cancel': 'Cancel',
334
- 'empty.workspaces': 'No workspaces yet',
335
- 'empty.sessions': 'No sessions in this workspace yet',
336
- 'empty.search': 'No matching sessions',
337
- 'empty.archived': 'No archived sessions',
338
- 'group.ungrouped': 'Ungrouped',
339
- 'action.undo': 'Undo',
340
- 'action.unarchive': 'Unarchive',
341
- 'action.view': 'View options',
342
- 'view.hide': 'Hide archived',
343
- 'view.all': 'All sessions',
344
- 'view.only': 'Archived only',
345
- 'notice.archived': 'Archived',
346
- 'notice.archivedNotOpenable': 'Archived — unarchive it to open',
347
- 'notice.pinFailed': 'Pin failed',
348
- 'notice.archiveFailed': 'Archive failed',
349
- 'notice.unarchiveFailed': 'Unarchive failed',
350
- 'notice.renameFailed': 'Rename failed',
351
- 'notice.forkFailed': 'Fork failed',
352
- 'notice.moveFailed': 'Reorder failed',
353
- 'notice.deleteFailed': 'Deleting the workspace failed',
354
- 'notice.addWorkspaceFailed': 'Adding the workspace failed',
355
- 'session.untitled': 'Untitled session',
356
- 'copied': 'Copied',
357
- 'unit.sessions': 'sessions',
358
- 'rail.expand': 'Expand sidebar',
359
- 'search.placeholder': 'Search sessions…',
360
- 'search.content': 'content match',
361
- 'status.running': 'Running',
362
- 'status.completed': 'Completed',
363
- 'status.error': 'Failed or interrupted',
364
- 'status.waiting': 'Waiting for your answer',
365
- 'status.waitingApproval': 'Waiting for approval',
366
- 'status.waitingPlanReview': 'Waiting for plan review',
367
- 'status.waitingAnswer': 'Waiting for your answer',
368
- 'status.stopped': 'Stopped',
369
- 'status.idle': 'Idle',
370
- },
371
- }
372
-
373
- /**
374
- * Precedence for a Workspace's single status cell, matching the Session row
375
- * order: an actionable question outranks activity, a failure outranks an
376
- * unread result. Ties inside one kind fall back to the oldest state.
377
- */
378
- const CSS = `
379
- .dfs-root{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%;font-size:13px;color:var(--dsw-alias-label-primary);padding:4px 8px 10px}
380
- .dfs-head{display:flex;align-items:center;gap:4px;flex:none;box-sizing:border-box;height:32px;margin:0 2px 8px;padding:0 2px}
381
- .dfs-topbar{position:relative;display:flex;align-items:center;flex:none;box-sizing:border-box;height:32px;margin:0 2px 8px}
382
- .dfs-topbar > .dfs-head{flex:1 1 auto;min-width:0;height:100%;margin:0;padding:0 2px;opacity:1;visibility:visible;transition:opacity .15s ease,visibility 0s linear 0s}
383
- .dfs-topbar > .dfs-search{position:absolute;inset:0;height:100%;margin:0;opacity:0;visibility:hidden;pointer-events:none;transform:scaleX(.94);transform-origin:right center;transition:opacity .15s ease,transform .18s ease,visibility 0s linear .18s}
384
- .dfs-topbar.is-search > .dfs-head{opacity:0;pointer-events:none;visibility:hidden;transition:opacity .15s ease,visibility 0s linear .15s}
385
- .dfs-topbar.is-search > .dfs-search{opacity:1;visibility:visible;pointer-events:auto;transform:none;transition:opacity .15s ease,transform .18s ease,visibility 0s linear 0s}
386
- @media (prefers-reduced-motion: reduce){.dfs-topbar > .dfs-head,.dfs-topbar > .dfs-search{transition:none}}
387
- .dfs-headTitle{flex:1;min-width:0;font-size:12px;font-weight:500;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
388
- .dfs-headBack{width:20px;height:20px;font-size:14px;color:var(--dsw-alias-label-secondary)}
389
- .dfs-iconBtn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none}
390
- .dfs-iconBtn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
391
- .dfs-iconBtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
392
- .dfs-search{display:flex;align-items:center;flex:none;gap:8px;box-sizing:border-box;height:32px;margin:0 2px 8px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);transition:border-color .12s ease}
393
-
394
- .dfs-search:focus-within{border-color:var(--dsw-alias-brand-primary)}
395
- .dfs-search input{flex:1;min-width:0;padding:0;border:0;outline:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px}
396
- .dfs-search input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.75}
397
- .dfs-crumb{display:flex;align-items:center;flex:none;gap:8px;box-sizing:border-box;height:28px;margin:0 2px 2px;padding:0 8px;border-radius:7px;cursor:pointer}
398
- .dfs-crumb:hover{background:var(--dsw-alias-bg-layer-2)}
399
- .dfs-crumb:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
400
- .dfs-crumbChevron{flex:none;width:12px;text-align:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
401
- .dfs-crumb .dfs-folderGlyph{display:inline-flex;align-items:center}
402
- .dfs-crumb .dfs-iconBtn{width:20px;height:20px}
403
- .dfs-crumbName{flex:1;min-width:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
404
- .dfs-list{display:flex;flex-direction:column;flex:1 1 auto;gap:2px;overflow-y:auto;min-height:0;padding-bottom:2px}
405
- .dfs-rowWrap{position:relative;display:flex;flex-direction:column}
406
- .dfs-rowWrap.is-drop-before:before,.dfs-rowWrap.is-drop-after:after{content:"";position:absolute;left:4px;right:4px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary);z-index:5;pointer-events:none}
407
- .dfs-rowWrap.is-drop-before:before{top:-1px}
408
- .dfs-rowWrap.is-drop-after:after{bottom:-1px}
409
- .dfs-rowWrap[draggable=true]{cursor:grab}
410
- .dfs-row{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:5px 8px;border:0;border-radius:7px;background:transparent;color:inherit;font:inherit;line-height:1.3;text-align:left;cursor:pointer}
411
- .dfs-row:hover{background:var(--dsw-alias-bg-layer-2)}
412
- .dfs-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
413
- .dfs-row.is-current{background:var(--dsw-alias-bg-layer-1)}
414
- .dfs-row.is-current .dfs-title{font-weight:600}
415
- .dfs-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
416
- .dfs-snippet{flex:1 1 100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
417
- .dfs-time{flex:none;font-size:11px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
418
- .dfs-status{display:inline-flex;align-items:center;justify-content:center;flex:none;width:18px;height:18px}
419
- .dfs-statusDot{position:relative;width:12px;height:12px;border-radius:50%;color:var(--dsw-alias-state-idle-primary)}
420
- .dfs-statusDot:after{content:"";position:absolute;inset:20%;border-radius:50%;background:currentColor}
421
- .dfs-statusDot.is-none{color:var(--dsw-alias-state-idle-primary);opacity:.55}
422
- .dfs-statusDot.is-completed{color:var(--dsw-alias-state-success-primary)}
423
- .dfs-statusDot.is-waiting{color:var(--dsw-alias-state-warn-primary)}
424
- .dfs-statusDot.is-error{color:var(--dsw-alias-state-error-primary)}
425
- .dfs-statusDot.is-stopped{color:var(--dsw-alias-state-idle-primary)}
426
- .dfs-statusDot.is-stopped:after{background:transparent;inset:15%;border:1.5px solid currentColor}
427
- .dfs-spinner{width:14px;height:14px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-label-secondary);animation:dfs-spin .8s linear infinite}
428
- @keyframes dfs-spin{to{transform:rotate(360deg)}}
429
- @media (prefers-reduced-motion: reduce){.dfs-spinner{animation:none;border-top-color:var(--dsw-alias-label-secondary);opacity:.85}}
430
- .dfs-statusFolder{width:18px}
431
- .dfs-glyph{font-size:15px;font-weight:700;line-height:1}
432
- .dfs-folderGlyph{display:inline-flex;align-items:center;justify-content:center}
433
- .dfs-badge{flex:none;min-width:16px;text-align:center;font-size:11px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
434
- .dfs-rowActions{display:inline-flex;align-items:center;gap:2px;flex:none;opacity:0;transition:opacity .12s ease}
435
- .dfs-rowWrap:hover .dfs-rowActions,.dfs-rowActions.is-open{opacity:1}
436
- .dfs-pin{flex:none;font-size:10px;color:var(--dsw-alias-brand-primary)}
437
- .dfs-input{flex:1;min-width:0;height:20px;padding:0 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
438
- .dfs-menu{position:fixed;z-index:40;width:168px;padding:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-overlay);box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:1px}
439
- .dfs-menuItem{display:flex;align-items:center;gap:4px;width:100%;box-sizing:border-box;padding:5px 8px;border:0;border-radius:5px;background:transparent;color:inherit;font:inherit;font-size:12px;text-align:left;cursor:pointer}
440
- .dfs-menuCheck{flex:none;width:16px;text-align:center;color:var(--dsw-alias-brand-primary)}
441
- .dfs-menuLabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
442
- .dfs-menuItem:hover{background:var(--dsw-alias-bg-layer-2)}
443
- .dfs-menuItem.is-danger{color:var(--dsw-alias-state-error-primary)}
444
- .dfs-menuDivider{height:1px;margin:4px 2px;background:var(--dsw-alias-border-l1)}
445
- .dfs-menuCaption{padding:3px 8px 1px;font-size:11px;line-height:1.4;color:var(--dsw-alias-label-secondary);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
446
- .dfs-confirm{display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}
447
- .dfs-confirmText{flex:1;min-width:0;line-height:1.4}
448
- .dfs-miniBtn{padding:2px 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;cursor:pointer}
449
- .dfs-miniBtn.is-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
450
- .dfs-miniBtn:hover{background:var(--dsw-alias-bg-layer-2)}
451
- .dfs-empty{padding:10px 8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
452
- .dfs-row.is-archived{opacity:.55}
453
- .dfs-notice{display:flex;align-items:center;gap:8px;margin:6px 2px 0;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:1.4}
454
- .dfs-noticeText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}
455
- .dfs-noticeAction{flex:none;padding:0 2px;border:0;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;cursor:pointer}
456
- .dfs-noticeAction:hover{text-decoration:underline}
457
- .dfs-hover{position:fixed;z-index:39;min-width:150px;max-width:260px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-overlay);box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:2px;pointer-events:none}
458
- .dfs-hoverRow{display:flex;align-items:center;gap:6px;font-size:12px;line-height:1.4;color:var(--dsw-alias-label-secondary)}
459
- .dfs-hoverRow .dfs-status{width:18px;height:16px}
460
- .dfs-hoverLabel{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
461
- .dfs-hoverSpacer{width:16px;flex:none}
462
- .dfs-rail{display:flex;flex-direction:column;align-items:center;padding-top:8px}
463
- `
464
-
465
- // Icons below reproduce the shipped `ui-primitives` geometry (16×16 grid,
466
- // stroke width 1 for the "Regular" weight) instead of importing the package.
467
- function iconFrame(children, options) {
468
- const size = options?.size ?? 16
469
- return h('svg', {
470
- width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
471
- xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true,
472
- strokeWidth: 1,
473
- style: { flex: 'none', color: options?.color ?? 'currentColor' },
474
- }, children)
475
- }
476
-
477
- /** IconFolderCloseRegular */
478
- function FolderIcon(options) {
479
- return iconFrame([
480
- h('path', {
481
- key: 'body',
482
- d: 'M1.50439 3.11059C1.50439 2.55831 1.95211 2.1106 2.50439 2.1106H5.43389C5.67773 2.1106 5.91318 2.19969 6.09593 2.36113L7.71649 3.79265C7.89924 3.95409 8.1347 4.04319 8.3785 4.04319H13.4958C14.0481 4.04319 14.4958 4.4909 14.4958 5.04319V12.8894C14.4958 13.4417 14.0481 13.8894 13.4958 13.8894H2.50439C1.95211 13.8894 1.50439 13.4417 1.50439 12.8894V4.04319V3.11059Z',
483
- stroke: 'currentColor',
484
- }),
485
- h('path', { key: 'line', d: 'M3.63501 7.66614H12.3647', stroke: 'currentColor' }),
486
- ], { size: options?.size ?? 16, color: options?.color })
487
- }
488
-
489
- /** IconTriangleRightFillRegular — the shipped row arrow. */
490
- function ChevronIcon() {
491
- return iconFrame(h('path', {
492
- d: 'M5.5 4.5C5.5 4.40714 5.52586 4.31612 5.57467 4.23713C5.62349 4.15815 5.69334 4.09431 5.77639 4.05279C5.85945 4.01126 5.95242 3.99368 6.0449 4.00202C6.13738 4.01036 6.22572 4.04429 6.3 4.1L10.967 7.6C11.0291 7.64657 11.0795 7.70697 11.1142 7.77639C11.1489 7.84582 11.167 7.92238 11.167 8C11.167 8.07762 11.1489 8.15418 11.1142 8.22361C11.0795 8.29303 11.0291 8.35343 10.967 8.4L6.3 11.9C6.22572 11.9557 6.13738 11.9896 6.0449 11.998C5.95242 12.0063 5.85945 11.9887 5.77639 11.9472C5.69334 11.9057 5.62349 11.8419 5.57467 11.7629C5.52586 11.6839 5.5 11.5929 5.5 11.5V4.5Z',
493
- fill: 'currentColor',
494
- }), { size: 14, color: 'var(--dsw-alias-label-secondary)' })
495
- }
496
-
497
- /** IconChevronLeftOutlineRegular */
498
- function BackIcon() {
499
- return iconFrame(h('path', {
500
- d: 'M10 4L6.70711 7.29289C6.31658 7.68342 6.31658 8.31658 6.70711 8.70711L10 12',
501
- stroke: 'currentColor',
502
- }), { size: 14, color: 'var(--dsw-alias-label-secondary)' })
503
- }
504
-
505
- /** IconEllipsisOutlineRegular */
506
- function DotsIcon() {
507
- return iconFrame([
508
- h('path', { key: 'a', d: 'M3 9C3.55228 9 4 8.55228 4 8C4 7.44772 3.55228 7 3 7C2.44772 7 2 7.44772 2 8C2 8.55228 2.44772 9 3 9Z', fill: 'currentColor' }),
509
- h('path', { key: 'b', d: 'M8 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z', fill: 'currentColor' }),
510
- h('path', { key: 'c', d: 'M13 9C13.5523 9 14 8.55228 14 8C14 7.44772 13.5523 7 13 7C12.4477 7 12 7.44772 12 8C12 8.55228 12.4477 9 13 9Z', fill: 'currentColor' }),
511
- ], { size: 16 })
512
- }
513
-
514
- /** IconSearchOutlineRegular */
515
- function SearchIcon() {
516
- return iconFrame([
517
- h('path', {
518
- key: 'lens',
519
- d: 'M6.58727 11.8586C9.55061 11.8586 11.9529 9.45637 11.9529 6.49304C11.9529 3.5297 9.55061 1.12744 6.58727 1.12744C3.62394 1.12744 1.22168 3.5297 1.22168 6.49304C1.22168 9.45637 3.62394 11.8586 6.58727 11.8586Z',
520
- stroke: 'currentColor',
521
- }),
522
- h('path', { key: 'handle', d: 'M10.2991 10.3933L14.7783 14.8725', stroke: 'currentColor' }),
523
- ], { size: 16, color: 'var(--dsw-alias-label-secondary)' })
524
- }
525
-
526
- /** IconSlidersTwoOutlineRegular — the shipped view-options glyph. */
527
- function SlidersIcon() {
528
- return iconFrame([
529
- h('path', { key: 'l1', d: 'M2.3 5h5.85M12.05 5h1.65', stroke: 'currentColor' }),
530
- h('circle', { key: 'c1', cx: 9.95, cy: 5, r: 1.45, stroke: 'currentColor' }),
531
- h('path', { key: 'l2', d: 'M2.3 11h1.65M7.85 11h5.85', stroke: 'currentColor' }),
532
- h('circle', { key: 'c2', cx: 5.75, cy: 11, r: 1.45, stroke: 'currentColor' }),
533
- ], { size: 16 })
534
- }
535
-
536
- /** IconPinFillRegular */
537
- function PinIcon() {
538
- return iconFrame([
539
- h('path', {
540
- key: 'body',
541
- d: 'M9.96976 1.70572L13.1554 3.93629L10.9019 8.12317L11.5158 11.605L10.7192 12.7427L2.52767 7.00693L3.3243 5.86922L6.80612 5.25528L9.96976 1.70572Z',
542
- fill: 'currentColor', stroke: 'currentColor', strokeLinejoin: 'round',
543
- }),
544
- h('path', {
545
- key: 'needle',
546
- d: 'M6.05285 9.47511C6.27284 9.16094 6.70586 9.08458 7.02003 9.30457C7.3342 9.52455 7.41055 9.95757 7.19057 10.2717L3.98587 14.4708L3.21223 13.9291L6.05285 9.47511Z',
547
- fill: 'currentColor',
548
- }),
549
- ], { size: 14 })
550
- }
551
-
552
- /** IconCloseFillRegular */
553
- function CloseIcon() {
554
- return iconFrame([
555
- h('path', { key: 'a', d: 'M3.5 3.5L12.5 12.5', stroke: 'currentColor' }),
556
- h('path', { key: 'b', d: 'M12.5 3.5L3.5 12.5', stroke: 'currentColor' }),
557
- ], { size: 14 })
558
- }
559
-
560
- /** IconNewChatOutlineRegular */
561
- function NewChatIcon() {
562
- return iconFrame([
563
- h('path', {
564
- key: 'bubble',
565
- d: 'M2.37091 11.2501C1.58745 9.89288 1.32067 8.29835 1.61969 6.76006C1.91872 5.22177 2.76342 3.8433 3.99826 2.87846C5.2331 1.91362 6.77494 1.42737 8.33988 1.50925C9.90482 1.59113 11.3875 2.23562 12.5149 3.32406C13.6425 4.41269 14.3387 5.87206 14.4754 7.4334C14.612 8.99474 14.18 10.5529 13.2587 11.8209C12.3375 13.0888 10.9891 13.9813 9.46194 14.3337C8.18691 14.628 6.85895 14.5294 5.64989 14.0605C5.1712 13.8748 4.76962 13.4932 4.26534 13.3967C3.67413 13.2835 2.95257 13.5598 2.03794 14.3337',
566
- stroke: 'currentColor',
567
- }),
568
- h('path', { key: 'v', d: 'M8 5V11', stroke: 'currentColor' }),
569
- h('path', { key: 'h', d: 'M5 8H11', stroke: 'currentColor' }),
570
- ], { size: 16 })
571
- }
572
-
573
- /** IconProjectAddOutlineRegular — add-workspace. */
574
- function ProjectAddIcon() {
575
- return iconFrame([
576
- h('path', {
577
- key: 'body',
578
- d: 'M5.54492 2.06738C5.91034 2.06754 6.26318 2.20149 6.53711 2.44336L7.94043 3.68164V4.7998C7.71462 4.74105 7.50367 4.63139 7.32617 4.47461L5.87598 3.19238C5.78477 3.11185 5.66658 3.06754 5.54492 3.06738H2.94922C2.67322 3.06738 2.44946 3.29145 2.44922 3.56738V12.4326C2.44927 12.7087 2.67311 12.9326 2.94922 12.9326H12.9326C13.2086 12.9325 13.4326 12.7086 13.4326 12.4326V8.53613H14.4326V12.4326C14.4326 13.2609 13.7609 13.9325 12.9326 13.9326H2.94922C2.12083 13.9326 1.44927 13.261 1.44922 12.4326V3.56738C1.44946 2.73916 2.12094 2.06738 2.94922 2.06738H5.54492Z',
579
- fill: 'currentColor',
580
- }),
581
- h('path', { key: 'h', d: 'M9.75977 4.50208H14.5509', stroke: 'currentColor' }),
582
- h('path', { key: 'v', d: 'M12.1492 6.89758L12.1492 2.10642', stroke: 'currentColor' }),
583
- ], { size: 16 })
584
- }
585
-
586
- /** IconCheckOutlineRegular — completed. */
587
- function CheckIcon() {
588
- return iconFrame(h('path', {
589
- d: 'M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4',
590
- stroke: 'currentColor',
591
- }), { size: 14 })
592
- }
593
-
594
- /** IconQuestionOutlineRegular — waiting for you. */
595
- function QuestionIcon() {
596
- return iconFrame([
597
- h('path', {
598
- key: 'circle',
599
- d: 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
600
- stroke: 'currentColor',
601
- }),
602
- h('path', {
603
- key: 'curve',
604
- d: 'M5.75 6.69646C5.75 6.29865 5.88196 5.90976 6.12919 5.57899C6.37643 5.24821 6.72783 4.99041 7.13896 4.83817C7.5501 4.68593 8.0025 4.6461 8.43895 4.72371C8.87541 4.80132 9.27632 4.99289 9.59099 5.27419C9.90566 5.55549 10.12 5.91388 10.2068 6.30406C10.2936 6.69423 10.249 7.09866 10.0787 7.4662C9.90843 7.83373 9.62004 8.14787 9.25003 8.36889C9.19476 8.4019 9.13803 8.43262 9.08004 8.46099C8.52566 8.73217 8 9.20817 8 9.82532',
605
- stroke: 'currentColor',
606
- }),
607
- h('path', { key: 'dot', d: 'M8 10.7416V11.7416', stroke: 'currentColor' }),
608
- ], { size: 14 })
609
- }
610
-
611
- /** IconWarningOutlineRegular — failure or interruption. */
612
- function WarningIcon() {
613
- return iconFrame([
614
- h('path', {
615
- key: 'circle',
616
- d: 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
617
- stroke: 'currentColor',
618
- }),
619
- h('path', { key: 'bar', d: 'M8 4.29199V9.79199', stroke: 'currentColor' }),
620
- h('path', { key: 'dot', d: 'M8 10.708V11.708', stroke: 'currentColor' }),
621
- ], { size: 14 })
622
- }
623
-
624
- class RenderBoundary extends React.Component {
625
- constructor(props) {
626
- super(props)
627
- this.state = { message: null }
628
- }
629
-
630
- static getDerivedStateFromError(error) {
631
- return { message: error instanceof Error ? error.message : String(error) }
632
- }
633
-
634
- componentDidCatch(error) {
635
- console.error('[folder-sidebar] render failed', error)
636
- }
637
-
638
- render() {
639
- if (this.state.message !== null) {
640
- return h('div', {
641
- className: 'dfs-empty',
642
- style: { color: 'var(--dsw-alias-state-error-primary)' },
643
- }, 'folder-sidebar: ', this.state.message)
644
- }
645
- return this.props.children
646
- }
647
- }
648
-
649
-
650
-
651
-
652
- return {
653
- inject: ['slots', 'locale', 'uiWorkspace', 'workspaces', 'sessions'],
654
- apply(ctx) {
655
- ctx.effect(() => ctx.locale.register(NS, DICTS))
656
- const t = ctx.locale.bind(NS)
657
-
658
- /**
659
- * The leading status cell: a fixed 16px slot on every Session row so
660
- * titles stay aligned whether or not the row has a state. Completed,
661
- * waiting and failure reuse the shipped glyphs; running is the spinner.
662
- */
663
- function statusIcon(kind) {
664
- if (kind === 'completed') return CheckIcon
665
- if (kind === 'waiting') return QuestionIcon
666
- if (kind === 'error') return WarningIcon
667
- return undefined
668
- }
669
-
670
- function Indicator({ kind, title }) {
671
- const Glyph = statusIcon(kind)
672
- if (Glyph !== undefined) {
673
- return h('span', {
674
- className: 'dfs-status dfs-glyph',
675
- style: { color: GLYPH_COLOR[kind] },
676
- title,
677
- 'aria-label': title,
678
- role: 'img',
679
- }, h(Glyph))
680
- }
681
- if (kind === undefined) {
682
- return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
683
- h('span', { className: 'dfs-statusDot is-none' }))
684
- }
685
- if (kind === 'running') {
686
- return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
687
- h('span', { className: 'dfs-spinner' }))
688
- }
689
- return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
690
- h('span', { className: `dfs-statusDot is-${kind}` }))
691
- }
692
-
693
- /** A Workspace row's single status cell: the folder icon itself. */
694
- function FolderGlyph({ kind, label }) {
695
- const color = kind === 'error' ? 'var(--dsw-alias-state-error-primary)'
696
- : kind === 'waiting' ? 'var(--dsw-alias-state-warn-primary)'
697
- : kind === 'completed' ? 'var(--dsw-alias-state-success-primary)'
698
- : kind === 'stopped' ? 'var(--dsw-alias-state-idle-primary)'
699
- : 'var(--dsw-alias-label-secondary)'
700
- const Glyph = statusIcon(kind)
701
- if (Glyph !== undefined) {
702
- return h('span', {
703
- className: 'dfs-status dfs-statusFolder dfs-glyph',
704
- style: { color: GLYPH_COLOR[kind] },
705
- title: label,
706
- 'aria-label': label,
707
- role: 'img',
708
- }, h(Glyph))
709
- }
710
- if (kind === 'running') {
711
- return h('span', {
712
- className: 'dfs-status dfs-statusFolder',
713
- title: label,
714
- 'aria-label': label,
715
- role: 'img',
716
- }, h('span', { className: 'dfs-spinner' }))
717
- }
718
- return h('span', {
719
- className: 'dfs-status dfs-statusFolder',
720
- title: label,
721
- 'aria-label': label,
722
- role: 'img',
723
- }, h('span', { className: 'dfs-folderGlyph' }, h(FolderIcon, { color })))
724
- }
725
-
726
- function FolderSidebar(props) {
727
- const wide = props.wide !== false
728
- const expandSidebar = props.expandSidebar
729
- const useWorkspaces = props.useWorkspaces
730
- const useSessions = props.useSessions
731
- const useSessionStatus = props.useSessionStatus
732
-
733
- const [openId, setOpenId] = React.useState(null)
734
- const [query, setQuery] = React.useState('')
735
- const [menuFor, setMenuFor] = React.useState(null)
736
- const [rename, setRename] = React.useState(null)
737
- const [confirm, setConfirm] = React.useState(null)
738
- const [archivedFilter, setArchivedFilter] = React.useState(() => readArchivedFilter())
739
- const [notice, setNotice] = React.useState(null)
740
- const [contentHits, setContentHits] = React.useState([])
741
- const [searching, setSearching] = React.useState(false)
742
- const [hoverFor, setHoverFor] = React.useState(null)
743
- const [searchOpen, setSearchOpen] = React.useState(false)
744
- const searchInput = React.useRef(null)
745
- const hoverTimer = React.useRef(undefined)
746
- const [sortState, setSortState] = React.useState(() => readSortState())
747
- const folderSort = sortState.folders
748
- const sessionSort = sortState.sessions
749
-
750
- const workspaceItems = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.items) : undefined
751
- const archivedIds = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.archivedSessionIds) : undefined
752
- const pinnedIds = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.pinnedSessionIds) : undefined
753
- const list = typeof useSessions === 'function' ? useSessions((state) => state) : undefined
754
- const projections = typeof useSessions === 'function' ? useSessions((state) => state.projectionsBySession) : undefined
755
- const statusMap = typeof useSessionStatus === 'function' ? useSessionStatus((state) => state) : undefined
756
-
757
- const workspaces = Array.isArray(workspaceItems) ? workspaceItems : []
758
- const archived = React.useMemo(() => new Set(archivedIds ?? []), [archivedIds])
759
- const pinned = React.useMemo(() => new Set(pinnedIds ?? []), [pinnedIds])
760
-
761
- const statusOf = React.useCallback((sessionId) => {
762
- if (statusMap === undefined || statusMap === null) return undefined
763
- return typeof statusMap.get === 'function' ? statusMap.get(sessionId) : undefined
764
- }, [statusMap])
765
-
766
- const projectionOf = React.useCallback((sessionId) => {
767
- if (projections === undefined || projections === null) return undefined
768
- const cell = projections[sessionId]
769
- const values = cell === undefined || cell === null ? undefined : cell.values
770
- return values === undefined || values === null ? undefined : values[TURN_KEY]
771
- }, [projections])
772
-
773
- const currentId = React.useMemo(() => {
774
- const byId = list === undefined || list === null ? undefined : list.byId
775
- if (byId === undefined || byId === null) return undefined
776
- for (const id of Object.keys(byId)) {
777
- const summary = byId[id]
778
- if (summary !== null && typeof summary === 'object' && (summary.retainedBy?.mainView ?? 0) > 0) return id
779
- }
780
- return undefined
781
- }, [list])
782
-
783
- const currentWorkspaceId = React.useMemo(() => {
784
- if (currentId === undefined) return undefined
785
- for (const workspace of workspaces) {
786
- if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(currentId)) return workspace.workspaceId
787
- }
788
- return undefined
789
- }, [workspaces, currentId])
790
-
791
- React.useEffect(() => {
792
- if (currentWorkspaceId === undefined) return
793
- setOpenId((previous) => (previous === null ? currentWorkspaceId : previous))
794
- }, [currentWorkspaceId])
795
-
796
- // Close transient UI when the region loses the selection it belongs to.
797
- React.useEffect(() => {
798
- if (menuFor === null) return undefined
799
- const onDown = (event) => {
800
- const target = event.target
801
- if (target === null || typeof target.closest !== 'function') { setMenuFor(null); return }
802
- // A press inside the panel must reach the item; a press on the
803
- // trigger is the trigger's own toggle. Anything else dismisses.
804
- const insideMenu = target.closest('[data-dfs-menu]') !== null
805
- const onTrigger = target.closest('[data-dfs-menu-trigger]') !== null
806
- if (!insideMenu && !onTrigger) setMenuFor(null)
807
- }
808
- const onKey = (event) => { if (event.key === 'Escape') setMenuFor(null) }
809
- const onScroll = () => setMenuFor(null)
810
- document.addEventListener('pointerdown', onDown)
811
- document.addEventListener('keydown', onKey)
812
- document.addEventListener('scroll', onScroll, true)
813
- return () => {
814
- document.removeEventListener('pointerdown', onDown)
815
- document.removeEventListener('keydown', onKey)
816
- document.removeEventListener('scroll', onScroll, true)
817
- }
818
- }, [menuFor])
819
-
820
- /** One transient bar; an optional action keeps "撤销" reachable. */
821
- const notify = (message, action) => setNotice({ message, action: action ?? null })
822
-
823
- React.useEffect(() => {
824
- if (notice === null) return undefined
825
- const timer = setTimeout(() => setNotice(null), notice.action === null ? 2200 : 6000)
826
- return () => clearTimeout(timer)
827
- }, [notice])
828
-
829
- // Host content search, debounced; titles stay local and instant.
830
- React.useEffect(() => {
831
- const text = query.trim()
832
- if (text === '' || typeof ctx.sessions?.search !== 'function') {
833
- setContentHits([])
834
- setSearching(false)
835
- return undefined
836
- }
837
- const controller = new AbortController()
838
- setSearching(true)
839
- const timer = setTimeout(() => {
840
- Promise.resolve(ctx.sessions.search(text, controller.signal))
841
- .then((result) => {
842
- if (controller.signal.aborted) return
843
- const items = result?.ok === true && Array.isArray(result.value?.items) ? result.value.items : []
844
- setContentHits(items)
845
- setSearching(false)
846
- })
847
- .catch(() => { if (!controller.signal.aborted) { setContentHits([]); setSearching(false) } })
848
- }, 250)
849
- return () => {
850
- clearTimeout(timer)
851
- controller.abort()
852
- setSearching(false)
853
- }
854
- }, [query])
855
-
856
- /** Sessions that belong to no Workspace (shipped "Ungrouped" bucket). */
857
- const ungroupedWorkspace = React.useMemo(() => {
858
- const byId = list === undefined || list === null ? undefined : list.byId
859
- const ids = list === undefined || list === null || !Array.isArray(list.ids) ? [] : list.ids
860
- const accounted = new Set()
861
- for (const workspace of workspaces) {
862
- for (const id of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) accounted.add(id)
863
- }
864
- const members = []
865
- for (const id of ids) {
866
- const summary = byId === undefined || byId === null ? undefined : byId[id]
867
- if (summary === null || typeof summary !== 'object') continue
868
- if (accounted.has(id)) continue
869
- if (summary.origin === 'subagent') continue
870
- members.push(summary)
871
- }
872
- return { workspaceId: UNGROUPED, path: '', title: t('group.ungrouped'), sessionIds: members.map((summary) => summary.id) }
873
- }, [list, workspaces, t])
874
-
875
- /**
876
- * Rows per Workspace, derived once per render instead of once per
877
- * caller: the list, its status cell and the hover card all read it.
878
- */
879
- const rowsIndex = React.useMemo(() => {
880
- const byId = list === undefined || list === null ? undefined : list.byId
881
- const collect = (ids) => {
882
- const rows = []
883
- for (const id of Array.isArray(ids) ? ids : []) {
884
- const summary = byId === undefined || byId === null ? undefined : byId[id]
885
- if (summary === null || typeof summary !== 'object') continue
886
- if (summary.origin === 'subagent') continue
887
- const isArchived = archived.has(id)
888
- if (archivedFilter === 'hide' && isArchived) continue
889
- if (archivedFilter === 'only' && !isArchived) continue
890
- if (summary.blank === true && id !== currentId) continue
891
- rows.push(summary)
892
- }
893
- rows.sort((a, b) => {
894
- const pinDelta = (pinned.has(b.id) ? 1 : 0) - (pinned.has(a.id) ? 1 : 0)
895
- if (pinDelta !== 0) return pinDelta
896
- return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
897
- })
898
- return rows
899
- }
900
- const index = new Map()
901
- for (const workspace of workspaces) index.set(workspace.workspaceId, collect(workspace.sessionIds))
902
- index.set(UNGROUPED, collect(ungroupedWorkspace.sessionIds))
903
- return index
904
- }, [list, archived, pinned, currentId, archivedFilter, workspaces, ungroupedWorkspace])
905
-
906
- const rowsFor = (workspace) => rowsIndex.get(workspace.workspaceId) ?? []
907
-
908
-
909
- const workspaceOf = React.useCallback((sessionId) => {
910
- for (const workspace of workspaces) {
911
- if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) return workspace
912
- }
913
- return undefined
914
- }, [workspaces])
915
-
916
- const titleOf = React.useCallback((summary) => {
917
- return typeof summary.displayTitle === 'string' && summary.displayTitle !== ''
918
- ? summary.displayTitle
919
- : t('session.untitled')
920
- }, [t])
921
-
922
- const displayNameOf = (workspace) => (typeof workspace.title === 'string' && workspace.title !== ''
923
- ? workspace.title
924
- : baseName(workspace.path))
925
-
926
- /** Newest Session activity in a Workspace, used by the "recent" sort. */
927
- const lastActivityOf = (workspace) => {
928
- const byId = list === undefined || list === null ? undefined : list.byId
929
- let latest = 0
930
- for (const id of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) {
931
- const summary = byId === undefined || byId === null ? undefined : byId[id]
932
- const time = summary === undefined || summary === null ? undefined : summary.updatedAt
933
- if (typeof time === 'number' && time > latest) latest = time
934
- }
935
- return latest
936
- }
937
-
938
- const orderedWorkspaces = React.useMemo(
939
- () => sortWorkspaces(workspaces, folderSort, { nameOf: displayNameOf, activityOf: lastActivityOf }),
940
- [workspaces, folderSort, list],
941
- )
942
-
943
- /** Pinned rows stay first in every mode; direction applies inside groups. */
944
- /** Session ordering for the active level (pure comparator lives up top). */
945
- const orderSessions = (rows) => sortSessions(rows, sessionSort, {
946
- titleOf,
947
- isPinned: (row) => pinned.has(row.id),
948
- updatedAtOf: (row) => row.updatedAt,
949
- })
950
-
951
- /** The level in view owns the sort: folders outside, Sessions inside. */
952
- const activeLevel = openId === null ? 'folders' : 'sessions'
953
- const activeSort = sortState[activeLevel]
954
-
955
- // Folder drag-and-drop: only meaningful when the Host order is the
956
- // rendered order (manual + ascending) and the flat list is visible.
957
- const [dragId, setDragId] = React.useState(null)
958
- const [dropEdge, setDropEdge] = React.useState(null)
959
- const canReorder = folderSort.mode === 'manual' && folderSort.dir === 'asc'
960
- && openId === null && query.trim() === ''
961
- const clearDrag = () => {
962
- setDragId(null)
963
- setDropEdge(null)
964
- }
965
-
966
- React.useEffect(() => {
967
- if (!canReorder) clearDrag()
968
- }, [canReorder])
969
-
970
- /** Land one Workspace before a Host-order anchor (`undefined` appends). */
971
- const dropOn = (targetId, edge) => {
972
- if (dragId === null || dragId === targetId) { clearDrag(); return }
973
- const ids = workspaces.map((workspace) => workspace.workspaceId)
974
- const targetIndex = ids.indexOf(targetId)
975
- if (targetIndex < 0) { clearDrag(); return }
976
- const beforeId = edge === 'before' ? ids[targetIndex] : ids[targetIndex + 1]
977
- // Dropping onto the neighbour it already precedes is a no-op.
978
- if (beforeId !== dragId) moveWorkspace(dragId, beforeId)
979
- clearDrag()
980
- }
981
-
982
- const commitSort = (patch) => {
983
- const next = {
984
- ...sortState,
985
- [activeLevel]: { ...sortState[activeLevel], ...patch },
986
- }
987
- setSortState(next)
988
- writeSortState(next)
989
- setMenuFor(null)
990
- }
991
-
992
- const chooseMode = (mode) => commitSort({ mode, dir: SORT_NATURAL_DIR[mode] })
993
-
994
- const modeItems = () => SORT_MODES.map((mode) => ({
995
- key: `mode:${mode}`,
996
- label: t(`sort.${mode}`),
997
- check: mode === activeSort.mode,
998
- onSelect: () => chooseMode(mode),
999
- }))
1000
-
1001
- const dirItems = () => SORT_DIRS.map((dir) => ({
1002
- key: `dir:${dir}`,
1003
- label: `${t(`sort.${dir}`)} ${dir === 'desc' ? '↓' : '↑'}`,
1004
- check: dir === activeSort.dir,
1005
- onSelect: () => commitSort({ dir }),
1006
- }))
1007
-
1008
- const folderKindOf = React.useCallback((workspace) => {
1009
- const candidates = []
1010
- for (const summary of rowsFor(workspace)) {
1011
- const kind = indicatorOf(statusOf(summary.id), projectionOf(summary.id))
1012
- if (kind === undefined) continue
1013
- const projection = projectionOf(summary.id)
1014
- const since = projection !== undefined && projection !== null
1015
- && typeof projection.since === 'number' && projection.since > 0
1016
- ? projection.since
1017
- : (summary.updatedAt ?? 0)
1018
- candidates.push({ kind, since })
1019
- }
1020
- return pickFolderKind(candidates)
1021
- }, [rowsFor, statusOf, projectionOf])
1022
-
1023
- const labels = {}
1024
- for (const kind of INDICATOR_PRIORITY) labels[kind] = t(INDICATOR_LABEL_KEY[kind])
1025
-
1026
- /** The shipped browser names the exact interaction; mirror that. */
1027
- const waitingLabel = (status) => {
1028
- const kind = status !== undefined && status !== null && status.pendingInteraction !== undefined && status.pendingInteraction !== null
1029
- ? status.pendingInteraction.kind
1030
- : undefined
1031
- if (kind === 'approval') return t('status.waitingApproval')
1032
- if (kind === 'plan-review') return t('status.waitingPlanReview')
1033
- if (kind === 'question') return t('status.waitingAnswer')
1034
- return t('status.waiting')
1035
- }
1036
-
1037
- /** Every status one Session carries, for the hover card. */
1038
- const statusRowsOf = (status, projection, updatedAt) => {
1039
- const rows = []
1040
- if (status !== undefined && status !== null
1041
- && status.pendingInteraction !== undefined && status.pendingInteraction !== null) {
1042
- rows.push({ key: 'waiting', kind: 'waiting', label: waitingLabel(status) })
1043
- }
1044
- if (status !== undefined && status !== null && status.running === true) {
1045
- rows.push({ key: 'running', kind: 'running', label: labels.running })
1046
- }
1047
- const state = projection === undefined || projection === null ? undefined : projection.state
1048
- if (state === 'failed' || state === 'interrupted') rows.push({ key: 'error', kind: 'error', label: labels.error })
1049
- if (status !== undefined && status !== null && status.completionUnread === true) {
1050
- rows.push({ key: 'completed', kind: 'completed', label: labels.completed })
1051
- }
1052
- if (state === 'stopped') rows.push({ key: 'stopped', kind: 'stopped', label: labels.stopped })
1053
- if (rows.length === 0) rows.push({ key: 'idle', kind: undefined, label: t('status.idle') })
1054
- rows.push({ key: 'time', kind: undefined, label: relativeTime(updatedAt) })
1055
- return rows
1056
- }
1057
-
1058
- /** Which states a Workspace contains and how many Sessions carry each. */
1059
- const folderStatusRows = (workspace) => {
1060
- const counts = new Map()
1061
- for (const summary of rowsFor(workspace)) {
1062
- const kind = indicatorOf(statusOf(summary.id), projectionOf(summary.id))
1063
- if (kind === undefined) continue
1064
- counts.set(kind, (counts.get(kind) ?? 0) + 1)
1065
- }
1066
- const rows = []
1067
- for (const kind of INDICATOR_PRIORITY) {
1068
- const count = counts.get(kind)
1069
- if (count === undefined) continue
1070
- rows.push({ key: kind, kind, label: `${labels[kind]} ×${count}` })
1071
- }
1072
- if (rows.length === 0) rows.push({ key: 'idle', kind: undefined, label: t('status.idle') })
1073
- return rows
1074
- }
1075
-
1076
- const cancelHover = () => {
1077
- if (hoverTimer.current !== undefined) {
1078
- clearTimeout(hoverTimer.current)
1079
- hoverTimer.current = undefined
1080
- }
1081
- setHoverFor(null)
1082
- }
1083
-
1084
- const scheduleHover = (key, rows, event) => {
1085
- if (menuFor !== null || dragId !== null) return
1086
- if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current)
1087
- const rect = event.currentTarget.getBoundingClientRect()
1088
- hoverTimer.current = setTimeout(() => {
1089
- hoverTimer.current = undefined
1090
- const width = 200
1091
- const height = rows.length * 20 + 16
1092
- const viewportW = typeof window === 'undefined' ? 1280 : window.innerWidth
1093
- const viewportH = typeof window === 'undefined' ? 800 : window.innerHeight
1094
- setHoverFor({
1095
- key,
1096
- rows,
1097
- left: Math.max(8, Math.min(rect.left + 8, viewportW - width - 8)),
1098
- top: Math.max(8, Math.min(rect.bottom + 6, viewportH - height - 8)),
1099
- })
1100
- }, 350)
1101
- }
1102
-
1103
- React.useEffect(() => () => {
1104
- if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current)
1105
- }, [])
1106
-
1107
- React.useEffect(() => {
1108
- if (hoverFor === null) return undefined
1109
- const onScroll = () => setHoverFor(null)
1110
- document.addEventListener('scroll', onScroll, true)
1111
- return () => document.removeEventListener('scroll', onScroll, true)
1112
- }, [hoverFor])
1113
-
1114
- const openSession = (sessionId) => {
1115
- setMenuFor(null)
1116
- setQuery('')
1117
- setSearchOpen(false)
1118
- if (typeof ctx.uiWorkspace?.openSession === 'function') ctx.uiWorkspace.openSession(sessionId)
1119
- }
1120
-
1121
- const startSession = (workspaceId) => {
1122
- setMenuFor(null)
1123
- if (typeof ctx.uiWorkspace?.startSession === 'function') ctx.uiWorkspace.startSession(workspaceId)
1124
- }
1125
-
1126
- const addWorkspace = () => {
1127
- const pickDirectory = ctx.uiWorkspace?.pickDirectory
1128
- if (typeof pickDirectory !== 'function') return
1129
- Promise.resolve(pickDirectory.call(ctx.uiWorkspace))
1130
- .then((path) => {
1131
- if (typeof path !== 'string' || path === '') return undefined
1132
- return ctx.workspaces?.create?.({ path })
1133
- })
1134
- .catch((error) => reportFailure(error, 'notice.addWorkspaceFailed'))
1135
- }
1136
-
1137
- /** Every failed action is both logged and shown; silence hides bugs. */
1138
- const reportFailure = (error, messageKey) => {
1139
- console.warn('[folder-sidebar] action failed', error)
1140
- notify(t(messageKey))
1141
- }
1142
-
1143
- const run = (promise, messageKey) => {
1144
- Promise.resolve(promise).catch((error) => reportFailure(error, messageKey))
1145
- }
1146
-
1147
- const togglePin = (sessionId) => {
1148
- setMenuFor(null)
1149
- const call = pinned.has(sessionId) ? ctx.uiWorkspace?.unpinSession : ctx.uiWorkspace?.pinSession
1150
- if (typeof call !== 'function') return
1151
- run(call.call(ctx.uiWorkspace, sessionId), 'notice.pinFailed')
1152
- }
1153
-
1154
- const archiveNow = (sessionId, stopActivity) => {
1155
- setConfirm(null)
1156
- setMenuFor(null)
1157
- const archive = ctx.uiWorkspace?.archiveSession
1158
- if (typeof archive !== 'function') return
1159
- Promise.resolve(archive.call(ctx.uiWorkspace, sessionId, stopActivity === true ? { stopActivity: true } : undefined))
1160
- .then(() => notify(t('notice.archived'), { label: t('action.undo'), onSelect: () => unarchive(sessionId) }))
1161
- .catch((error) => {
1162
- const code = error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : ''
1163
- // The Host refuses a session with running work unless told to stop it.
1164
- if (code.includes('session-active') || String(error?.message ?? '').includes('session-active')) setConfirm({ kind: 'archive', sessionId })
1165
- else reportFailure(error, 'notice.archiveFailed')
1166
- })
1167
- }
1168
-
1169
- const fork = (sessionId) => {
1170
- setMenuFor(null)
1171
- const call = ctx.uiWorkspace?.forkSession
1172
- if (typeof call === 'function') run(call.call(ctx.uiWorkspace, sessionId), 'notice.forkFailed')
1173
- }
1174
-
1175
- const copyId = (sessionId) => {
1176
- setMenuFor(null)
1177
- const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
1178
- if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
1179
- Promise.resolve(clipboard.writeText(sessionId))
1180
- .then(() => notify(t('copied')))
1181
- .catch(() => {})
1182
- }
1183
-
1184
- const startRenameSession = (summary) => {
1185
- setMenuFor(null)
1186
- setRename({ kind: 'session', id: summary.id, value: titleOf(summary) })
1187
- }
1188
-
1189
- const startRenameWorkspace = (workspace) => {
1190
- setMenuFor(null)
1191
- setRename({ kind: 'workspace', id: workspace.workspaceId, value: workspace.title || baseName(workspace.path) })
1192
- }
1193
-
1194
- const commitRename = () => {
1195
- const draft = rename
1196
- if (draft === null) return
1197
- setRename(null)
1198
- const value = typeof draft.value === 'string' ? draft.value.trim() : ''
1199
- if (value === '') return
1200
- if (draft.kind === 'workspace') {
1201
- run(ctx.workspaces?.rename?.(draft.id, value), 'notice.renameFailed')
1202
- return
1203
- }
1204
- const sessions = ctx.sessions
1205
- if (sessions === undefined || typeof sessions.using !== 'function') return
1206
- Promise.resolve(sessions.using(draft.id, { source: 'workspaceOperation' }, (reference) => reference.binding.session.rename(value)))
1207
- .then((result) => {
1208
- if (result !== null && typeof result === 'object' && result.ok === false) throw new Error(result.error?.message ?? 'rename failed')
1209
- })
1210
- .catch((error) => reportFailure(error, 'notice.renameFailed'))
1211
- }
1212
-
1213
- const deleteWorkspace = (workspaceId) => {
1214
- setConfirm(null)
1215
- setMenuFor(null)
1216
- const remove = ctx.workspaces?.delete
1217
- if (typeof remove !== 'function') return
1218
- Promise.resolve(remove.call(ctx.workspaces, workspaceId))
1219
- .then(() => setOpenId(null))
1220
- .catch((error) => reportFailure(error, 'notice.deleteFailed'))
1221
- }
1222
-
1223
- /** Move one Workspace before another; `undefined` appends. */
1224
- const moveWorkspace = (workspaceId, beforeId) => {
1225
- setMenuFor(null)
1226
- const insertBefore = ctx.workspaces?.insertBefore
1227
- if (typeof insertBefore !== 'function') return
1228
- run(insertBefore.call(ctx.workspaces, workspaceId, beforeId), 'notice.moveFailed')
1229
- }
1230
-
1231
- // ----- rename input -----
1232
- const renameInput = (draft) => h('input', {
1233
- key: 'rename',
1234
- className: 'dfs-input',
1235
- value: draft.value,
1236
- autoFocus: true,
1237
- onClick: (event) => event.stopPropagation(),
1238
- onContextMenu: (event) => event.stopPropagation(),
1239
- onChange: (event) => setRename({ ...draft, value: event.target.value }),
1240
- onKeyDown: (event) => {
1241
- if (event.key === 'Enter') { event.preventDefault(); commitRename() }
1242
- if (event.key === 'Escape') { event.preventDefault(); setRename(null) }
1243
- },
1244
- })
1245
-
1246
- // ----- menu -----
1247
- // Rendered once at the root as a fixed layer: a panel inside the
1248
- // scrolling list would be clipped at the list's edge.
1249
- const menuEl = menuFor === null ? null : h('div', {
1250
- className: 'dfs-menu',
1251
- 'data-dfs-menu': '1',
1252
- role: 'menu',
1253
- style: { left: menuFor.left, top: menuFor.top },
1254
- }, menuFor.items.map((item) => (item.kind === 'divider'
1255
- ? h('div', { key: item.key, className: 'dfs-menuDivider', role: 'separator' })
1256
- : item.kind === 'caption'
1257
- ? h('div', { key: item.key, className: 'dfs-menuCaption', role: 'presentation' }, item.label)
1258
- : h('button', {
1259
- key: item.key,
1260
- type: 'button',
1261
- role: item.check === undefined ? 'menuitem' : 'menuitemradio',
1262
- 'aria-checked': item.check === undefined ? undefined : item.check,
1263
- className: `dfs-menuItem${item.danger === true ? ' is-danger' : ''}`,
1264
- onClick: (event) => { event.stopPropagation(); item.onSelect() },
1265
- },
1266
- item.check === undefined
1267
- ? item.label
1268
- : [
1269
- h('span', { key: 'check', className: 'dfs-menuCheck', 'aria-hidden': true }, item.check ? '✓' : ''),
1270
- h('span', { key: 'label', className: 'dfs-menuLabel' }, item.label),
1271
- ]))))
1272
-
1273
- const noticeEl = () => h('div', { className: 'dfs-notice', role: 'status' },
1274
- h('span', { className: 'dfs-noticeText' }, notice.message),
1275
- notice.action === null ? null : h('button', {
1276
- key: 'action',
1277
- type: 'button',
1278
- className: 'dfs-noticeAction',
1279
- onClick: () => { notice.action.onSelect(); setNotice(null) },
1280
- }, notice.action.label))
1281
-
1282
- const hoverEl = hoverFor === null ? null : h('div', {
1283
- className: 'dfs-hover',
1284
- role: 'tooltip',
1285
- style: { left: hoverFor.left, top: hoverFor.top },
1286
- }, hoverFor.rows.map((row) => h('div', { key: row.key, className: 'dfs-hoverRow' },
1287
- row.kind === undefined
1288
- ? h('span', { className: 'dfs-hoverSpacer' })
1289
- : h(Indicator, { kind: row.kind, title: row.label }),
1290
- h('span', { className: 'dfs-hoverLabel' }, row.label))))
1291
-
1292
- /** Place the menu with the pointer or a trigger rect, kept in the viewport. */
1293
- const showMenu = (key, items, x, y) => {
1294
- cancelHover()
1295
- const width = 168
1296
- const height = items.length * 26 + 10
1297
- const viewportW = typeof window === 'undefined' ? 1280 : window.innerWidth
1298
- const viewportH = typeof window === 'undefined' ? 800 : window.innerHeight
1299
- setMenuFor({
1300
- key,
1301
- items,
1302
- left: Math.max(8, Math.min(x, viewportW - width - 8)),
1303
- top: Math.max(8, Math.min(y, viewportH - height - 8)),
1304
- })
1305
- }
1306
-
1307
- const toggleMenuAt = (key, items, event) => {
1308
- event.stopPropagation()
1309
- if (menuFor !== null && menuFor.key === key) { setMenuFor(null); return }
1310
- const rect = event.currentTarget.getBoundingClientRect()
1311
- showMenu(key, items, rect.right - 168, rect.bottom + 4)
1312
- }
1313
-
1314
- const contextMenuHandler = (key, items) => (event) => {
1315
- event.preventDefault()
1316
- event.stopPropagation()
1317
- showMenu(key, items, event.clientX, event.clientY)
1318
- }
1319
-
1320
- const menuTrigger = (key, items) => h('button', {
1321
- type: 'button',
1322
- className: 'dfs-iconBtn',
1323
- 'data-dfs-menu-trigger': '1',
1324
- title: t('action.menu'),
1325
- 'aria-label': t('action.menu'),
1326
- 'aria-expanded': menuFor !== null && menuFor.key === key,
1327
- onClick: (event) => toggleMenuAt(key, items, event),
1328
- }, DotsIcon())
1329
-
1330
- if (!wide) {
1331
- return h('div', { className: 'dfs-rail' },
1332
- h('button', {
1333
- type: 'button', className: 'dfs-iconBtn', title: t('rail.expand'),
1334
- 'aria-label': t('rail.expand'),
1335
- onClick: () => { if (typeof expandSidebar === 'function') expandSidebar() },
1336
- }, FolderIcon()))
1337
- }
1338
-
1339
- const body = [h('style', { key: 'style' }, CSS)]
1340
-
1341
- // ----- search results replace both levels while a query exists -----
1342
- const text = query.trim()
1343
- if (text !== '') {
1344
- const lower = text.toLowerCase()
1345
- const byId = list === undefined || list === null ? undefined : list.byId
1346
- const seen = new Set()
1347
- const hits = []
1348
- if (byId !== undefined && byId !== null) {
1349
- for (const id of Object.keys(byId)) {
1350
- const summary = byId[id]
1351
- if (summary === null || typeof summary !== 'object') continue
1352
- const isArchivedHit = archived.has(id)
1353
- if (archivedFilter === 'hide' && isArchivedHit) continue
1354
- if (archivedFilter === 'only' && !isArchivedHit) continue
1355
- const workspace = workspaceOf(id) ?? ungroupedWorkspace
1356
- const title = titleOf(summary)
1357
- if (!title.toLowerCase().includes(lower)) continue
1358
- seen.add(id)
1359
- hits.push({ sessionId: id, title, workspace, snippet: undefined })
1360
- }
1361
- }
1362
- for (const item of contentHits) {
1363
- if (!seen.has(item.sessionId)) {
1364
- const summary = byId === undefined || byId === null ? undefined : byId[item.sessionId]
1365
- const workspace = workspaceOf(item.sessionId) ?? ungroupedWorkspace
1366
- const isArchivedHit = archived.has(item.sessionId)
1367
- if (summary === undefined) continue
1368
- if (archivedFilter === 'hide' && isArchivedHit) continue
1369
- if (archivedFilter === 'only' && !isArchivedHit) continue
1370
- seen.add(item.sessionId)
1371
- hits.push({ sessionId: item.sessionId, title: titleOf(summary), workspace, snippet: item.snippet })
1372
- }
1373
- }
1374
- const resultRows = hits.slice(0, 30).map((hit) => h('div', { key: hit.sessionId, className: 'dfs-rowWrap' },
1375
- h('div', {
1376
- className: 'dfs-row', role: 'button', tabIndex: 0,
1377
- onClick: () => openSession(hit.sessionId),
1378
- onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSession(hit.sessionId) } },
1379
- },
1380
- h('span', { className: 'dfs-title' }, hit.title),
1381
- h('span', { className: 'dfs-time' }, baseName(hit.workspace.path) || t('group.ungrouped')),
1382
- hit.snippet === undefined ? null : h('span', { className: 'dfs-snippet' }, `${t('search.content')}: ${hit.snippet}`))))
1383
- body.push(topBar())
1384
- body.push(h('div', { key: 'list', className: 'dfs-list' },
1385
- resultRows.length === 0 ? [h('div', { key: 'empty', className: 'dfs-empty' }, t('empty.search'))] : resultRows))
1386
- return h('div', { className: 'dfs-root' }, body)
1387
- }
1388
-
1389
- /** Shared top bar for both levels: optional back, title, actions. */
1390
- function headerBar(key, options) {
1391
- const parts = []
1392
- if (options.back === true) {
1393
- parts.push(h('button', {
1394
- key: 'back',
1395
- type: 'button',
1396
- className: 'dfs-iconBtn dfs-headBack',
1397
- title: t('action.back'),
1398
- 'aria-label': t('action.back'),
1399
- onClick: () => { setOpenId(null); setQuery('') },
1400
- }, h(BackIcon)))
1401
- }
1402
- parts.push(options.titleNode ?? h('span', { key: 'title', className: 'dfs-headTitle', title: options.title }, options.title))
1403
- for (const action of options.actions ?? []) parts.push(action)
1404
- return h('div', { key, className: 'dfs-head' }, parts)
1405
- }
1406
-
1407
- /** The search bar that overlays the header while expanded. */
1408
- function searchBar(key) {
1409
- return h('div', { key, className: 'dfs-search dfs-searchHeader', 'aria-hidden': searchOpen ? undefined : 'true' },
1410
- h(SearchIcon, null),
1411
- h('input', {
1412
- ref: searchInput,
1413
- tabIndex: searchOpen ? 0 : -1,
1414
- value: query,
1415
- placeholder: t('search.placeholder'),
1416
- 'aria-label': t('action.search'),
1417
- onChange: (event) => setQuery(event.target.value),
1418
- onKeyDown: (event) => { if (event.key === 'Escape') closeSearch() },
1419
- }),
1420
- h('button', {
1421
- key: 'close',
1422
- type: 'button',
1423
- tabIndex: searchOpen ? 0 : -1,
1424
- className: 'dfs-iconBtn',
1425
- title: t('action.close'),
1426
- 'aria-label': t('action.close'),
1427
- onClick: () => closeSearch(),
1428
- }, h(CloseIcon)))
1429
- }
1430
-
1431
- const closeSearch = () => {
1432
- setSearchOpen(false)
1433
- setQuery('')
1434
- }
1435
-
1436
- React.useEffect(() => {
1437
- if (!searchOpen) return undefined
1438
- const node = searchInput.current
1439
- if (node !== null && typeof node.focus === 'function') node.focus()
1440
- return undefined
1441
- }, [searchOpen])
1442
-
1443
- /** The fixed top bar: identical on both levels, a home for actions. */
1444
- function mainHeader(key) {
1445
- return headerBar(key, {
1446
- title: t('section.title'),
1447
- actions: [
1448
- h('button', {
1449
- key: 'search',
1450
- type: 'button',
1451
- className: 'dfs-iconBtn',
1452
- title: t('action.search'),
1453
- 'aria-label': t('action.search'),
1454
- onClick: (event) => { event.stopPropagation(); setSearchOpen(true) },
1455
- }, SearchIcon()),
1456
- h('button', {
1457
- key: 'view',
1458
- type: 'button',
1459
- className: 'dfs-iconBtn',
1460
- title: `${t('action.view')} · ${t(`sort.${activeSort.mode}`)} ${activeSort.dir === 'desc' ? '↓' : '↑'} · ${t(`view.${archivedFilter}`)}`,
1461
- 'aria-label': t('action.view'),
1462
- onClick: (event) => {
1463
- event.stopPropagation()
1464
- const rect = event.currentTarget.getBoundingClientRect()
1465
- showMenu('view', viewMenuItems(), rect.right - 168, rect.bottom + 4)
1466
- },
1467
- }, h(SlidersIcon)),
1468
- h('button', {
1469
- key: 'add',
1470
- type: 'button', className: 'dfs-iconBtn', title: t('action.addWorkspace'),
1471
- 'aria-label': t('action.addWorkspace'), onClick: addWorkspace,
1472
- }, h(ProjectAddIcon)),
1473
- ],
1474
- })
1475
- }
1476
-
1477
- /** One top bar: header and search overlay share a row so the switch animates. */
1478
- function topBar() {
1479
- return h('div', { key: 'topbar', className: 'dfs-topbar' + (searchOpen ? ' is-search' : '') },
1480
- mainHeader('head'),
1481
- searchBar('searchbar'))
1482
- }
1483
-
1484
- const unarchive = (sessionId) => {
1485
- setMenuFor(null)
1486
- const call = ctx.uiWorkspace?.unarchiveSession
1487
- if (typeof call !== 'function') return
1488
- Promise.resolve(call.call(ctx.uiWorkspace, sessionId))
1489
- .catch((error) => reportFailure(error, 'notice.unarchiveFailed'))
1490
- }
1491
-
1492
- const applyArchivedFilter = (value) => {
1493
- setArchivedFilter(value)
1494
- writeArchivedFilter(value)
1495
- setMenuFor(null)
1496
- }
1497
-
1498
- /** One view-options menu: ordering for the level in view, then visibility. */
1499
- const viewMenuItems = () => ([
1500
- { key: 'caption:sort', kind: 'caption', label: `${t('action.sort')} · ${activeLevel === 'folders' ? t('level.folders') : t('level.sessions')}` },
1501
- ...modeItems(),
1502
- ...dirItems(),
1503
- { key: 'divider', kind: 'divider' },
1504
- { key: 'caption:view', kind: 'caption', label: t('action.view') },
1505
- ...ARCHIVED_FILTERS.map((value) => ({
1506
- key: `view:${value}`,
1507
- label: t(`view.${value}`),
1508
- check: value === archivedFilter,
1509
- onSelect: () => applyArchivedFilter(value),
1510
- })),
1511
- ])
1512
-
1513
- const openWorkspace = openId === null
1514
- ? undefined
1515
- : (openId === UNGROUPED
1516
- ? ungroupedWorkspace
1517
- : workspaces.find((workspace) => workspace.workspaceId === openId))
1518
-
1519
- if (openWorkspace === undefined) {
1520
- const listRows = []
1521
- orderedWorkspaces.forEach((workspace) => {
1522
- const rows = rowsFor(workspace)
1523
- if (archivedFilter === 'only' && rows.length === 0) return
1524
- const name = displayNameOf(workspace)
1525
- const isCurrent = workspace.workspaceId === currentWorkspaceId
1526
- const renaming = rename !== null && rename.kind === 'workspace' && rename.id === workspace.workspaceId
1527
- const menuKey = `workspace:${workspace.workspaceId}`
1528
- const folderKind = folderKindOf(workspace)
1529
- const hostIndex = workspaces.indexOf(workspace)
1530
- const moveItems = []
1531
- if (folderSort.mode === 'manual' && folderSort.dir === 'asc') {
1532
- if (hostIndex > 0) {
1533
- moveItems.push({
1534
- key: 'up',
1535
- label: t('action.moveUp'),
1536
- onSelect: () => moveWorkspace(workspace.workspaceId, workspaces[hostIndex - 1].workspaceId),
1537
- })
1538
- }
1539
- if (hostIndex < workspaces.length - 1) {
1540
- const following = hostIndex + 2 < workspaces.length ? workspaces[hostIndex + 2].workspaceId : undefined
1541
- moveItems.push({
1542
- key: 'down',
1543
- label: t('action.moveDown'),
1544
- onSelect: () => moveWorkspace(workspace.workspaceId, following),
1545
- })
1546
- }
1547
- }
1548
- const folderMenu = [
1549
- { key: 'rename', label: t('action.renameWorkspace'), onSelect: () => startRenameWorkspace(workspace) },
1550
- { key: 'new', label: t('action.newSessionHere'), onSelect: () => startSession(workspace.workspaceId) },
1551
- ...moveItems,
1552
- { key: 'delete', label: t('action.deleteWorkspace'), danger: true, onSelect: () => { setMenuFor(null); setConfirm({ kind: 'deleteWorkspace', workspaceId: workspace.workspaceId }) } },
1553
- ]
1554
- listRows.push(h('div', {
1555
- key: workspace.workspaceId,
1556
- className: `dfs-rowWrap${dropEdge !== null && dropEdge.id === workspace.workspaceId ? ` is-drop-${dropEdge.edge}` : ''}`,
1557
- draggable: canReorder,
1558
- onDragStart: (event) => {
1559
- if (!canReorder) return
1560
- const target = event.target
1561
- if (target !== null && typeof target.closest === 'function' && target.closest('button') !== null) {
1562
- event.preventDefault()
1563
- return
1564
- }
1565
- setDragId(workspace.workspaceId)
1566
- try {
1567
- event.dataTransfer.effectAllowed = 'move'
1568
- event.dataTransfer.setData('text/plain', workspace.workspaceId)
1569
- } catch {
1570
- // Some environments expose a read-only dataTransfer.
1571
- }
1572
- },
1573
- onDragOver: (event) => {
1574
- if (!canReorder || dragId === null || dragId === workspace.workspaceId) return
1575
- event.preventDefault()
1576
- const rect = event.currentTarget.getBoundingClientRect()
1577
- const edge = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
1578
- setDropEdge((previous) => (previous !== null && previous.id === workspace.workspaceId && previous.edge === edge
1579
- ? previous
1580
- : { id: workspace.workspaceId, edge }))
1581
- },
1582
- onDragLeave: (event) => {
1583
- if (event.currentTarget.contains(event.relatedTarget)) return
1584
- setDropEdge((previous) => (previous !== null && previous.id === workspace.workspaceId ? null : previous))
1585
- },
1586
- onDrop: (event) => {
1587
- if (!canReorder) return
1588
- event.preventDefault()
1589
- const edge = dropEdge !== null && dropEdge.id === workspace.workspaceId ? dropEdge.edge : 'before'
1590
- dropOn(workspace.workspaceId, edge)
1591
- },
1592
- onDragEnd: clearDrag,
1593
- },
1594
- h('div', {
1595
- className: `dfs-row${isCurrent ? ' is-current' : ''}`,
1596
- role: 'button',
1597
- tabIndex: 0,
1598
- title: typeof workspace.path === 'string' && workspace.path !== '' ? workspace.path : name,
1599
- onClick: () => { if (!renaming) setOpenId(workspace.workspaceId) },
1600
- onContextMenu: contextMenuHandler(menuKey, folderMenu),
1601
- onMouseEnter: (event) => scheduleHover(menuKey, folderStatusRows(workspace), event),
1602
- onMouseLeave: cancelHover,
1603
- onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(workspace.workspaceId) } },
1604
- },
1605
- h(FolderGlyph, { kind: folderKind, label: folderKind === undefined ? undefined : labels[folderKind] }),
1606
- renaming
1607
- ? renameInput(rename)
1608
- : h('span', { className: 'dfs-title' }, name === '' ? t('section.title') : name),
1609
- h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1610
- menuTrigger(menuKey, folderMenu)),
1611
- rows.length > 0 ? h('span', { className: 'dfs-badge' }, String(rows.length)) : null,
1612
- h(ChevronIcon, null)),
1613
- confirm !== null && confirm.kind === 'deleteWorkspace' && confirm.workspaceId === workspace.workspaceId
1614
- ? h('div', { className: 'dfs-confirm' },
1615
- h('span', { className: 'dfs-confirmText' }, t('confirm.deleteWorkspace')),
1616
- h('button', { type: 'button', className: 'dfs-miniBtn is-danger', onClick: () => deleteWorkspace(workspace.workspaceId) }, t('confirm.yes')),
1617
- h('button', { type: 'button', className: 'dfs-miniBtn', onClick: () => setConfirm(null) }, t('confirm.cancel')))
1618
- : null))
1619
- })
1620
- const ungroupedRows = rowsFor(ungroupedWorkspace)
1621
- if (ungroupedRows.length > 0) {
1622
- const menuKey = 'workspace:' + UNGROUPED
1623
- listRows.push(h('div', { key: UNGROUPED, className: 'dfs-rowWrap' },
1624
- h('div', {
1625
- className: `dfs-row${openId === UNGROUPED ? ' is-current' : ''}`,
1626
- role: 'button',
1627
- tabIndex: 0,
1628
- title: t('group.ungrouped'),
1629
- onClick: () => setOpenId(UNGROUPED),
1630
- onMouseEnter: (event) => scheduleHover(menuKey, folderStatusRows(ungroupedWorkspace), event),
1631
- onMouseLeave: cancelHover,
1632
- onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(UNGROUPED) } },
1633
- },
1634
- h(FolderGlyph, { kind: folderKindOf(ungroupedWorkspace), label: undefined }),
1635
- h('span', { className: 'dfs-title' }, t('group.ungrouped')),
1636
- h('span', { className: 'dfs-badge' }, String(ungroupedRows.length)),
1637
- h(ChevronIcon, null))))
1638
- }
1639
- if (listRows.length === 0) {
1640
- listRows.push(h('div', { key: 'empty', className: 'dfs-empty' }, t(archivedFilter === 'only' ? 'empty.archived' : 'empty.workspaces')))
1641
- }
1642
- body.push(topBar())
1643
- body.push(h('div', { key: 'list', className: 'dfs-list' }, listRows))
1644
- return h('div', { className: 'dfs-root' }, body, menuEl, hoverEl, notice === null ? null : noticeEl())
1645
- }
1646
-
1647
- // ----- one Workspace's Session list -----
1648
- const isUngrouped = openWorkspace.workspaceId === UNGROUPED
1649
- const name = isUngrouped
1650
- ? t('group.ungrouped')
1651
- : (typeof openWorkspace.title === 'string' && openWorkspace.title !== ''
1652
- ? openWorkspace.title
1653
- : baseName(openWorkspace.path))
1654
- const workspaceRenaming = rename !== null && rename.kind === 'workspace' && rename.id === openWorkspace.workspaceId
1655
- body.push(topBar())
1656
-
1657
- // The folder row sits below the search field: the whole row goes back
1658
- // to the folder list, the trailing plus starts a Session here.
1659
- body.push(h('div', {
1660
- key: 'crumb',
1661
- className: 'dfs-crumb',
1662
- role: 'button',
1663
- tabIndex: 0,
1664
- title: t('action.back'),
1665
- onClick: () => { setOpenId(null); setQuery('') },
1666
- onKeyDown: (event) => {
1667
- if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(null); setQuery('') }
1668
- },
1669
- },
1670
- h('span', { className: 'dfs-crumbChevron', 'aria-hidden': true }, h(BackIcon)),
1671
- h(FolderIcon, null),
1672
- workspaceRenaming
1673
- ? renameInput(rename)
1674
- : h('span', { className: 'dfs-crumbName', title: name }, name),
1675
- isUngrouped ? null : h('button', {
1676
- key: 'new',
1677
- type: 'button', className: 'dfs-iconBtn', title: t('action.newSession'),
1678
- 'aria-label': t('action.newSession'),
1679
- onClick: (event) => { event.stopPropagation(); startSession(openWorkspace.workspaceId) },
1680
- }, h(NewChatIcon))))
1681
-
1682
- const rows = orderSessions(rowsFor(openWorkspace))
1683
- const sessionRows = []
1684
- for (const summary of rows) {
1685
- const status = statusOf(summary.id)
1686
- const kind = indicatorOf(status, projectionOf(summary.id))
1687
- const label = kind === undefined ? undefined : (kind === 'waiting' ? waitingLabel(status) : labels[kind])
1688
- const menuKey = `session:${summary.id}`
1689
- const renaming = rename !== null && rename.kind === 'session' && rename.id === summary.id
1690
- const isPinned = pinned.has(summary.id)
1691
- const isArchived = archived.has(summary.id)
1692
- const openRow = () => {
1693
- if (isArchived) { notify(t('notice.archivedNotOpenable')); return }
1694
- openSession(summary.id)
1695
- }
1696
- const sessionMenu = isArchived
1697
- ? [
1698
- { key: 'unarchive', label: t('action.unarchive'), onSelect: () => unarchive(summary.id) },
1699
- { key: 'rename', label: t('action.rename'), onSelect: () => startRenameSession(summary) },
1700
- { key: 'fork', label: t('action.fork'), onSelect: () => fork(summary.id) },
1701
- { key: 'copy', label: t('action.copyId'), onSelect: () => copyId(summary.id) },
1702
- ]
1703
- : [
1704
- { key: 'pin', label: isPinned ? t('action.unpin') : t('action.pin'), onSelect: () => togglePin(summary.id) },
1705
- { key: 'rename', label: t('action.rename'), onSelect: () => startRenameSession(summary) },
1706
- { key: 'fork', label: t('action.fork'), onSelect: () => fork(summary.id) },
1707
- { key: 'copy', label: t('action.copyId'), onSelect: () => copyId(summary.id) },
1708
- { key: 'archive', label: t('action.archive'), danger: true, onSelect: () => archiveNow(summary.id, false) },
1709
- ]
1710
- sessionRows.push(h('div', { key: summary.id, className: 'dfs-rowWrap' },
1711
- h('div', {
1712
- className: `dfs-row${summary.id === currentId ? ' is-current' : ''}${isArchived ? ' is-archived' : ''}`,
1713
- role: 'button',
1714
- tabIndex: 0,
1715
- title: isArchived ? t('notice.archivedNotOpenable') : (kind === undefined ? titleOf(summary) : `${titleOf(summary)} — ${label}`),
1716
- onClick: () => { if (!renaming) openRow() },
1717
- onContextMenu: contextMenuHandler(menuKey, sessionMenu),
1718
- onMouseEnter: (event) => scheduleHover(menuKey, statusRowsOf(status, projectionOf(summary.id), summary.updatedAt), event),
1719
- onMouseLeave: cancelHover,
1720
- onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openRow() } },
1721
- },
1722
- h(Indicator, { kind, title: label }),
1723
- renaming ? renameInput(rename) : h('span', { className: 'dfs-title' }, titleOf(summary)),
1724
- isPinned ? h('span', { className: 'dfs-pin', title: t('action.pin'), 'aria-hidden': true }, h(PinIcon)) : null,
1725
- h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1726
- menuTrigger(menuKey, sessionMenu)),
1727
- h('span', { className: 'dfs-time' }, relativeTime(summary.updatedAt))),
1728
- confirm !== null && confirm.kind === 'archive' && confirm.sessionId === summary.id
1729
- ? h('div', { className: 'dfs-confirm' },
1730
- h('span', { className: 'dfs-confirmText' }, t('confirm.archive')),
1731
- h('button', { type: 'button', className: 'dfs-miniBtn is-danger', onClick: () => archiveNow(summary.id, true) }, t('confirm.yes')),
1732
- h('button', { type: 'button', className: 'dfs-miniBtn', onClick: () => setConfirm(null) }, t('confirm.cancel')))
1733
- : null))
1734
- }
1735
- if (sessionRows.length === 0) sessionRows.push(h('div', { key: 'empty', className: 'dfs-empty' }, t('empty.sessions')))
1736
- body.push(h('div', { key: 'list', className: 'dfs-list' }, sessionRows))
1737
- return h('div', { className: 'dfs-root' }, body, menuEl, hoverEl, notice === null ? null : noticeEl())
1738
- }
1739
-
1740
- // A single slot refuses a second registration at the same priority and
1741
- // renders the lowest priority, so this row must sit below the shipped
1742
- // browser (priority 0) to replace it. The boundary keeps a render bug
1743
- // from abdicating this entry back to the shipped browser: it renders the
1744
- // message in place instead, which is what a reader can diagnose.
1745
- ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register({
1746
- name: 'sidebar.workspaces',
1747
- priority: -1,
1748
- locale: NS,
1749
- }, (props) => h(RenderBoundary, null, h(FolderSidebar, props))))
1750
- },
1751
- }
1752
- },
1753
- })
1754
- }
1755
-
1756
- if (typeof module === 'object' && module !== null && module.exports !== undefined) {
1757
- module.exports = {
1758
- INDICATOR_PRIORITY, INDICATOR_LABEL_KEY, GLYPH_COLOR,
1759
- SORT_MODES, SORT_DIRS, SORT_DEFAULT, SORT_NATURAL_DIR,
1760
- ARCHIVED_FILTERS, UNGROUPED,
1761
- directionSign, normalizeSort, readSortState, writeSortState,
1762
- readArchivedFilter, writeArchivedFilter,
1763
- sortWorkspaces, sortSessions, pickFolderKind, indicatorOf,
1764
- relativeTime, baseName,
1765
- }
1766
- }
1
+ /**
2
+ * Pure logic for dsh-folder-sidebar.
3
+ *
4
+ * This block lives at module scope, outside the Client factory, so that the
5
+ * exact shipped source can be exercised by `node` tests: with `window` absent
6
+ * the browser bootstrap below is skipped and `module.exports` exposes these
7
+ * functions. Nothing here touches React, the DOM, or a specific Cordis context.
8
+ */
9
+
10
+ const INDICATOR_PRIORITY = ['waiting', 'running', 'error', 'completed', 'stopped']
11
+
12
+ const INDICATOR_LABEL_KEY = {
13
+ running: 'status.running',
14
+ completed: 'status.completed',
15
+ waiting: 'status.waiting',
16
+ error: 'status.error',
17
+ stopped: 'status.stopped',
18
+ }
19
+
20
+ const GLYPH_COLOR = {
21
+ waiting: 'var(--dsw-alias-state-warn-primary)',
22
+ error: 'var(--dsw-alias-state-error-primary)',
23
+ completed: 'var(--dsw-alias-state-success-primary)',
24
+ }
25
+
26
+ const SORT_MODES = ['manual', 'name', 'recent']
27
+ const SORT_DIRS = ['asc', 'desc']
28
+
29
+ /** Each level keeps its own mode and direction. */
30
+ const SORT_KEY = 'dsh.folder-sidebar.sort'
31
+
32
+ const SORT_DEFAULT = {
33
+ folders: { mode: 'manual', dir: 'asc' },
34
+ sessions: { mode: 'recent', dir: 'desc' },
35
+ }
36
+
37
+ /** A mode's natural order; picking a mode resets the direction to it. */
38
+ const SORT_NATURAL_DIR = { manual: 'asc', name: 'asc', recent: 'desc' }
39
+
40
+ /** Archived visibility mirrors the shipped view options. */
41
+ const ARCHIVED_KEY = 'dsh.folder-sidebar.archived'
42
+ const ARCHIVED_FILTERS = ['hide', 'all', 'only']
43
+
44
+ /** Sentinel for the pseudo Workspace that owns Sessions outside every Workspace. */
45
+ const UNGROUPED = '__ungrouped__'
46
+
47
+ function safeStorage(storage) {
48
+ if (storage !== undefined && storage !== null) return storage
49
+ try {
50
+ return globalThis.localStorage
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** Comparator sign for a direction. */
57
+ function directionSign(dir) {
58
+ return dir === 'desc' ? -1 : 1
59
+ }
60
+
61
+ function normalizeSort(value, fallback) {
62
+ const mode = value !== null && typeof value === 'object' && SORT_MODES.includes(value.mode) ? value.mode : fallback.mode
63
+ const dir = value !== null && typeof value === 'object' && SORT_DIRS.includes(value.dir) ? value.dir : fallback.dir
64
+ return { mode, dir }
65
+ }
66
+
67
+ function readSortState(storage) {
68
+ const store = safeStorage(storage)
69
+ try {
70
+ const parsed = JSON.parse(store?.getItem(SORT_KEY) ?? 'null')
71
+ return {
72
+ folders: normalizeSort(parsed?.folders, SORT_DEFAULT.folders),
73
+ sessions: normalizeSort(parsed?.sessions, SORT_DEFAULT.sessions),
74
+ }
75
+ } catch {
76
+ return { folders: { ...SORT_DEFAULT.folders }, sessions: { ...SORT_DEFAULT.sessions } }
77
+ }
78
+ }
79
+
80
+ function writeSortState(state, storage) {
81
+ try {
82
+ safeStorage(storage)?.setItem(SORT_KEY, JSON.stringify(state))
83
+ } catch {
84
+ // Storage may be unavailable; the choice still applies this session.
85
+ }
86
+ }
87
+
88
+ function readArchivedFilter(storage) {
89
+ try {
90
+ const saved = safeStorage(storage)?.getItem(ARCHIVED_KEY)
91
+ return ARCHIVED_FILTERS.includes(saved) ? saved : 'hide'
92
+ } catch {
93
+ return 'hide'
94
+ }
95
+ }
96
+
97
+ function writeArchivedFilter(value, storage) {
98
+ try {
99
+ safeStorage(storage)?.setItem(ARCHIVED_KEY, value)
100
+ } catch {
101
+ // Storage may be unavailable; the filter still applies this session.
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Order Workspaces for the folder list.
107
+ * @param items - Workspaces in Host order.
108
+ * @param sort - `{ mode, dir }`.
109
+ * @param accessors - `nameOf(item)` and `activityOf(item)`.
110
+ */
111
+ function sortWorkspaces(items, sort, accessors) {
112
+ const ordered = items.slice()
113
+ const sign = directionSign(sort.dir)
114
+ if (sort.mode === 'name') {
115
+ ordered.sort((a, b) => accessors.nameOf(a).localeCompare(accessors.nameOf(b), undefined, { numeric: true }) * sign)
116
+ } else if (sort.mode === 'recent') {
117
+ ordered.sort((a, b) => (accessors.activityOf(a) - accessors.activityOf(b)) * sign)
118
+ } else if (sign < 0) {
119
+ ordered.reverse()
120
+ }
121
+ return ordered
122
+ }
123
+
124
+ /**
125
+ * Order Sessions inside one Workspace. Pinned rows stay first in every mode;
126
+ * the direction applies inside a group. Descending updates are what the caller
127
+ * already derives, so only ascending needs a sort.
128
+ */
129
+ function sortSessions(rows, sort, accessors) {
130
+ const ordered = rows.slice()
131
+ const sign = directionSign(sort.dir)
132
+ const pinOf = (row) => (accessors.isPinned(row) ? 1 : 0)
133
+ if (sort.mode === 'name') {
134
+ ordered.sort((a, b) => {
135
+ const pin = pinOf(b) - pinOf(a)
136
+ if (pin !== 0) return pin
137
+ return accessors.titleOf(a).localeCompare(accessors.titleOf(b), undefined, { numeric: true }) * sign
138
+ })
139
+ return ordered
140
+ }
141
+ // Both "manual" and "recent" order Sessions by recency; the direction only
142
+ // flips the comparison, so the result never depends on the caller's order.
143
+ ordered.sort((a, b) => {
144
+ const pin = pinOf(b) - pinOf(a)
145
+ if (pin !== 0) return pin
146
+ return ((accessors.updatedAtOf(a) ?? 0) - (accessors.updatedAtOf(b) ?? 0)) * sign
147
+ })
148
+ return ordered
149
+ }
150
+
151
+ /**
152
+ * A Workspace shows one status: highest precedence first, then the state that
153
+ * began earliest.
154
+ * @param candidates - `{ kind, since }` pairs for the Workspace's Sessions.
155
+ */
156
+ function pickFolderKind(candidates) {
157
+ let best
158
+ for (const candidate of candidates) {
159
+ const priority = INDICATOR_PRIORITY.indexOf(candidate.kind)
160
+ if (priority < 0) continue
161
+ if (best === undefined || priority < best.priority || (priority === best.priority && candidate.since < best.since)) {
162
+ best = { kind: candidate.kind, priority, since: candidate.since }
163
+ }
164
+ }
165
+ return best === undefined ? undefined : best.kind
166
+ }
167
+
168
+ /**
169
+ * One row's leading indicator. Green follows the client's completion-unread
170
+ * rule; failure comes from this bundle's projection and is not cleared by
171
+ * viewing. Running wins over everything, a pending question wins over activity.
172
+ */
173
+ function indicatorOf(status, projection) {
174
+ const waiting = status !== undefined && status !== null
175
+ && status.pendingInteraction !== undefined && status.pendingInteraction !== null
176
+ if (waiting) return 'waiting'
177
+ const running = status !== undefined && status !== null && status.running === true
178
+ if (running) return 'running'
179
+ const state = projection === undefined || projection === null ? undefined : projection.state
180
+ if (state === 'failed' || state === 'interrupted') return 'error'
181
+ if (status !== undefined && status !== null && status.completionUnread === true) return 'completed'
182
+ if (state === 'running') return 'running'
183
+ if (state === 'stopped') return 'stopped'
184
+ return undefined
185
+ }
186
+
187
+ function relativeTime(ts, now = Date.now()) {
188
+ if (typeof ts !== 'number' || !Number.isFinite(ts)) return ''
189
+ const delta = now - ts
190
+ if (delta < 60_000) return '<1m'
191
+ if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m`
192
+ if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h`
193
+ if (delta < 7 * 86_400_000) return `${Math.floor(delta / 86_400_000)}d`
194
+ const date = new Date(ts)
195
+ return `${date.getMonth() + 1}/${date.getDate()}`
196
+ }
197
+
198
+ function baseName(path) {
199
+ if (typeof path !== 'string' || path === '') return ''
200
+ const parts = path.split(/[\\/]+/).filter(Boolean)
201
+ return parts.length > 0 ? parts[parts.length - 1] : path
202
+ }
203
+
204
+ /**
205
+ * How far a title must travel to show its tail, or 0 when it already fits.
206
+ * A couple of pixels of rounding should not start a marquee.
207
+ */
208
+ function marqueeShiftOf(scrollWidth, clientWidth) {
209
+ if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return 0
210
+ const shift = Math.round(scrollWidth - clientWidth)
211
+ return shift > 2 ? shift : 0
212
+ }
213
+
214
+ /** Longer titles scroll slower, so the reading speed stays roughly even. */
215
+ function marqueeDurationOf(shift) {
216
+ return Math.min(14000, Math.max(2200, Math.round(shift * 26)))
217
+ }
218
+
219
+ /**
220
+ * Web Client half of dsh-folder-sidebar.
221
+ *
222
+ * Replaces the sidebar's `sidebar.workspaces` region (shipped browser:
223
+ * ui-workspace) with a drill-down "folder" view:
224
+ * 1. folder list — one row per Workspace; the folder icon itself is that
225
+ * Workspace's single status cell, showing its oldest Session state;
226
+ * 2. enter a folder — only that Workspace's Sessions, each with its own
227
+ * state colour, a "…" menu, inline rename, and a back row.
228
+ * A search field switches the region to a flat result list across Workspaces.
229
+ *
230
+ * Data comes from the slot's standard projection props (`useWorkspaces`,
231
+ * `useSessions`, `useSessionStatus`); actions go through Client services
232
+ * (`uiWorkspace`, `workspaces`, `sessions`). Failure state is read from this
233
+ * bundle's Host projection (`folder-sidebar/turn`), because a Session summary
234
+ * carries no failure field.
235
+ */
236
+
237
+ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
238
+ window.__ModuleLoader__.load({
239
+ id: 'dsh-folder-sidebar',
240
+ factory(require) {
241
+ const React = require('react')
242
+ const h = React.createElement
243
+
244
+ const NS = 'folder-sidebar'
245
+
246
+ /** Host projection key: this bundle's per-Session last-turn state. */
247
+ const TURN_KEY = 'folder-sidebar/turn'
248
+
249
+ const DICTS = {
250
+ zh: {
251
+ 'section.title': '工作区',
252
+ 'action.back': '全部工作区',
253
+ 'action.newSession': '新会话',
254
+ 'action.addWorkspace': '添加工作区',
255
+ 'action.search': '搜索会话',
256
+ 'action.close': '关闭',
257
+ 'action.menu': '更多操作',
258
+ 'action.pin': '置顶',
259
+ 'action.unpin': '取消置顶',
260
+ 'action.rename': '重命名',
261
+ 'action.archive': '归档',
262
+ 'action.fork': '分叉',
263
+ 'action.copyId': '复制会话 ID',
264
+ 'action.renameWorkspace': '重命名工作区',
265
+ 'action.deleteWorkspace': '删除工作区',
266
+ 'action.newSessionHere': '在此新建会话',
267
+ 'action.moveUp': '上移',
268
+ 'action.moveDown': '下移',
269
+ 'action.sort': '排序',
270
+ 'level.folders': '文件夹',
271
+ 'level.sessions': '会话',
272
+ 'sort.manual': '手动顺序',
273
+ 'sort.name': '按名称',
274
+ 'sort.recent': '按最近活动',
275
+ 'sort.asc': '正序',
276
+ 'sort.desc': '倒序',
277
+ 'confirm.archive': '该会话仍在运行,停止并归档?',
278
+ 'confirm.deleteWorkspace': '删除这个工作区?会话与文件不会被删除。',
279
+ 'confirm.yes': '确认',
280
+ 'confirm.cancel': '取消',
281
+ 'empty.workspaces': '还没有工作区',
282
+ 'empty.sessions': '这个工作区还没有会话',
283
+ 'empty.search': '没有匹配的会话',
284
+ 'empty.archived': '暂无已归档会话',
285
+ 'group.ungrouped': '未分组',
286
+ 'action.undo': '撤销',
287
+ 'action.unarchive': '取消归档',
288
+ 'action.view': '视图选项',
289
+ 'view.hide': '隐藏已归档',
290
+ 'view.all': '全部会话',
291
+ 'view.only': '仅显示已归档',
292
+ 'notice.archived': '已归档',
293
+ 'notice.archivedNotOpenable': '已归档,取消归档后才能打开',
294
+ 'notice.pinFailed': '置顶操作失败',
295
+ 'notice.archiveFailed': '归档失败',
296
+ 'notice.unarchiveFailed': '取消归档失败',
297
+ 'notice.renameFailed': '重命名失败',
298
+ 'notice.forkFailed': '分叉失败',
299
+ 'notice.moveFailed': '调整顺序失败',
300
+ 'notice.deleteFailed': '删除工作区失败',
301
+ 'notice.addWorkspaceFailed': '添加工作区失败',
302
+ 'session.untitled': '未命名会话',
303
+ 'copied': '已复制',
304
+ 'unit.sessions': '个会话',
305
+ 'rail.expand': '展开侧栏',
306
+ 'search.placeholder': '搜索会话…',
307
+ 'search.content': '内容匹配',
308
+ 'status.running': '运行中',
309
+ 'status.completed': '已完成',
310
+ 'status.error': '失败或中断',
311
+ 'status.waiting': '等待你的回答',
312
+ 'status.waitingApproval': '等待审批',
313
+ 'status.waitingPlanReview': '等待计划确认',
314
+ 'status.waitingAnswer': '等待回答',
315
+ 'status.stopped': '已停止',
316
+ 'status.idle': '空闲',
317
+ },
318
+ en: {
319
+ 'section.title': 'Workspaces',
320
+ 'action.back': 'All workspaces',
321
+ 'action.newSession': 'New session',
322
+ 'action.addWorkspace': 'Add workspace',
323
+ 'action.search': 'Search sessions',
324
+ 'action.close': 'Close',
325
+ 'action.menu': 'More actions',
326
+ 'action.pin': 'Pin',
327
+ 'action.unpin': 'Unpin',
328
+ 'action.rename': 'Rename',
329
+ 'action.archive': 'Archive',
330
+ 'action.fork': 'Fork',
331
+ 'action.copyId': 'Copy session ID',
332
+ 'action.renameWorkspace': 'Rename workspace',
333
+ 'action.deleteWorkspace': 'Delete workspace',
334
+ 'action.newSessionHere': 'New session here',
335
+ 'action.moveUp': 'Move up',
336
+ 'action.moveDown': 'Move down',
337
+ 'action.sort': 'Sort',
338
+ 'level.folders': 'folders',
339
+ 'level.sessions': 'sessions',
340
+ 'sort.manual': 'Manual order',
341
+ 'sort.name': 'By name',
342
+ 'sort.recent': 'By recent activity',
343
+ 'sort.asc': 'Ascending',
344
+ 'sort.desc': 'Descending',
345
+ 'confirm.archive': 'This session is still running. Stop it and archive?',
346
+ 'confirm.deleteWorkspace': 'Delete this workspace? Sessions and files are kept.',
347
+ 'confirm.yes': 'Confirm',
348
+ 'confirm.cancel': 'Cancel',
349
+ 'empty.workspaces': 'No workspaces yet',
350
+ 'empty.sessions': 'No sessions in this workspace yet',
351
+ 'empty.search': 'No matching sessions',
352
+ 'empty.archived': 'No archived sessions',
353
+ 'group.ungrouped': 'Ungrouped',
354
+ 'action.undo': 'Undo',
355
+ 'action.unarchive': 'Unarchive',
356
+ 'action.view': 'View options',
357
+ 'view.hide': 'Hide archived',
358
+ 'view.all': 'All sessions',
359
+ 'view.only': 'Archived only',
360
+ 'notice.archived': 'Archived',
361
+ 'notice.archivedNotOpenable': 'Archived — unarchive it to open',
362
+ 'notice.pinFailed': 'Pin failed',
363
+ 'notice.archiveFailed': 'Archive failed',
364
+ 'notice.unarchiveFailed': 'Unarchive failed',
365
+ 'notice.renameFailed': 'Rename failed',
366
+ 'notice.forkFailed': 'Fork failed',
367
+ 'notice.moveFailed': 'Reorder failed',
368
+ 'notice.deleteFailed': 'Deleting the workspace failed',
369
+ 'notice.addWorkspaceFailed': 'Adding the workspace failed',
370
+ 'session.untitled': 'Untitled session',
371
+ 'copied': 'Copied',
372
+ 'unit.sessions': 'sessions',
373
+ 'rail.expand': 'Expand sidebar',
374
+ 'search.placeholder': 'Search sessions…',
375
+ 'search.content': 'content match',
376
+ 'status.running': 'Running',
377
+ 'status.completed': 'Completed',
378
+ 'status.error': 'Failed or interrupted',
379
+ 'status.waiting': 'Waiting for your answer',
380
+ 'status.waitingApproval': 'Waiting for approval',
381
+ 'status.waitingPlanReview': 'Waiting for plan review',
382
+ 'status.waitingAnswer': 'Waiting for your answer',
383
+ 'status.stopped': 'Stopped',
384
+ 'status.idle': 'Idle',
385
+ },
386
+ }
387
+
388
+ /**
389
+ * Precedence for a Workspace's single status cell, matching the Session row
390
+ * order: an actionable question outranks activity, a failure outranks an
391
+ * unread result. Ties inside one kind fall back to the oldest state.
392
+ */
393
+ const CSS = `
394
+ .dfs-root{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%;font-size:13px;color:var(--dsw-alias-label-primary);padding:4px 8px 10px}
395
+ .dfs-head{display:flex;align-items:center;gap:4px;flex:none;box-sizing:border-box;height:32px;margin:0 2px 8px;padding:0 2px}
396
+ .dfs-topbar{position:relative;display:flex;align-items:center;flex:none;box-sizing:border-box;height:32px;margin:0 2px 8px}
397
+ .dfs-topbar > .dfs-head{flex:1 1 auto;min-width:0;height:100%;margin:0;padding:0 2px;opacity:1;visibility:visible;transition:opacity .15s ease,visibility 0s linear 0s}
398
+ .dfs-topbar > .dfs-search{position:absolute;inset:0;height:100%;margin:0;opacity:0;visibility:hidden;pointer-events:none;transform:scaleX(.94);transform-origin:right center;transition:opacity .15s ease,transform .18s ease,visibility 0s linear .18s}
399
+ .dfs-topbar.is-search > .dfs-head{opacity:0;pointer-events:none;visibility:hidden;transition:opacity .15s ease,visibility 0s linear .15s}
400
+ .dfs-topbar.is-search > .dfs-search{opacity:1;visibility:visible;pointer-events:auto;transform:none;transition:opacity .15s ease,transform .18s ease,visibility 0s linear 0s}
401
+ @media (prefers-reduced-motion: reduce){.dfs-topbar > .dfs-head,.dfs-topbar > .dfs-search{transition:none}}
402
+ .dfs-headTitle{flex:1;min-width:0;font-size:12px;font-weight:500;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
403
+ .dfs-headBack{width:20px;height:20px;font-size:14px;color:var(--dsw-alias-label-secondary)}
404
+ .dfs-iconBtn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none}
405
+ .dfs-iconBtn:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
406
+ .dfs-iconBtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
407
+ .dfs-search{display:flex;align-items:center;flex:none;gap:8px;box-sizing:border-box;height:32px;margin:0 2px 8px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);transition:border-color .12s ease}
408
+
409
+ .dfs-search:focus-within{border-color:var(--dsw-alias-brand-primary)}
410
+ .dfs-search input{flex:1;min-width:0;padding:0;border:0;outline:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px}
411
+ .dfs-search input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.75}
412
+ .dfs-crumb{display:flex;align-items:center;flex:none;gap:8px;box-sizing:border-box;height:28px;margin:0 2px 2px;padding:0 8px;border-radius:7px;cursor:pointer}
413
+ .dfs-crumb:hover{background:var(--dsw-alias-bg-layer-2)}
414
+ .dfs-crumb:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
415
+ .dfs-crumbChevron{flex:none;width:12px;text-align:center;font-size:13px;color:var(--dsw-alias-label-secondary)}
416
+ .dfs-crumb .dfs-folderGlyph{display:inline-flex;align-items:center}
417
+ .dfs-crumb .dfs-iconBtn{width:20px;height:20px}
418
+ .dfs-crumbName{flex:1;min-width:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
419
+ .dfs-list{display:flex;flex-direction:column;flex:1 1 auto;gap:2px;overflow-y:auto;min-height:0;padding-bottom:2px}
420
+ .dfs-rowWrap{position:relative;display:flex;flex-direction:column}
421
+ .dfs-rowWrap.is-drop-before:before,.dfs-rowWrap.is-drop-after:after{content:"";position:absolute;left:4px;right:4px;height:2px;border-radius:1px;background:var(--dsw-alias-brand-primary);z-index:5;pointer-events:none}
422
+ .dfs-rowWrap.is-drop-before:before{top:-1px}
423
+ .dfs-rowWrap.is-drop-after:after{bottom:-1px}
424
+ .dfs-rowWrap[draggable=true]{cursor:grab}
425
+ .dfs-row{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:5px 8px;border:0;border-radius:7px;background:transparent;color:inherit;font:inherit;line-height:1.3;text-align:left;cursor:pointer}
426
+ .dfs-row:hover{background:var(--dsw-alias-bg-layer-2)}
427
+ .dfs-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
428
+ .dfs-row.is-current{background:var(--dsw-alias-bg-layer-1)}
429
+ .dfs-row.is-current .dfs-title{font-weight:600}
430
+ .dfs-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
431
+ .dfs-titleText{display:inline-block;white-space:nowrap;will-change:transform}
432
+ .dfs-title.is-marquee{text-overflow:clip}
433
+ .dfs-title.is-marquee .dfs-titleText,
434
+ .dfs-crumbName.is-marquee .dfs-titleText{animation:dfs-title-marquee var(--dfs-marquee-duration,4s) ease-in-out infinite alternate}
435
+ @keyframes dfs-title-marquee{0%,10%{transform:translateX(0)}90%,100%{transform:translateX(var(--dfs-marquee-shift,0))}}
436
+ @media (prefers-reduced-motion: reduce){.dfs-title.is-marquee .dfs-titleText,.dfs-crumbName.is-marquee .dfs-titleText{animation:none}}
437
+ .dfs-snippet{flex:1 1 100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
438
+ .dfs-time{flex:none;font-size:11px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
439
+ .dfs-status{display:inline-flex;align-items:center;justify-content:center;flex:none;width:18px;height:18px}
440
+ .dfs-statusDot{position:relative;width:12px;height:12px;border-radius:50%;color:var(--dsw-alias-state-idle-primary)}
441
+ .dfs-statusDot:after{content:"";position:absolute;inset:20%;border-radius:50%;background:currentColor}
442
+ .dfs-statusDot.is-none{color:var(--dsw-alias-state-idle-primary);opacity:.55}
443
+ .dfs-statusDot.is-completed{color:var(--dsw-alias-state-success-primary)}
444
+ .dfs-statusDot.is-waiting{color:var(--dsw-alias-state-warn-primary)}
445
+ .dfs-statusDot.is-error{color:var(--dsw-alias-state-error-primary)}
446
+ .dfs-statusDot.is-stopped{color:var(--dsw-alias-state-idle-primary)}
447
+ .dfs-statusDot.is-stopped:after{background:transparent;inset:15%;border:1.5px solid currentColor}
448
+ .dfs-spinner{width:14px;height:14px;border-radius:50%;border:1.5px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-label-secondary);animation:dfs-spin .8s linear infinite}
449
+ @keyframes dfs-spin{to{transform:rotate(360deg)}}
450
+ @media (prefers-reduced-motion: reduce){.dfs-spinner{animation:none;border-top-color:var(--dsw-alias-label-secondary);opacity:.85}}
451
+ .dfs-statusFolder{width:18px}
452
+ .dfs-glyph{font-size:15px;font-weight:700;line-height:1}
453
+ .dfs-folderGlyph{display:inline-flex;align-items:center;justify-content:center}
454
+ .dfs-badge{flex:none;min-width:16px;text-align:center;font-size:11px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
455
+ .dfs-rowActions{display:inline-flex;align-items:center;gap:2px;flex:none;opacity:0;transition:opacity .12s ease}
456
+ .dfs-rowWrap:hover .dfs-rowActions,.dfs-rowActions.is-open{opacity:1}
457
+ .dfs-pin{flex:none;font-size:10px;color:var(--dsw-alias-brand-primary)}
458
+ .dfs-input{flex:1;min-width:0;height:20px;padding:0 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px}
459
+ .dfs-menu{position:fixed;z-index:40;width:168px;padding:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-overlay);box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:1px}
460
+ .dfs-menuItem{display:flex;align-items:center;gap:4px;width:100%;box-sizing:border-box;padding:5px 8px;border:0;border-radius:5px;background:transparent;color:inherit;font:inherit;font-size:12px;text-align:left;cursor:pointer}
461
+ .dfs-menuCheck{flex:none;width:16px;text-align:center;color:var(--dsw-alias-brand-primary)}
462
+ .dfs-menuLabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
463
+ .dfs-menuItem:hover{background:var(--dsw-alias-bg-layer-2)}
464
+ .dfs-menuItem.is-danger{color:var(--dsw-alias-state-error-primary)}
465
+ .dfs-menuDivider{height:1px;margin:4px 2px;background:var(--dsw-alias-border-l1)}
466
+ .dfs-menuCaption{padding:3px 8px 1px;font-size:11px;line-height:1.4;color:var(--dsw-alias-label-secondary);opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
467
+ .dfs-confirm{display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}
468
+ .dfs-confirmText{flex:1;min-width:0;line-height:1.4}
469
+ .dfs-miniBtn{padding:2px 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:11px;cursor:pointer}
470
+ .dfs-miniBtn.is-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
471
+ .dfs-miniBtn:hover{background:var(--dsw-alias-bg-layer-2)}
472
+ .dfs-empty{padding:10px 8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
473
+ .dfs-row.is-archived{opacity:.55}
474
+ .dfs-notice{display:flex;align-items:center;gap:8px;margin:6px 2px 0;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-2);font-size:12px;line-height:1.4}
475
+ .dfs-noticeText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}
476
+ .dfs-noticeAction{flex:none;padding:0 2px;border:0;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;cursor:pointer}
477
+ .dfs-noticeAction:hover{text-decoration:underline}
478
+ .dfs-hover{position:fixed;z-index:39;min-width:150px;max-width:260px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-overlay);box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:2px;pointer-events:none}
479
+ .dfs-hoverRow{display:flex;align-items:center;gap:6px;font-size:12px;line-height:1.4;color:var(--dsw-alias-label-secondary)}
480
+ .dfs-hoverRow .dfs-status{width:18px;height:16px}
481
+ .dfs-hoverLabel{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
482
+ .dfs-hoverSpacer{width:16px;flex:none}
483
+ .dfs-rail{display:flex;flex-direction:column;align-items:center;padding-top:8px}
484
+ `
485
+
486
+ // Icons below reproduce the shipped `ui-primitives` geometry (16×16 grid,
487
+ // stroke width 1 for the "Regular" weight) instead of importing the package.
488
+ function iconFrame(children, options) {
489
+ const size = options?.size ?? 16
490
+ return h('svg', {
491
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
492
+ xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true,
493
+ strokeWidth: 1,
494
+ style: { flex: 'none', color: options?.color ?? 'currentColor' },
495
+ }, children)
496
+ }
497
+
498
+ /** IconFolderCloseRegular */
499
+ function FolderIcon(options) {
500
+ return iconFrame([
501
+ h('path', {
502
+ key: 'body',
503
+ d: 'M1.50439 3.11059C1.50439 2.55831 1.95211 2.1106 2.50439 2.1106H5.43389C5.67773 2.1106 5.91318 2.19969 6.09593 2.36113L7.71649 3.79265C7.89924 3.95409 8.1347 4.04319 8.3785 4.04319H13.4958C14.0481 4.04319 14.4958 4.4909 14.4958 5.04319V12.8894C14.4958 13.4417 14.0481 13.8894 13.4958 13.8894H2.50439C1.95211 13.8894 1.50439 13.4417 1.50439 12.8894V4.04319V3.11059Z',
504
+ stroke: 'currentColor',
505
+ }),
506
+ h('path', { key: 'line', d: 'M3.63501 7.66614H12.3647', stroke: 'currentColor' }),
507
+ ], { size: options?.size ?? 16, color: options?.color })
508
+ }
509
+
510
+ /** IconTriangleRightFillRegular — the shipped row arrow. */
511
+ function ChevronIcon() {
512
+ return iconFrame(h('path', {
513
+ d: 'M5.5 4.5C5.5 4.40714 5.52586 4.31612 5.57467 4.23713C5.62349 4.15815 5.69334 4.09431 5.77639 4.05279C5.85945 4.01126 5.95242 3.99368 6.0449 4.00202C6.13738 4.01036 6.22572 4.04429 6.3 4.1L10.967 7.6C11.0291 7.64657 11.0795 7.70697 11.1142 7.77639C11.1489 7.84582 11.167 7.92238 11.167 8C11.167 8.07762 11.1489 8.15418 11.1142 8.22361C11.0795 8.29303 11.0291 8.35343 10.967 8.4L6.3 11.9C6.22572 11.9557 6.13738 11.9896 6.0449 11.998C5.95242 12.0063 5.85945 11.9887 5.77639 11.9472C5.69334 11.9057 5.62349 11.8419 5.57467 11.7629C5.52586 11.6839 5.5 11.5929 5.5 11.5V4.5Z',
514
+ fill: 'currentColor',
515
+ }), { size: 14, color: 'var(--dsw-alias-label-secondary)' })
516
+ }
517
+
518
+ /** IconChevronLeftOutlineRegular */
519
+ function BackIcon() {
520
+ return iconFrame(h('path', {
521
+ d: 'M10 4L6.70711 7.29289C6.31658 7.68342 6.31658 8.31658 6.70711 8.70711L10 12',
522
+ stroke: 'currentColor',
523
+ }), { size: 14, color: 'var(--dsw-alias-label-secondary)' })
524
+ }
525
+
526
+ /** IconEllipsisOutlineRegular */
527
+ function DotsIcon() {
528
+ return iconFrame([
529
+ h('path', { key: 'a', d: 'M3 9C3.55228 9 4 8.55228 4 8C4 7.44772 3.55228 7 3 7C2.44772 7 2 7.44772 2 8C2 8.55228 2.44772 9 3 9Z', fill: 'currentColor' }),
530
+ h('path', { key: 'b', d: 'M8 9C8.55228 9 9 8.55228 9 8C9 7.44772 8.55228 7 8 7C7.44772 7 7 7.44772 7 8C7 8.55228 7.44772 9 8 9Z', fill: 'currentColor' }),
531
+ h('path', { key: 'c', d: 'M13 9C13.5523 9 14 8.55228 14 8C14 7.44772 13.5523 7 13 7C12.4477 7 12 7.44772 12 8C12 8.55228 12.4477 9 13 9Z', fill: 'currentColor' }),
532
+ ], { size: 16 })
533
+ }
534
+
535
+ /** IconSearchOutlineRegular */
536
+ function SearchIcon() {
537
+ return iconFrame([
538
+ h('path', {
539
+ key: 'lens',
540
+ d: 'M6.58727 11.8586C9.55061 11.8586 11.9529 9.45637 11.9529 6.49304C11.9529 3.5297 9.55061 1.12744 6.58727 1.12744C3.62394 1.12744 1.22168 3.5297 1.22168 6.49304C1.22168 9.45637 3.62394 11.8586 6.58727 11.8586Z',
541
+ stroke: 'currentColor',
542
+ }),
543
+ h('path', { key: 'handle', d: 'M10.2991 10.3933L14.7783 14.8725', stroke: 'currentColor' }),
544
+ ], { size: 16, color: 'var(--dsw-alias-label-secondary)' })
545
+ }
546
+
547
+ /** IconSlidersTwoOutlineRegular — the shipped view-options glyph. */
548
+ function SlidersIcon() {
549
+ return iconFrame([
550
+ h('path', { key: 'l1', d: 'M2.3 5h5.85M12.05 5h1.65', stroke: 'currentColor' }),
551
+ h('circle', { key: 'c1', cx: 9.95, cy: 5, r: 1.45, stroke: 'currentColor' }),
552
+ h('path', { key: 'l2', d: 'M2.3 11h1.65M7.85 11h5.85', stroke: 'currentColor' }),
553
+ h('circle', { key: 'c2', cx: 5.75, cy: 11, r: 1.45, stroke: 'currentColor' }),
554
+ ], { size: 16 })
555
+ }
556
+
557
+ /** IconPinFillRegular */
558
+ function PinIcon() {
559
+ return iconFrame([
560
+ h('path', {
561
+ key: 'body',
562
+ d: 'M9.96976 1.70572L13.1554 3.93629L10.9019 8.12317L11.5158 11.605L10.7192 12.7427L2.52767 7.00693L3.3243 5.86922L6.80612 5.25528L9.96976 1.70572Z',
563
+ fill: 'currentColor', stroke: 'currentColor', strokeLinejoin: 'round',
564
+ }),
565
+ h('path', {
566
+ key: 'needle',
567
+ d: 'M6.05285 9.47511C6.27284 9.16094 6.70586 9.08458 7.02003 9.30457C7.3342 9.52455 7.41055 9.95757 7.19057 10.2717L3.98587 14.4708L3.21223 13.9291L6.05285 9.47511Z',
568
+ fill: 'currentColor',
569
+ }),
570
+ ], { size: 14 })
571
+ }
572
+
573
+ /** IconCloseFillRegular */
574
+ function CloseIcon() {
575
+ return iconFrame([
576
+ h('path', { key: 'a', d: 'M3.5 3.5L12.5 12.5', stroke: 'currentColor' }),
577
+ h('path', { key: 'b', d: 'M12.5 3.5L3.5 12.5', stroke: 'currentColor' }),
578
+ ], { size: 14 })
579
+ }
580
+
581
+ /** IconNewChatOutlineRegular */
582
+ function NewChatIcon() {
583
+ return iconFrame([
584
+ h('path', {
585
+ key: 'bubble',
586
+ d: 'M2.37091 11.2501C1.58745 9.89288 1.32067 8.29835 1.61969 6.76006C1.91872 5.22177 2.76342 3.8433 3.99826 2.87846C5.2331 1.91362 6.77494 1.42737 8.33988 1.50925C9.90482 1.59113 11.3875 2.23562 12.5149 3.32406C13.6425 4.41269 14.3387 5.87206 14.4754 7.4334C14.612 8.99474 14.18 10.5529 13.2587 11.8209C12.3375 13.0888 10.9891 13.9813 9.46194 14.3337C8.18691 14.628 6.85895 14.5294 5.64989 14.0605C5.1712 13.8748 4.76962 13.4932 4.26534 13.3967C3.67413 13.2835 2.95257 13.5598 2.03794 14.3337',
587
+ stroke: 'currentColor',
588
+ }),
589
+ h('path', { key: 'v', d: 'M8 5V11', stroke: 'currentColor' }),
590
+ h('path', { key: 'h', d: 'M5 8H11', stroke: 'currentColor' }),
591
+ ], { size: 16 })
592
+ }
593
+
594
+ /** IconProjectAddOutlineRegular — add-workspace. */
595
+ function ProjectAddIcon() {
596
+ return iconFrame([
597
+ h('path', {
598
+ key: 'body',
599
+ d: 'M5.54492 2.06738C5.91034 2.06754 6.26318 2.20149 6.53711 2.44336L7.94043 3.68164V4.7998C7.71462 4.74105 7.50367 4.63139 7.32617 4.47461L5.87598 3.19238C5.78477 3.11185 5.66658 3.06754 5.54492 3.06738H2.94922C2.67322 3.06738 2.44946 3.29145 2.44922 3.56738V12.4326C2.44927 12.7087 2.67311 12.9326 2.94922 12.9326H12.9326C13.2086 12.9325 13.4326 12.7086 13.4326 12.4326V8.53613H14.4326V12.4326C14.4326 13.2609 13.7609 13.9325 12.9326 13.9326H2.94922C2.12083 13.9326 1.44927 13.261 1.44922 12.4326V3.56738C1.44946 2.73916 2.12094 2.06738 2.94922 2.06738H5.54492Z',
600
+ fill: 'currentColor',
601
+ }),
602
+ h('path', { key: 'h', d: 'M9.75977 4.50208H14.5509', stroke: 'currentColor' }),
603
+ h('path', { key: 'v', d: 'M12.1492 6.89758L12.1492 2.10642', stroke: 'currentColor' }),
604
+ ], { size: 16 })
605
+ }
606
+
607
+ /** IconCheckOutlineRegular — completed. */
608
+ function CheckIcon() {
609
+ return iconFrame(h('path', {
610
+ d: 'M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4',
611
+ stroke: 'currentColor',
612
+ }), { size: 14 })
613
+ }
614
+
615
+ /** IconQuestionOutlineRegular — waiting for you. */
616
+ function QuestionIcon() {
617
+ return iconFrame([
618
+ h('path', {
619
+ key: 'circle',
620
+ d: 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
621
+ stroke: 'currentColor',
622
+ }),
623
+ h('path', {
624
+ key: 'curve',
625
+ d: 'M5.75 6.69646C5.75 6.29865 5.88196 5.90976 6.12919 5.57899C6.37643 5.24821 6.72783 4.99041 7.13896 4.83817C7.5501 4.68593 8.0025 4.6461 8.43895 4.72371C8.87541 4.80132 9.27632 4.99289 9.59099 5.27419C9.90566 5.55549 10.12 5.91388 10.2068 6.30406C10.2936 6.69423 10.249 7.09866 10.0787 7.4662C9.90843 7.83373 9.62004 8.14787 9.25003 8.36889C9.19476 8.4019 9.13803 8.43262 9.08004 8.46099C8.52566 8.73217 8 9.20817 8 9.82532',
626
+ stroke: 'currentColor',
627
+ }),
628
+ h('path', { key: 'dot', d: 'M8 10.7416V11.7416', stroke: 'currentColor' }),
629
+ ], { size: 14 })
630
+ }
631
+
632
+ /** IconWarningOutlineRegular — failure or interruption. */
633
+ function WarningIcon() {
634
+ return iconFrame([
635
+ h('path', {
636
+ key: 'circle',
637
+ d: 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
638
+ stroke: 'currentColor',
639
+ }),
640
+ h('path', { key: 'bar', d: 'M8 4.29199V9.79199', stroke: 'currentColor' }),
641
+ h('path', { key: 'dot', d: 'M8 10.708V11.708', stroke: 'currentColor' }),
642
+ ], { size: 14 })
643
+ }
644
+
645
+ /**
646
+ * One title line: the outer span clips, the inner span scrolls on hover.
647
+ * The marquee only starts for text that does not fit, so short names stay
648
+ * still and keep their ellipsis when narrowed.
649
+ */
650
+ function Title(props) {
651
+ const className = props.className ?? 'dfs-title'
652
+ return h('span', { className, title: props.title ?? props.text },
653
+ h('span', { className: 'dfs-titleText' }, props.text))
654
+ }
655
+
656
+ class RenderBoundary extends React.Component {
657
+ constructor(props) {
658
+ super(props)
659
+ this.state = { message: null }
660
+ }
661
+
662
+ static getDerivedStateFromError(error) {
663
+ return { message: error instanceof Error ? error.message : String(error) }
664
+ }
665
+
666
+ componentDidCatch(error) {
667
+ console.error('[folder-sidebar] render failed', error)
668
+ }
669
+
670
+ render() {
671
+ if (this.state.message !== null) {
672
+ return h('div', {
673
+ className: 'dfs-empty',
674
+ style: { color: 'var(--dsw-alias-state-error-primary)' },
675
+ }, 'folder-sidebar: ', this.state.message)
676
+ }
677
+ return this.props.children
678
+ }
679
+ }
680
+
681
+
682
+
683
+
684
+ return {
685
+ inject: ['slots', 'locale', 'uiWorkspace', 'workspaces', 'sessions'],
686
+ apply(ctx) {
687
+ ctx.effect(() => ctx.locale.register(NS, DICTS))
688
+ const t = ctx.locale.bind(NS)
689
+
690
+ /**
691
+ * The leading status cell: a fixed 16px slot on every Session row so
692
+ * titles stay aligned whether or not the row has a state. Completed,
693
+ * waiting and failure reuse the shipped glyphs; running is the spinner.
694
+ */
695
+ function statusIcon(kind) {
696
+ if (kind === 'completed') return CheckIcon
697
+ if (kind === 'waiting') return QuestionIcon
698
+ if (kind === 'error') return WarningIcon
699
+ return undefined
700
+ }
701
+
702
+ function Indicator({ kind, title }) {
703
+ const Glyph = statusIcon(kind)
704
+ if (Glyph !== undefined) {
705
+ return h('span', {
706
+ className: 'dfs-status dfs-glyph',
707
+ style: { color: GLYPH_COLOR[kind] },
708
+ title,
709
+ 'aria-label': title,
710
+ role: 'img',
711
+ }, h(Glyph))
712
+ }
713
+ if (kind === undefined) {
714
+ return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
715
+ h('span', { className: 'dfs-statusDot is-none' }))
716
+ }
717
+ if (kind === 'running') {
718
+ return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
719
+ h('span', { className: 'dfs-spinner' }))
720
+ }
721
+ return h('span', { className: 'dfs-status', title, 'aria-label': title, role: 'img' },
722
+ h('span', { className: `dfs-statusDot is-${kind}` }))
723
+ }
724
+
725
+ /** A Workspace row's single status cell: the folder icon itself. */
726
+ function FolderGlyph({ kind, label }) {
727
+ const color = kind === 'error' ? 'var(--dsw-alias-state-error-primary)'
728
+ : kind === 'waiting' ? 'var(--dsw-alias-state-warn-primary)'
729
+ : kind === 'completed' ? 'var(--dsw-alias-state-success-primary)'
730
+ : kind === 'stopped' ? 'var(--dsw-alias-state-idle-primary)'
731
+ : 'var(--dsw-alias-label-secondary)'
732
+ const Glyph = statusIcon(kind)
733
+ if (Glyph !== undefined) {
734
+ return h('span', {
735
+ className: 'dfs-status dfs-statusFolder dfs-glyph',
736
+ style: { color: GLYPH_COLOR[kind] },
737
+ title: label,
738
+ 'aria-label': label,
739
+ role: 'img',
740
+ }, h(Glyph))
741
+ }
742
+ if (kind === 'running') {
743
+ return h('span', {
744
+ className: 'dfs-status dfs-statusFolder',
745
+ title: label,
746
+ 'aria-label': label,
747
+ role: 'img',
748
+ }, h('span', { className: 'dfs-spinner' }))
749
+ }
750
+ return h('span', {
751
+ className: 'dfs-status dfs-statusFolder',
752
+ title: label,
753
+ 'aria-label': label,
754
+ role: 'img',
755
+ }, h('span', { className: 'dfs-folderGlyph' }, h(FolderIcon, { color })))
756
+ }
757
+
758
+ function FolderSidebar(props) {
759
+ const wide = props.wide !== false
760
+ const expandSidebar = props.expandSidebar
761
+ const useWorkspaces = props.useWorkspaces
762
+ const useSessions = props.useSessions
763
+ const useSessionStatus = props.useSessionStatus
764
+
765
+ const [openId, setOpenId] = React.useState(null)
766
+ const [query, setQuery] = React.useState('')
767
+ const [menuFor, setMenuFor] = React.useState(null)
768
+ const [rename, setRename] = React.useState(null)
769
+ const [confirm, setConfirm] = React.useState(null)
770
+ const [archivedFilter, setArchivedFilter] = React.useState(() => readArchivedFilter())
771
+ const [notice, setNotice] = React.useState(null)
772
+ const [contentHits, setContentHits] = React.useState([])
773
+ const [searching, setSearching] = React.useState(false)
774
+ const [hoverFor, setHoverFor] = React.useState(null)
775
+ const [searchOpen, setSearchOpen] = React.useState(false)
776
+ const searchInput = React.useRef(null)
777
+ const hoverTimer = React.useRef(undefined)
778
+ const [sortState, setSortState] = React.useState(() => readSortState())
779
+ const folderSort = sortState.folders
780
+ const sessionSort = sortState.sessions
781
+
782
+ const workspaceItems = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.items) : undefined
783
+ const archivedIds = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.archivedSessionIds) : undefined
784
+ const pinnedIds = typeof useWorkspaces === 'function' ? useWorkspaces((state) => state.pinnedSessionIds) : undefined
785
+ const list = typeof useSessions === 'function' ? useSessions((state) => state) : undefined
786
+ const projections = typeof useSessions === 'function' ? useSessions((state) => state.projectionsBySession) : undefined
787
+ const statusMap = typeof useSessionStatus === 'function' ? useSessionStatus((state) => state) : undefined
788
+
789
+ const workspaces = Array.isArray(workspaceItems) ? workspaceItems : []
790
+ const archived = React.useMemo(() => new Set(archivedIds ?? []), [archivedIds])
791
+ const pinned = React.useMemo(() => new Set(pinnedIds ?? []), [pinnedIds])
792
+
793
+ const statusOf = React.useCallback((sessionId) => {
794
+ if (statusMap === undefined || statusMap === null) return undefined
795
+ return typeof statusMap.get === 'function' ? statusMap.get(sessionId) : undefined
796
+ }, [statusMap])
797
+
798
+ const projectionOf = React.useCallback((sessionId) => {
799
+ if (projections === undefined || projections === null) return undefined
800
+ const cell = projections[sessionId]
801
+ const values = cell === undefined || cell === null ? undefined : cell.values
802
+ return values === undefined || values === null ? undefined : values[TURN_KEY]
803
+ }, [projections])
804
+
805
+ const currentId = React.useMemo(() => {
806
+ const byId = list === undefined || list === null ? undefined : list.byId
807
+ if (byId === undefined || byId === null) return undefined
808
+ for (const id of Object.keys(byId)) {
809
+ const summary = byId[id]
810
+ if (summary !== null && typeof summary === 'object' && (summary.retainedBy?.mainView ?? 0) > 0) return id
811
+ }
812
+ return undefined
813
+ }, [list])
814
+
815
+ const currentWorkspaceId = React.useMemo(() => {
816
+ if (currentId === undefined) return undefined
817
+ for (const workspace of workspaces) {
818
+ if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(currentId)) return workspace.workspaceId
819
+ }
820
+ return undefined
821
+ }, [workspaces, currentId])
822
+
823
+ React.useEffect(() => {
824
+ if (currentWorkspaceId === undefined) return
825
+ setOpenId((previous) => (previous === null ? currentWorkspaceId : previous))
826
+ }, [currentWorkspaceId])
827
+
828
+ // Close transient UI when the region loses the selection it belongs to.
829
+ React.useEffect(() => {
830
+ if (menuFor === null) return undefined
831
+ const onDown = (event) => {
832
+ const target = event.target
833
+ if (target === null || typeof target.closest !== 'function') { setMenuFor(null); return }
834
+ // A press inside the panel must reach the item; a press on the
835
+ // trigger is the trigger's own toggle. Anything else dismisses.
836
+ const insideMenu = target.closest('[data-dfs-menu]') !== null
837
+ const onTrigger = target.closest('[data-dfs-menu-trigger]') !== null
838
+ if (!insideMenu && !onTrigger) setMenuFor(null)
839
+ }
840
+ const onKey = (event) => { if (event.key === 'Escape') setMenuFor(null) }
841
+ const onScroll = () => setMenuFor(null)
842
+ document.addEventListener('pointerdown', onDown)
843
+ document.addEventListener('keydown', onKey)
844
+ document.addEventListener('scroll', onScroll, true)
845
+ return () => {
846
+ document.removeEventListener('pointerdown', onDown)
847
+ document.removeEventListener('keydown', onKey)
848
+ document.removeEventListener('scroll', onScroll, true)
849
+ }
850
+ }, [menuFor])
851
+
852
+ /** One transient bar; an optional action keeps "撤销" reachable. */
853
+ const notify = (message, action) => setNotice({ message, action: action ?? null })
854
+
855
+ React.useEffect(() => {
856
+ if (notice === null) return undefined
857
+ const timer = setTimeout(() => setNotice(null), notice.action === null ? 2200 : 6000)
858
+ return () => clearTimeout(timer)
859
+ }, [notice])
860
+
861
+ // Host content search, debounced; titles stay local and instant.
862
+ React.useEffect(() => {
863
+ const text = query.trim()
864
+ if (text === '' || typeof ctx.sessions?.search !== 'function') {
865
+ setContentHits([])
866
+ setSearching(false)
867
+ return undefined
868
+ }
869
+ const controller = new AbortController()
870
+ setSearching(true)
871
+ const timer = setTimeout(() => {
872
+ Promise.resolve(ctx.sessions.search(text, controller.signal))
873
+ .then((result) => {
874
+ if (controller.signal.aborted) return
875
+ const items = result?.ok === true && Array.isArray(result.value?.items) ? result.value.items : []
876
+ setContentHits(items)
877
+ setSearching(false)
878
+ })
879
+ .catch(() => { if (!controller.signal.aborted) { setContentHits([]); setSearching(false) } })
880
+ }, 250)
881
+ return () => {
882
+ clearTimeout(timer)
883
+ controller.abort()
884
+ setSearching(false)
885
+ }
886
+ }, [query])
887
+
888
+ /** Sessions that belong to no Workspace (shipped "Ungrouped" bucket). */
889
+ const ungroupedWorkspace = React.useMemo(() => {
890
+ const byId = list === undefined || list === null ? undefined : list.byId
891
+ const ids = list === undefined || list === null || !Array.isArray(list.ids) ? [] : list.ids
892
+ const accounted = new Set()
893
+ for (const workspace of workspaces) {
894
+ for (const id of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) accounted.add(id)
895
+ }
896
+ const members = []
897
+ for (const id of ids) {
898
+ const summary = byId === undefined || byId === null ? undefined : byId[id]
899
+ if (summary === null || typeof summary !== 'object') continue
900
+ if (accounted.has(id)) continue
901
+ if (summary.origin === 'subagent') continue
902
+ members.push(summary)
903
+ }
904
+ return { workspaceId: UNGROUPED, path: '', title: t('group.ungrouped'), sessionIds: members.map((summary) => summary.id) }
905
+ }, [list, workspaces, t])
906
+
907
+ /**
908
+ * Rows per Workspace, derived once per render instead of once per
909
+ * caller: the list, its status cell and the hover card all read it.
910
+ */
911
+ const rowsIndex = React.useMemo(() => {
912
+ const byId = list === undefined || list === null ? undefined : list.byId
913
+ const collect = (ids) => {
914
+ const rows = []
915
+ for (const id of Array.isArray(ids) ? ids : []) {
916
+ const summary = byId === undefined || byId === null ? undefined : byId[id]
917
+ if (summary === null || typeof summary !== 'object') continue
918
+ if (summary.origin === 'subagent') continue
919
+ const isArchived = archived.has(id)
920
+ if (archivedFilter === 'hide' && isArchived) continue
921
+ if (archivedFilter === 'only' && !isArchived) continue
922
+ if (summary.blank === true && id !== currentId) continue
923
+ rows.push(summary)
924
+ }
925
+ rows.sort((a, b) => {
926
+ const pinDelta = (pinned.has(b.id) ? 1 : 0) - (pinned.has(a.id) ? 1 : 0)
927
+ if (pinDelta !== 0) return pinDelta
928
+ return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
929
+ })
930
+ return rows
931
+ }
932
+ const index = new Map()
933
+ for (const workspace of workspaces) index.set(workspace.workspaceId, collect(workspace.sessionIds))
934
+ index.set(UNGROUPED, collect(ungroupedWorkspace.sessionIds))
935
+ return index
936
+ }, [list, archived, pinned, currentId, archivedFilter, workspaces, ungroupedWorkspace])
937
+
938
+ const rowsFor = (workspace) => rowsIndex.get(workspace.workspaceId) ?? []
939
+
940
+
941
+ const workspaceOf = React.useCallback((sessionId) => {
942
+ for (const workspace of workspaces) {
943
+ if (Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) return workspace
944
+ }
945
+ return undefined
946
+ }, [workspaces])
947
+
948
+ const titleOf = React.useCallback((summary) => {
949
+ return typeof summary.displayTitle === 'string' && summary.displayTitle !== ''
950
+ ? summary.displayTitle
951
+ : t('session.untitled')
952
+ }, [t])
953
+
954
+ const displayNameOf = (workspace) => (typeof workspace.title === 'string' && workspace.title !== ''
955
+ ? workspace.title
956
+ : baseName(workspace.path))
957
+
958
+ /** Newest Session activity in a Workspace, used by the "recent" sort. */
959
+ const lastActivityOf = (workspace) => {
960
+ const byId = list === undefined || list === null ? undefined : list.byId
961
+ let latest = 0
962
+ for (const id of Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []) {
963
+ const summary = byId === undefined || byId === null ? undefined : byId[id]
964
+ const time = summary === undefined || summary === null ? undefined : summary.updatedAt
965
+ if (typeof time === 'number' && time > latest) latest = time
966
+ }
967
+ return latest
968
+ }
969
+
970
+ const orderedWorkspaces = React.useMemo(
971
+ () => sortWorkspaces(workspaces, folderSort, { nameOf: displayNameOf, activityOf: lastActivityOf }),
972
+ [workspaces, folderSort, list],
973
+ )
974
+
975
+ /** Pinned rows stay first in every mode; direction applies inside groups. */
976
+ /** Session ordering for the active level (pure comparator lives up top). */
977
+ const orderSessions = (rows) => sortSessions(rows, sessionSort, {
978
+ titleOf,
979
+ isPinned: (row) => pinned.has(row.id),
980
+ updatedAtOf: (row) => row.updatedAt,
981
+ })
982
+
983
+ /** The level in view owns the sort: folders outside, Sessions inside. */
984
+ const activeLevel = openId === null ? 'folders' : 'sessions'
985
+ const activeSort = sortState[activeLevel]
986
+
987
+ // Folder drag-and-drop: only meaningful when the Host order is the
988
+ // rendered order (manual + ascending) and the flat list is visible.
989
+ const [dragId, setDragId] = React.useState(null)
990
+ const [dropEdge, setDropEdge] = React.useState(null)
991
+ const canReorder = folderSort.mode === 'manual' && folderSort.dir === 'asc'
992
+ && openId === null && query.trim() === ''
993
+ const clearDrag = () => {
994
+ setDragId(null)
995
+ setDropEdge(null)
996
+ }
997
+
998
+ React.useEffect(() => {
999
+ if (!canReorder) clearDrag()
1000
+ }, [canReorder])
1001
+
1002
+ /** Land one Workspace before a Host-order anchor (`undefined` appends). */
1003
+ const dropOn = (targetId, edge) => {
1004
+ if (dragId === null || dragId === targetId) { clearDrag(); return }
1005
+ const ids = workspaces.map((workspace) => workspace.workspaceId)
1006
+ const targetIndex = ids.indexOf(targetId)
1007
+ if (targetIndex < 0) { clearDrag(); return }
1008
+ const beforeId = edge === 'before' ? ids[targetIndex] : ids[targetIndex + 1]
1009
+ // Dropping onto the neighbour it already precedes is a no-op.
1010
+ if (beforeId !== dragId) moveWorkspace(dragId, beforeId)
1011
+ clearDrag()
1012
+ }
1013
+
1014
+ const commitSort = (patch) => {
1015
+ const next = {
1016
+ ...sortState,
1017
+ [activeLevel]: { ...sortState[activeLevel], ...patch },
1018
+ }
1019
+ setSortState(next)
1020
+ writeSortState(next)
1021
+ setMenuFor(null)
1022
+ }
1023
+
1024
+ const chooseMode = (mode) => commitSort({ mode, dir: SORT_NATURAL_DIR[mode] })
1025
+
1026
+ const modeItems = () => SORT_MODES.map((mode) => ({
1027
+ key: `mode:${mode}`,
1028
+ label: t(`sort.${mode}`),
1029
+ check: mode === activeSort.mode,
1030
+ onSelect: () => chooseMode(mode),
1031
+ }))
1032
+
1033
+ const dirItems = () => SORT_DIRS.map((dir) => ({
1034
+ key: `dir:${dir}`,
1035
+ label: `${t(`sort.${dir}`)} ${dir === 'desc' ? '↓' : '↑'}`,
1036
+ check: dir === activeSort.dir,
1037
+ onSelect: () => commitSort({ dir }),
1038
+ }))
1039
+
1040
+ const folderKindOf = React.useCallback((workspace) => {
1041
+ const candidates = []
1042
+ for (const summary of rowsFor(workspace)) {
1043
+ const kind = indicatorOf(statusOf(summary.id), projectionOf(summary.id))
1044
+ if (kind === undefined) continue
1045
+ const projection = projectionOf(summary.id)
1046
+ const since = projection !== undefined && projection !== null
1047
+ && typeof projection.since === 'number' && projection.since > 0
1048
+ ? projection.since
1049
+ : (summary.updatedAt ?? 0)
1050
+ candidates.push({ kind, since })
1051
+ }
1052
+ return pickFolderKind(candidates)
1053
+ }, [rowsFor, statusOf, projectionOf])
1054
+
1055
+ const labels = {}
1056
+ for (const kind of INDICATOR_PRIORITY) labels[kind] = t(INDICATOR_LABEL_KEY[kind])
1057
+
1058
+ /** The shipped browser names the exact interaction; mirror that. */
1059
+ const waitingLabel = (status) => {
1060
+ const kind = status !== undefined && status !== null && status.pendingInteraction !== undefined && status.pendingInteraction !== null
1061
+ ? status.pendingInteraction.kind
1062
+ : undefined
1063
+ if (kind === 'approval') return t('status.waitingApproval')
1064
+ if (kind === 'plan-review') return t('status.waitingPlanReview')
1065
+ if (kind === 'question') return t('status.waitingAnswer')
1066
+ return t('status.waiting')
1067
+ }
1068
+
1069
+ /** Every status one Session carries, for the hover card. */
1070
+ const statusRowsOf = (status, projection, updatedAt) => {
1071
+ const rows = []
1072
+ if (status !== undefined && status !== null
1073
+ && status.pendingInteraction !== undefined && status.pendingInteraction !== null) {
1074
+ rows.push({ key: 'waiting', kind: 'waiting', label: waitingLabel(status) })
1075
+ }
1076
+ if (status !== undefined && status !== null && status.running === true) {
1077
+ rows.push({ key: 'running', kind: 'running', label: labels.running })
1078
+ }
1079
+ const state = projection === undefined || projection === null ? undefined : projection.state
1080
+ if (state === 'failed' || state === 'interrupted') rows.push({ key: 'error', kind: 'error', label: labels.error })
1081
+ if (status !== undefined && status !== null && status.completionUnread === true) {
1082
+ rows.push({ key: 'completed', kind: 'completed', label: labels.completed })
1083
+ }
1084
+ if (state === 'stopped') rows.push({ key: 'stopped', kind: 'stopped', label: labels.stopped })
1085
+ if (rows.length === 0) rows.push({ key: 'idle', kind: undefined, label: t('status.idle') })
1086
+ rows.push({ key: 'time', kind: undefined, label: relativeTime(updatedAt) })
1087
+ return rows
1088
+ }
1089
+
1090
+ /** Which states a Workspace contains and how many Sessions carry each. */
1091
+ const folderStatusRows = (workspace) => {
1092
+ const counts = new Map()
1093
+ for (const summary of rowsFor(workspace)) {
1094
+ const kind = indicatorOf(statusOf(summary.id), projectionOf(summary.id))
1095
+ if (kind === undefined) continue
1096
+ counts.set(kind, (counts.get(kind) ?? 0) + 1)
1097
+ }
1098
+ const rows = []
1099
+ for (const kind of INDICATOR_PRIORITY) {
1100
+ const count = counts.get(kind)
1101
+ if (count === undefined) continue
1102
+ rows.push({ key: kind, kind, label: `${labels[kind]} ×${count}` })
1103
+ }
1104
+ if (rows.length === 0) rows.push({ key: 'idle', kind: undefined, label: t('status.idle') })
1105
+ return rows
1106
+ }
1107
+
1108
+ const cancelHover = () => {
1109
+ if (hoverTimer.current !== undefined) {
1110
+ clearTimeout(hoverTimer.current)
1111
+ hoverTimer.current = undefined
1112
+ }
1113
+ setHoverFor(null)
1114
+ }
1115
+
1116
+ const scheduleHover = (key, rows, event) => {
1117
+ if (menuFor !== null || dragId !== null) return
1118
+ if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current)
1119
+ const rect = event.currentTarget.getBoundingClientRect()
1120
+ hoverTimer.current = setTimeout(() => {
1121
+ hoverTimer.current = undefined
1122
+ const width = 200
1123
+ const height = rows.length * 20 + 16
1124
+ const viewportW = typeof window === 'undefined' ? 1280 : window.innerWidth
1125
+ const viewportH = typeof window === 'undefined' ? 800 : window.innerHeight
1126
+ setHoverFor({
1127
+ key,
1128
+ rows,
1129
+ left: Math.max(8, Math.min(rect.left + 8, viewportW - width - 8)),
1130
+ top: Math.max(8, Math.min(rect.bottom + 6, viewportH - height - 8)),
1131
+ })
1132
+ }, 350)
1133
+ }
1134
+
1135
+ React.useEffect(() => () => {
1136
+ if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current)
1137
+ }, [])
1138
+
1139
+ /** Reveal a clipped title by scrolling it while the row is hovered. */
1140
+ const startMarquee = (event) => {
1141
+ const row = event.currentTarget
1142
+ if (row === null || typeof row.querySelector !== 'function') return
1143
+ const text = row.querySelector('.dfs-titleText')
1144
+ const box = text === null ? null : text.parentElement
1145
+ if (text === null || box === null) return
1146
+ const shift = marqueeShiftOf(text.scrollWidth, box.clientWidth)
1147
+ if (shift === 0) return
1148
+ text.style.setProperty('--dfs-marquee-shift', `-${shift}px`)
1149
+ text.style.setProperty('--dfs-marquee-duration', `${marqueeDurationOf(shift)}ms`)
1150
+ box.classList.add('is-marquee')
1151
+ }
1152
+
1153
+ const stopMarquee = (event) => {
1154
+ const row = event.currentTarget
1155
+ if (row === null || typeof row.querySelector !== 'function') return
1156
+ const text = row.querySelector('.dfs-titleText')
1157
+ const box = text === null ? null : text.parentElement
1158
+ if (text === null || box === null) return
1159
+ box.classList.remove('is-marquee')
1160
+ text.style.removeProperty('--dfs-marquee-shift')
1161
+ text.style.removeProperty('--dfs-marquee-duration')
1162
+ }
1163
+
1164
+
1165
+ React.useEffect(() => {
1166
+ if (hoverFor === null) return undefined
1167
+ const onScroll = () => setHoverFor(null)
1168
+ document.addEventListener('scroll', onScroll, true)
1169
+ return () => document.removeEventListener('scroll', onScroll, true)
1170
+ }, [hoverFor])
1171
+
1172
+ const openSession = (sessionId) => {
1173
+ setMenuFor(null)
1174
+ setQuery('')
1175
+ setSearchOpen(false)
1176
+ if (typeof ctx.uiWorkspace?.openSession === 'function') ctx.uiWorkspace.openSession(sessionId)
1177
+ }
1178
+
1179
+ const startSession = (workspaceId) => {
1180
+ setMenuFor(null)
1181
+ if (typeof ctx.uiWorkspace?.startSession === 'function') ctx.uiWorkspace.startSession(workspaceId)
1182
+ }
1183
+
1184
+ const addWorkspace = () => {
1185
+ const pickDirectory = ctx.uiWorkspace?.pickDirectory
1186
+ if (typeof pickDirectory !== 'function') return
1187
+ Promise.resolve(pickDirectory.call(ctx.uiWorkspace))
1188
+ .then((path) => {
1189
+ if (typeof path !== 'string' || path === '') return undefined
1190
+ return ctx.workspaces?.create?.({ path })
1191
+ })
1192
+ .catch((error) => reportFailure(error, 'notice.addWorkspaceFailed'))
1193
+ }
1194
+
1195
+ /** Every failed action is both logged and shown; silence hides bugs. */
1196
+ const reportFailure = (error, messageKey) => {
1197
+ console.warn('[folder-sidebar] action failed', error)
1198
+ notify(t(messageKey))
1199
+ }
1200
+
1201
+ const run = (promise, messageKey) => {
1202
+ Promise.resolve(promise).catch((error) => reportFailure(error, messageKey))
1203
+ }
1204
+
1205
+ const togglePin = (sessionId) => {
1206
+ setMenuFor(null)
1207
+ const call = pinned.has(sessionId) ? ctx.uiWorkspace?.unpinSession : ctx.uiWorkspace?.pinSession
1208
+ if (typeof call !== 'function') return
1209
+ run(call.call(ctx.uiWorkspace, sessionId), 'notice.pinFailed')
1210
+ }
1211
+
1212
+ const archiveNow = (sessionId, stopActivity) => {
1213
+ setConfirm(null)
1214
+ setMenuFor(null)
1215
+ const archive = ctx.uiWorkspace?.archiveSession
1216
+ if (typeof archive !== 'function') return
1217
+ Promise.resolve(archive.call(ctx.uiWorkspace, sessionId, stopActivity === true ? { stopActivity: true } : undefined))
1218
+ .then(() => notify(t('notice.archived'), { label: t('action.undo'), onSelect: () => unarchive(sessionId) }))
1219
+ .catch((error) => {
1220
+ const code = error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : ''
1221
+ // The Host refuses a session with running work unless told to stop it.
1222
+ if (code.includes('session-active') || String(error?.message ?? '').includes('session-active')) setConfirm({ kind: 'archive', sessionId })
1223
+ else reportFailure(error, 'notice.archiveFailed')
1224
+ })
1225
+ }
1226
+
1227
+ const fork = (sessionId) => {
1228
+ setMenuFor(null)
1229
+ const call = ctx.uiWorkspace?.forkSession
1230
+ if (typeof call === 'function') run(call.call(ctx.uiWorkspace, sessionId), 'notice.forkFailed')
1231
+ }
1232
+
1233
+ const copyId = (sessionId) => {
1234
+ setMenuFor(null)
1235
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
1236
+ if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
1237
+ Promise.resolve(clipboard.writeText(sessionId))
1238
+ .then(() => notify(t('copied')))
1239
+ .catch(() => {})
1240
+ }
1241
+
1242
+ const startRenameSession = (summary) => {
1243
+ setMenuFor(null)
1244
+ setRename({ kind: 'session', id: summary.id, value: titleOf(summary) })
1245
+ }
1246
+
1247
+ const startRenameWorkspace = (workspace) => {
1248
+ setMenuFor(null)
1249
+ setRename({ kind: 'workspace', id: workspace.workspaceId, value: workspace.title || baseName(workspace.path) })
1250
+ }
1251
+
1252
+ const commitRename = () => {
1253
+ const draft = rename
1254
+ if (draft === null) return
1255
+ setRename(null)
1256
+ const value = typeof draft.value === 'string' ? draft.value.trim() : ''
1257
+ if (value === '') return
1258
+ if (draft.kind === 'workspace') {
1259
+ run(ctx.workspaces?.rename?.(draft.id, value), 'notice.renameFailed')
1260
+ return
1261
+ }
1262
+ const sessions = ctx.sessions
1263
+ if (sessions === undefined || typeof sessions.using !== 'function') return
1264
+ Promise.resolve(sessions.using(draft.id, { source: 'workspaceOperation' }, (reference) => reference.binding.session.rename(value)))
1265
+ .then((result) => {
1266
+ if (result !== null && typeof result === 'object' && result.ok === false) throw new Error(result.error?.message ?? 'rename failed')
1267
+ })
1268
+ .catch((error) => reportFailure(error, 'notice.renameFailed'))
1269
+ }
1270
+
1271
+ const deleteWorkspace = (workspaceId) => {
1272
+ setConfirm(null)
1273
+ setMenuFor(null)
1274
+ const remove = ctx.workspaces?.delete
1275
+ if (typeof remove !== 'function') return
1276
+ Promise.resolve(remove.call(ctx.workspaces, workspaceId))
1277
+ .then(() => setOpenId(null))
1278
+ .catch((error) => reportFailure(error, 'notice.deleteFailed'))
1279
+ }
1280
+
1281
+ /** Move one Workspace before another; `undefined` appends. */
1282
+ const moveWorkspace = (workspaceId, beforeId) => {
1283
+ setMenuFor(null)
1284
+ const insertBefore = ctx.workspaces?.insertBefore
1285
+ if (typeof insertBefore !== 'function') return
1286
+ run(insertBefore.call(ctx.workspaces, workspaceId, beforeId), 'notice.moveFailed')
1287
+ }
1288
+
1289
+ // ----- rename input -----
1290
+ const renameInput = (draft) => h('input', {
1291
+ key: 'rename',
1292
+ className: 'dfs-input',
1293
+ value: draft.value,
1294
+ autoFocus: true,
1295
+ onClick: (event) => event.stopPropagation(),
1296
+ onContextMenu: (event) => event.stopPropagation(),
1297
+ onChange: (event) => setRename({ ...draft, value: event.target.value }),
1298
+ onKeyDown: (event) => {
1299
+ if (event.key === 'Enter') { event.preventDefault(); commitRename() }
1300
+ if (event.key === 'Escape') { event.preventDefault(); setRename(null) }
1301
+ },
1302
+ })
1303
+
1304
+ // ----- menu -----
1305
+ // Rendered once at the root as a fixed layer: a panel inside the
1306
+ // scrolling list would be clipped at the list's edge.
1307
+ const menuEl = menuFor === null ? null : h('div', {
1308
+ className: 'dfs-menu',
1309
+ 'data-dfs-menu': '1',
1310
+ role: 'menu',
1311
+ style: { left: menuFor.left, top: menuFor.top },
1312
+ }, menuFor.items.map((item) => (item.kind === 'divider'
1313
+ ? h('div', { key: item.key, className: 'dfs-menuDivider', role: 'separator' })
1314
+ : item.kind === 'caption'
1315
+ ? h('div', { key: item.key, className: 'dfs-menuCaption', role: 'presentation' }, item.label)
1316
+ : h('button', {
1317
+ key: item.key,
1318
+ type: 'button',
1319
+ role: item.check === undefined ? 'menuitem' : 'menuitemradio',
1320
+ 'aria-checked': item.check === undefined ? undefined : item.check,
1321
+ className: `dfs-menuItem${item.danger === true ? ' is-danger' : ''}`,
1322
+ onClick: (event) => { event.stopPropagation(); item.onSelect() },
1323
+ },
1324
+ item.check === undefined
1325
+ ? item.label
1326
+ : [
1327
+ h('span', { key: 'check', className: 'dfs-menuCheck', 'aria-hidden': true }, item.check ? '✓' : ''),
1328
+ h('span', { key: 'label', className: 'dfs-menuLabel' }, item.label),
1329
+ ]))))
1330
+
1331
+ const noticeEl = () => h('div', { className: 'dfs-notice', role: 'status' },
1332
+ h('span', { className: 'dfs-noticeText' }, notice.message),
1333
+ notice.action === null ? null : h('button', {
1334
+ key: 'action',
1335
+ type: 'button',
1336
+ className: 'dfs-noticeAction',
1337
+ onClick: () => { notice.action.onSelect(); setNotice(null) },
1338
+ }, notice.action.label))
1339
+
1340
+ const hoverEl = hoverFor === null ? null : h('div', {
1341
+ className: 'dfs-hover',
1342
+ role: 'tooltip',
1343
+ style: { left: hoverFor.left, top: hoverFor.top },
1344
+ }, hoverFor.rows.map((row) => h('div', { key: row.key, className: 'dfs-hoverRow' },
1345
+ row.kind === undefined
1346
+ ? h('span', { className: 'dfs-hoverSpacer' })
1347
+ : h(Indicator, { kind: row.kind, title: row.label }),
1348
+ h('span', { className: 'dfs-hoverLabel' }, row.label))))
1349
+
1350
+ /** Place the menu with the pointer or a trigger rect, kept in the viewport. */
1351
+ const showMenu = (key, items, x, y) => {
1352
+ cancelHover()
1353
+ const width = 168
1354
+ const height = items.length * 26 + 10
1355
+ const viewportW = typeof window === 'undefined' ? 1280 : window.innerWidth
1356
+ const viewportH = typeof window === 'undefined' ? 800 : window.innerHeight
1357
+ setMenuFor({
1358
+ key,
1359
+ items,
1360
+ left: Math.max(8, Math.min(x, viewportW - width - 8)),
1361
+ top: Math.max(8, Math.min(y, viewportH - height - 8)),
1362
+ })
1363
+ }
1364
+
1365
+ const toggleMenuAt = (key, items, event) => {
1366
+ event.stopPropagation()
1367
+ if (menuFor !== null && menuFor.key === key) { setMenuFor(null); return }
1368
+ const rect = event.currentTarget.getBoundingClientRect()
1369
+ showMenu(key, items, rect.right - 168, rect.bottom + 4)
1370
+ }
1371
+
1372
+ const contextMenuHandler = (key, items) => (event) => {
1373
+ event.preventDefault()
1374
+ event.stopPropagation()
1375
+ showMenu(key, items, event.clientX, event.clientY)
1376
+ }
1377
+
1378
+ const menuTrigger = (key, items) => h('button', {
1379
+ type: 'button',
1380
+ className: 'dfs-iconBtn',
1381
+ 'data-dfs-menu-trigger': '1',
1382
+ title: t('action.menu'),
1383
+ 'aria-label': t('action.menu'),
1384
+ 'aria-expanded': menuFor !== null && menuFor.key === key,
1385
+ onClick: (event) => toggleMenuAt(key, items, event),
1386
+ }, DotsIcon())
1387
+
1388
+ if (!wide) {
1389
+ return h('div', { className: 'dfs-rail' },
1390
+ h('button', {
1391
+ type: 'button', className: 'dfs-iconBtn', title: t('rail.expand'),
1392
+ 'aria-label': t('rail.expand'),
1393
+ onClick: () => { if (typeof expandSidebar === 'function') expandSidebar() },
1394
+ }, FolderIcon()))
1395
+ }
1396
+
1397
+ const body = [h('style', { key: 'style' }, CSS)]
1398
+
1399
+ // ----- search results replace both levels while a query exists -----
1400
+ const text = query.trim()
1401
+ if (text !== '') {
1402
+ const lower = text.toLowerCase()
1403
+ const byId = list === undefined || list === null ? undefined : list.byId
1404
+ const seen = new Set()
1405
+ const hits = []
1406
+ if (byId !== undefined && byId !== null) {
1407
+ for (const id of Object.keys(byId)) {
1408
+ const summary = byId[id]
1409
+ if (summary === null || typeof summary !== 'object') continue
1410
+ const isArchivedHit = archived.has(id)
1411
+ if (archivedFilter === 'hide' && isArchivedHit) continue
1412
+ if (archivedFilter === 'only' && !isArchivedHit) continue
1413
+ const workspace = workspaceOf(id) ?? ungroupedWorkspace
1414
+ const title = titleOf(summary)
1415
+ if (!title.toLowerCase().includes(lower)) continue
1416
+ seen.add(id)
1417
+ hits.push({ sessionId: id, title, workspace, snippet: undefined })
1418
+ }
1419
+ }
1420
+ for (const item of contentHits) {
1421
+ if (!seen.has(item.sessionId)) {
1422
+ const summary = byId === undefined || byId === null ? undefined : byId[item.sessionId]
1423
+ const workspace = workspaceOf(item.sessionId) ?? ungroupedWorkspace
1424
+ const isArchivedHit = archived.has(item.sessionId)
1425
+ if (summary === undefined) continue
1426
+ if (archivedFilter === 'hide' && isArchivedHit) continue
1427
+ if (archivedFilter === 'only' && !isArchivedHit) continue
1428
+ seen.add(item.sessionId)
1429
+ hits.push({ sessionId: item.sessionId, title: titleOf(summary), workspace, snippet: item.snippet })
1430
+ }
1431
+ }
1432
+ const resultRows = hits.slice(0, 30).map((hit) => h('div', { key: hit.sessionId, className: 'dfs-rowWrap' },
1433
+ h('div', {
1434
+ className: 'dfs-row', role: 'button', tabIndex: 0,
1435
+ onMouseEnter: startMarquee,
1436
+ onMouseLeave: stopMarquee,
1437
+ onClick: () => openSession(hit.sessionId),
1438
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSession(hit.sessionId) } },
1439
+ },
1440
+ h(Title, { text: hit.title }),
1441
+ h('span', { className: 'dfs-time' }, baseName(hit.workspace.path) || t('group.ungrouped')),
1442
+ hit.snippet === undefined ? null : h('span', { className: 'dfs-snippet' }, `${t('search.content')}: ${hit.snippet}`))))
1443
+ body.push(topBar())
1444
+ body.push(h('div', { key: 'list', className: 'dfs-list' },
1445
+ resultRows.length === 0 ? [h('div', { key: 'empty', className: 'dfs-empty' }, t('empty.search'))] : resultRows))
1446
+ return h('div', { className: 'dfs-root' }, body)
1447
+ }
1448
+
1449
+ /** Shared top bar for both levels: optional back, title, actions. */
1450
+ function headerBar(key, options) {
1451
+ const parts = []
1452
+ if (options.back === true) {
1453
+ parts.push(h('button', {
1454
+ key: 'back',
1455
+ type: 'button',
1456
+ className: 'dfs-iconBtn dfs-headBack',
1457
+ title: t('action.back'),
1458
+ 'aria-label': t('action.back'),
1459
+ onClick: () => { setOpenId(null); setQuery('') },
1460
+ }, h(BackIcon)))
1461
+ }
1462
+ parts.push(options.titleNode ?? h('span', { key: 'title', className: 'dfs-headTitle', title: options.title }, options.title))
1463
+ for (const action of options.actions ?? []) parts.push(action)
1464
+ return h('div', { key, className: 'dfs-head' }, parts)
1465
+ }
1466
+
1467
+ /** The search bar that overlays the header while expanded. */
1468
+ function searchBar(key) {
1469
+ return h('div', { key, className: 'dfs-search dfs-searchHeader', 'aria-hidden': searchOpen ? undefined : 'true' },
1470
+ h(SearchIcon, null),
1471
+ h('input', {
1472
+ ref: searchInput,
1473
+ tabIndex: searchOpen ? 0 : -1,
1474
+ value: query,
1475
+ placeholder: t('search.placeholder'),
1476
+ 'aria-label': t('action.search'),
1477
+ onChange: (event) => setQuery(event.target.value),
1478
+ onKeyDown: (event) => { if (event.key === 'Escape') closeSearch() },
1479
+ }),
1480
+ h('button', {
1481
+ key: 'close',
1482
+ type: 'button',
1483
+ tabIndex: searchOpen ? 0 : -1,
1484
+ className: 'dfs-iconBtn',
1485
+ title: t('action.close'),
1486
+ 'aria-label': t('action.close'),
1487
+ onClick: () => closeSearch(),
1488
+ }, h(CloseIcon)))
1489
+ }
1490
+
1491
+ const closeSearch = () => {
1492
+ setSearchOpen(false)
1493
+ setQuery('')
1494
+ }
1495
+
1496
+ React.useEffect(() => {
1497
+ if (!searchOpen) return undefined
1498
+ const node = searchInput.current
1499
+ if (node !== null && typeof node.focus === 'function') node.focus()
1500
+ return undefined
1501
+ }, [searchOpen])
1502
+
1503
+ /** The fixed top bar: identical on both levels, a home for actions. */
1504
+ function mainHeader(key) {
1505
+ return headerBar(key, {
1506
+ title: t('section.title'),
1507
+ actions: [
1508
+ h('button', {
1509
+ key: 'search',
1510
+ type: 'button',
1511
+ className: 'dfs-iconBtn',
1512
+ title: t('action.search'),
1513
+ 'aria-label': t('action.search'),
1514
+ onClick: (event) => { event.stopPropagation(); setSearchOpen(true) },
1515
+ }, SearchIcon()),
1516
+ h('button', {
1517
+ key: 'view',
1518
+ type: 'button',
1519
+ className: 'dfs-iconBtn',
1520
+ title: `${t('action.view')} · ${t(`sort.${activeSort.mode}`)} ${activeSort.dir === 'desc' ? '↓' : '↑'} · ${t(`view.${archivedFilter}`)}`,
1521
+ 'aria-label': t('action.view'),
1522
+ onClick: (event) => {
1523
+ event.stopPropagation()
1524
+ const rect = event.currentTarget.getBoundingClientRect()
1525
+ showMenu('view', viewMenuItems(), rect.right - 168, rect.bottom + 4)
1526
+ },
1527
+ }, h(SlidersIcon)),
1528
+ h('button', {
1529
+ key: 'add',
1530
+ type: 'button', className: 'dfs-iconBtn', title: t('action.addWorkspace'),
1531
+ 'aria-label': t('action.addWorkspace'), onClick: addWorkspace,
1532
+ }, h(ProjectAddIcon)),
1533
+ ],
1534
+ })
1535
+ }
1536
+
1537
+ /** One top bar: header and search overlay share a row so the switch animates. */
1538
+ function topBar() {
1539
+ return h('div', { key: 'topbar', className: 'dfs-topbar' + (searchOpen ? ' is-search' : '') },
1540
+ mainHeader('head'),
1541
+ searchBar('searchbar'))
1542
+ }
1543
+
1544
+ const unarchive = (sessionId) => {
1545
+ setMenuFor(null)
1546
+ const call = ctx.uiWorkspace?.unarchiveSession
1547
+ if (typeof call !== 'function') return
1548
+ Promise.resolve(call.call(ctx.uiWorkspace, sessionId))
1549
+ .catch((error) => reportFailure(error, 'notice.unarchiveFailed'))
1550
+ }
1551
+
1552
+ const applyArchivedFilter = (value) => {
1553
+ setArchivedFilter(value)
1554
+ writeArchivedFilter(value)
1555
+ setMenuFor(null)
1556
+ }
1557
+
1558
+ /** One view-options menu: ordering for the level in view, then visibility. */
1559
+ const viewMenuItems = () => ([
1560
+ { key: 'caption:sort', kind: 'caption', label: `${t('action.sort')} · ${activeLevel === 'folders' ? t('level.folders') : t('level.sessions')}` },
1561
+ ...modeItems(),
1562
+ ...dirItems(),
1563
+ { key: 'divider', kind: 'divider' },
1564
+ { key: 'caption:view', kind: 'caption', label: t('action.view') },
1565
+ ...ARCHIVED_FILTERS.map((value) => ({
1566
+ key: `view:${value}`,
1567
+ label: t(`view.${value}`),
1568
+ check: value === archivedFilter,
1569
+ onSelect: () => applyArchivedFilter(value),
1570
+ })),
1571
+ ])
1572
+
1573
+ const openWorkspace = openId === null
1574
+ ? undefined
1575
+ : (openId === UNGROUPED
1576
+ ? ungroupedWorkspace
1577
+ : workspaces.find((workspace) => workspace.workspaceId === openId))
1578
+
1579
+ if (openWorkspace === undefined) {
1580
+ const listRows = []
1581
+ orderedWorkspaces.forEach((workspace) => {
1582
+ const rows = rowsFor(workspace)
1583
+ if (archivedFilter === 'only' && rows.length === 0) return
1584
+ const name = displayNameOf(workspace)
1585
+ const isCurrent = workspace.workspaceId === currentWorkspaceId
1586
+ const renaming = rename !== null && rename.kind === 'workspace' && rename.id === workspace.workspaceId
1587
+ const menuKey = `workspace:${workspace.workspaceId}`
1588
+ const folderKind = folderKindOf(workspace)
1589
+ const hostIndex = workspaces.indexOf(workspace)
1590
+ const moveItems = []
1591
+ if (folderSort.mode === 'manual' && folderSort.dir === 'asc') {
1592
+ if (hostIndex > 0) {
1593
+ moveItems.push({
1594
+ key: 'up',
1595
+ label: t('action.moveUp'),
1596
+ onSelect: () => moveWorkspace(workspace.workspaceId, workspaces[hostIndex - 1].workspaceId),
1597
+ })
1598
+ }
1599
+ if (hostIndex < workspaces.length - 1) {
1600
+ const following = hostIndex + 2 < workspaces.length ? workspaces[hostIndex + 2].workspaceId : undefined
1601
+ moveItems.push({
1602
+ key: 'down',
1603
+ label: t('action.moveDown'),
1604
+ onSelect: () => moveWorkspace(workspace.workspaceId, following),
1605
+ })
1606
+ }
1607
+ }
1608
+ const folderMenu = [
1609
+ { key: 'rename', label: t('action.renameWorkspace'), onSelect: () => startRenameWorkspace(workspace) },
1610
+ { key: 'new', label: t('action.newSessionHere'), onSelect: () => startSession(workspace.workspaceId) },
1611
+ ...moveItems,
1612
+ { key: 'delete', label: t('action.deleteWorkspace'), danger: true, onSelect: () => { setMenuFor(null); setConfirm({ kind: 'deleteWorkspace', workspaceId: workspace.workspaceId }) } },
1613
+ ]
1614
+ listRows.push(h('div', {
1615
+ key: workspace.workspaceId,
1616
+ className: `dfs-rowWrap${dropEdge !== null && dropEdge.id === workspace.workspaceId ? ` is-drop-${dropEdge.edge}` : ''}`,
1617
+ draggable: canReorder,
1618
+ onDragStart: (event) => {
1619
+ if (!canReorder) return
1620
+ const target = event.target
1621
+ if (target !== null && typeof target.closest === 'function' && target.closest('button') !== null) {
1622
+ event.preventDefault()
1623
+ return
1624
+ }
1625
+ setDragId(workspace.workspaceId)
1626
+ try {
1627
+ event.dataTransfer.effectAllowed = 'move'
1628
+ event.dataTransfer.setData('text/plain', workspace.workspaceId)
1629
+ } catch {
1630
+ // Some environments expose a read-only dataTransfer.
1631
+ }
1632
+ },
1633
+ onDragOver: (event) => {
1634
+ if (!canReorder || dragId === null || dragId === workspace.workspaceId) return
1635
+ event.preventDefault()
1636
+ const rect = event.currentTarget.getBoundingClientRect()
1637
+ const edge = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
1638
+ setDropEdge((previous) => (previous !== null && previous.id === workspace.workspaceId && previous.edge === edge
1639
+ ? previous
1640
+ : { id: workspace.workspaceId, edge }))
1641
+ },
1642
+ onDragLeave: (event) => {
1643
+ if (event.currentTarget.contains(event.relatedTarget)) return
1644
+ setDropEdge((previous) => (previous !== null && previous.id === workspace.workspaceId ? null : previous))
1645
+ },
1646
+ onDrop: (event) => {
1647
+ if (!canReorder) return
1648
+ event.preventDefault()
1649
+ const edge = dropEdge !== null && dropEdge.id === workspace.workspaceId ? dropEdge.edge : 'before'
1650
+ dropOn(workspace.workspaceId, edge)
1651
+ },
1652
+ onDragEnd: clearDrag,
1653
+ },
1654
+ h('div', {
1655
+ className: `dfs-row${isCurrent ? ' is-current' : ''}`,
1656
+ role: 'button',
1657
+ tabIndex: 0,
1658
+ title: typeof workspace.path === 'string' && workspace.path !== '' ? workspace.path : name,
1659
+ onClick: () => { if (!renaming) setOpenId(workspace.workspaceId) },
1660
+ onContextMenu: contextMenuHandler(menuKey, folderMenu),
1661
+ onMouseEnter: (event) => {
1662
+ startMarquee(event)
1663
+ scheduleHover(menuKey, folderStatusRows(workspace), event)
1664
+ },
1665
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1666
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(workspace.workspaceId) } },
1667
+ },
1668
+ h(FolderGlyph, { kind: folderKind, label: folderKind === undefined ? undefined : labels[folderKind] }),
1669
+ renaming
1670
+ ? renameInput(rename)
1671
+ : h(Title, { text: name === '' ? t('section.title') : name }),
1672
+ h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1673
+ menuTrigger(menuKey, folderMenu)),
1674
+ rows.length > 0 ? h('span', { className: 'dfs-badge' }, String(rows.length)) : null,
1675
+ h(ChevronIcon, null)),
1676
+ confirm !== null && confirm.kind === 'deleteWorkspace' && confirm.workspaceId === workspace.workspaceId
1677
+ ? h('div', { className: 'dfs-confirm' },
1678
+ h('span', { className: 'dfs-confirmText' }, t('confirm.deleteWorkspace')),
1679
+ h('button', { type: 'button', className: 'dfs-miniBtn is-danger', onClick: () => deleteWorkspace(workspace.workspaceId) }, t('confirm.yes')),
1680
+ h('button', { type: 'button', className: 'dfs-miniBtn', onClick: () => setConfirm(null) }, t('confirm.cancel')))
1681
+ : null))
1682
+ })
1683
+ const ungroupedRows = rowsFor(ungroupedWorkspace)
1684
+ if (ungroupedRows.length > 0) {
1685
+ const menuKey = 'workspace:' + UNGROUPED
1686
+ listRows.push(h('div', { key: UNGROUPED, className: 'dfs-rowWrap' },
1687
+ h('div', {
1688
+ className: `dfs-row${openId === UNGROUPED ? ' is-current' : ''}`,
1689
+ role: 'button',
1690
+ tabIndex: 0,
1691
+ title: t('group.ungrouped'),
1692
+ onClick: () => setOpenId(UNGROUPED),
1693
+ onMouseEnter: (event) => {
1694
+ startMarquee(event)
1695
+ scheduleHover(menuKey, folderStatusRows(ungroupedWorkspace), event)
1696
+ },
1697
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1698
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(UNGROUPED) } },
1699
+ },
1700
+ h(FolderGlyph, { kind: folderKindOf(ungroupedWorkspace), label: undefined }),
1701
+ h(Title, { text: t('group.ungrouped') }),
1702
+ h('span', { className: 'dfs-badge' }, String(ungroupedRows.length)),
1703
+ h(ChevronIcon, null))))
1704
+ }
1705
+ if (listRows.length === 0) {
1706
+ listRows.push(h('div', { key: 'empty', className: 'dfs-empty' }, t(archivedFilter === 'only' ? 'empty.archived' : 'empty.workspaces')))
1707
+ }
1708
+ body.push(topBar())
1709
+ body.push(h('div', { key: 'list', className: 'dfs-list' }, listRows))
1710
+ return h('div', { className: 'dfs-root' }, body, menuEl, hoverEl, notice === null ? null : noticeEl())
1711
+ }
1712
+
1713
+ // ----- one Workspace's Session list -----
1714
+ const isUngrouped = openWorkspace.workspaceId === UNGROUPED
1715
+ const name = isUngrouped
1716
+ ? t('group.ungrouped')
1717
+ : (typeof openWorkspace.title === 'string' && openWorkspace.title !== ''
1718
+ ? openWorkspace.title
1719
+ : baseName(openWorkspace.path))
1720
+ const workspaceRenaming = rename !== null && rename.kind === 'workspace' && rename.id === openWorkspace.workspaceId
1721
+ body.push(topBar())
1722
+
1723
+ // The folder row sits below the search field: the whole row goes back
1724
+ // to the folder list, the trailing plus starts a Session here.
1725
+ body.push(h('div', {
1726
+ key: 'crumb',
1727
+ className: 'dfs-crumb',
1728
+ role: 'button',
1729
+ tabIndex: 0,
1730
+ title: t('action.back'),
1731
+ onMouseEnter: startMarquee,
1732
+ onMouseLeave: stopMarquee,
1733
+ onClick: () => { setOpenId(null); setQuery('') },
1734
+ onKeyDown: (event) => {
1735
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(null); setQuery('') }
1736
+ },
1737
+ },
1738
+ h('span', { className: 'dfs-crumbChevron', 'aria-hidden': true }, h(BackIcon)),
1739
+ h(FolderIcon, null),
1740
+ workspaceRenaming
1741
+ ? renameInput(rename)
1742
+ : h(Title, { className: 'dfs-crumbName', text: name }),
1743
+ isUngrouped ? null : h('button', {
1744
+ key: 'new',
1745
+ type: 'button', className: 'dfs-iconBtn', title: t('action.newSession'),
1746
+ 'aria-label': t('action.newSession'),
1747
+ onClick: (event) => { event.stopPropagation(); startSession(openWorkspace.workspaceId) },
1748
+ }, h(NewChatIcon))))
1749
+
1750
+ const rows = orderSessions(rowsFor(openWorkspace))
1751
+ const sessionRows = []
1752
+ for (const summary of rows) {
1753
+ const status = statusOf(summary.id)
1754
+ const kind = indicatorOf(status, projectionOf(summary.id))
1755
+ const label = kind === undefined ? undefined : (kind === 'waiting' ? waitingLabel(status) : labels[kind])
1756
+ const menuKey = `session:${summary.id}`
1757
+ const renaming = rename !== null && rename.kind === 'session' && rename.id === summary.id
1758
+ const isPinned = pinned.has(summary.id)
1759
+ const isArchived = archived.has(summary.id)
1760
+ const openRow = () => {
1761
+ if (isArchived) { notify(t('notice.archivedNotOpenable')); return }
1762
+ openSession(summary.id)
1763
+ }
1764
+ const sessionMenu = isArchived
1765
+ ? [
1766
+ { key: 'unarchive', label: t('action.unarchive'), onSelect: () => unarchive(summary.id) },
1767
+ { key: 'rename', label: t('action.rename'), onSelect: () => startRenameSession(summary) },
1768
+ { key: 'fork', label: t('action.fork'), onSelect: () => fork(summary.id) },
1769
+ { key: 'copy', label: t('action.copyId'), onSelect: () => copyId(summary.id) },
1770
+ ]
1771
+ : [
1772
+ { key: 'pin', label: isPinned ? t('action.unpin') : t('action.pin'), onSelect: () => togglePin(summary.id) },
1773
+ { key: 'rename', label: t('action.rename'), onSelect: () => startRenameSession(summary) },
1774
+ { key: 'fork', label: t('action.fork'), onSelect: () => fork(summary.id) },
1775
+ { key: 'copy', label: t('action.copyId'), onSelect: () => copyId(summary.id) },
1776
+ { key: 'archive', label: t('action.archive'), danger: true, onSelect: () => archiveNow(summary.id, false) },
1777
+ ]
1778
+ sessionRows.push(h('div', { key: summary.id, className: 'dfs-rowWrap' },
1779
+ h('div', {
1780
+ className: `dfs-row${summary.id === currentId ? ' is-current' : ''}${isArchived ? ' is-archived' : ''}`,
1781
+ role: 'button',
1782
+ tabIndex: 0,
1783
+ title: isArchived ? t('notice.archivedNotOpenable') : (kind === undefined ? titleOf(summary) : `${titleOf(summary)} — ${label}`),
1784
+ onClick: () => { if (!renaming) openRow() },
1785
+ onContextMenu: contextMenuHandler(menuKey, sessionMenu),
1786
+ onMouseEnter: (event) => {
1787
+ startMarquee(event)
1788
+ scheduleHover(menuKey, statusRowsOf(status, projectionOf(summary.id), summary.updatedAt), event)
1789
+ },
1790
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1791
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openRow() } },
1792
+ },
1793
+ h(Indicator, { kind, title: label }),
1794
+ renaming ? renameInput(rename) : h(Title, { text: titleOf(summary) }),
1795
+ isPinned ? h('span', { className: 'dfs-pin', title: t('action.pin'), 'aria-hidden': true }, h(PinIcon)) : null,
1796
+ h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1797
+ menuTrigger(menuKey, sessionMenu)),
1798
+ h('span', { className: 'dfs-time' }, relativeTime(summary.updatedAt))),
1799
+ confirm !== null && confirm.kind === 'archive' && confirm.sessionId === summary.id
1800
+ ? h('div', { className: 'dfs-confirm' },
1801
+ h('span', { className: 'dfs-confirmText' }, t('confirm.archive')),
1802
+ h('button', { type: 'button', className: 'dfs-miniBtn is-danger', onClick: () => archiveNow(summary.id, true) }, t('confirm.yes')),
1803
+ h('button', { type: 'button', className: 'dfs-miniBtn', onClick: () => setConfirm(null) }, t('confirm.cancel')))
1804
+ : null))
1805
+ }
1806
+ if (sessionRows.length === 0) sessionRows.push(h('div', { key: 'empty', className: 'dfs-empty' }, t('empty.sessions')))
1807
+ body.push(h('div', { key: 'list', className: 'dfs-list' }, sessionRows))
1808
+ return h('div', { className: 'dfs-root' }, body, menuEl, hoverEl, notice === null ? null : noticeEl())
1809
+ }
1810
+
1811
+ // A single slot refuses a second registration at the same priority and
1812
+ // renders the lowest priority, so this row must sit below the shipped
1813
+ // browser (priority 0) to replace it. The boundary keeps a render bug
1814
+ // from abdicating this entry back to the shipped browser: it renders the
1815
+ // message in place instead, which is what a reader can diagnose.
1816
+ ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register({
1817
+ name: 'sidebar.workspaces',
1818
+ priority: -1,
1819
+ locale: NS,
1820
+ }, (props) => h(RenderBoundary, null, h(FolderSidebar, props))))
1821
+ },
1822
+ }
1823
+ },
1824
+ })
1825
+ }
1826
+
1827
+ if (typeof module === 'object' && module !== null && module.exports !== undefined) {
1828
+ module.exports = {
1829
+ INDICATOR_PRIORITY, INDICATOR_LABEL_KEY, GLYPH_COLOR,
1830
+ SORT_MODES, SORT_DIRS, SORT_DEFAULT, SORT_NATURAL_DIR,
1831
+ ARCHIVED_FILTERS, UNGROUPED,
1832
+ directionSign, normalizeSort, readSortState, writeSortState,
1833
+ readArchivedFilter, writeArchivedFilter,
1834
+ sortWorkspaces, sortSessions, pickFolderKind, indicatorOf,
1835
+ relativeTime, baseName, marqueeShiftOf, marqueeDurationOf,
1836
+ }
1837
+ }