dsh-task-groups 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,2596 @@
1
+ /**
2
+ * dsh-task-groups — browser half.
3
+ *
4
+ * Replaces the `sidebar.workspaces` browsing region with two additions on top
5
+ * of the shipped session list:
6
+ *
7
+ * 1. user-defined groups ("分组") that sessions can be dragged into, plus a
8
+ * "项目" view that restores the native Workspace grouping;
9
+ * 2. an activity bell that lists running / waiting / unread sessions first
10
+ * and lets the reader clear the read ones back into the ordinary list.
11
+ *
12
+ * The entry registers at a lower priority than ui-workspace's browser: if this
13
+ * bundle fails, slot supervision abdicates it and the shipped browser returns.
14
+ *
15
+ * Group definitions and session→group assignments live in the Host state file
16
+ * (lib/index.js), never in localStorage: the Web origin carries a per-launch
17
+ * port, the file does not.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: 'dsh-task-groups',
21
+ factory: (require) => {
22
+ const module = { exports: {} }
23
+ const exports = module.exports
24
+
25
+ const React = require('react')
26
+ const { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = React
27
+ const h = React.createElement
28
+ const ReactDOM = (() => {
29
+ try {
30
+ return require('react-dom')
31
+ } catch {
32
+ return undefined
33
+ }
34
+ })()
35
+ const portalTo = (node) => (typeof ReactDOM?.createPortal === 'function' ? ReactDOM.createPortal(node, document.body) : node)
36
+
37
+ const P = require('@deepseek-ai/dsh-client-ui-primitives')
38
+ const {
39
+ Menu,
40
+ StateDot,
41
+ Tooltip,
42
+ Toast,
43
+ IconChevronRightOutlineRegular,
44
+ IconEllipsisOutlineRegular,
45
+ IconPlusOutlineRegular,
46
+ IconSearchOutlineRegular,
47
+ IconCloseOutlineRegular,
48
+ IconCheckOutlineRegular,
49
+ IconFolderOpenRegular,
50
+ IconFolderCloseRegular,
51
+ IconPinOutlineRegular,
52
+ IconPinFillRegular,
53
+ } = P
54
+
55
+ /** Host state route (lib/index.js). */
56
+ const STATE_URL = '/dsh-task-groups/state'
57
+
58
+ /** Swatch order follows the picker the user referenced; gray is the muted option. */
59
+ const GROUP_COLORS = [
60
+ { id: 'gray', value: '#b6b9c2', label: '灰色' },
61
+ { id: 'red', value: '#ee7a7a', label: '红色' },
62
+ { id: 'orange', value: '#f0a05a', label: '橙色' },
63
+ { id: 'yellow', value: '#e6c14b', label: '黄色' },
64
+ { id: 'green', value: '#46c79a', label: '绿色' },
65
+ { id: 'blue', value: '#4aa8e8', label: '蓝色' },
66
+ { id: 'purple', value: '#9b7ff0', label: '紫色' },
67
+ { id: 'pink', value: '#e878b0', label: '粉色' },
68
+ ]
69
+
70
+ const EMPTY_STATE = { version: 1, groups: [], assignments: {}, sessionOrder: [], prefs: { view: 'groups', activity: false } }
71
+
72
+ /**
73
+ * Groups share one flat list with ungrouped tasks. A group's `order` is a
74
+ * composite: floor(order / SLOT_UNIT) = how many ungrouped rows precede the
75
+ * card, order % SLOT_UNIT = rank among groups in the same slot.
76
+ */
77
+ const SLOT_UNIT = 1000
78
+
79
+ /** Module-level host context; one browser region per page. */
80
+ let rootCtx = null
81
+
82
+ // ---------------------------------------------------------------------
83
+ // persisted store
84
+ // ---------------------------------------------------------------------
85
+
86
+ function normalizeState(raw) {
87
+ const groups = Array.isArray(raw?.groups)
88
+ ? raw.groups
89
+ .filter((group) => group && typeof group.id === 'string' && typeof group.name === 'string')
90
+ .map((group, index) => ({
91
+ id: group.id,
92
+ name: group.name,
93
+ color: GROUP_COLORS.some((entry) => entry.id === group.color) ? group.color : 'gray',
94
+ collapsed: group.collapsed === true,
95
+ /**
96
+ * Composite position: floor(order / SLOT_UNIT) is how many
97
+ * ungrouped tasks render above the card; order % SLOT_UNIT ranks
98
+ * groups that share a slot. Encoding both in `order` keeps older
99
+ * Host builds (which drop unknown fields) from losing placement.
100
+ */
101
+ order: Number.isFinite(group.order) ? Number(group.order) : index,
102
+ }))
103
+ : []
104
+ const known = new Set(groups.map((group) => group.id))
105
+ const assignments = {}
106
+ const source = raw?.assignments && typeof raw.assignments === 'object' ? raw.assignments : {}
107
+ for (const [sessionId, groupId] of Object.entries(source)) {
108
+ if (typeof groupId === 'string' && known.has(groupId)) assignments[sessionId] = groupId
109
+ }
110
+ return {
111
+ version: 1,
112
+ groups,
113
+ assignments,
114
+ sessionOrder: Array.isArray(raw?.sessionOrder)
115
+ ? raw.sessionOrder.filter((id) => typeof id === 'string' && id !== '').slice(0, 5000)
116
+ : [],
117
+ prefs: {
118
+ view: raw?.prefs?.view === 'projects' ? 'projects' : 'groups',
119
+ activity: raw?.prefs?.activity === true,
120
+ },
121
+ }
122
+ }
123
+
124
+ function createStore() {
125
+ let state = EMPTY_STATE
126
+ let loaded = false
127
+ let timer = null
128
+ let disposed = false
129
+ const listeners = new Set()
130
+
131
+ const emit = () => {
132
+ for (const listener of listeners) listener()
133
+ }
134
+
135
+ const persist = () => {
136
+ timer = null
137
+ if (disposed) return
138
+ try {
139
+ void fetch(STATE_URL, {
140
+ method: 'PUT',
141
+ headers: { 'content-type': 'application/json' },
142
+ body: JSON.stringify({ state }),
143
+ }).then(
144
+ (response) => {
145
+ if (!response.ok) console.warn('[dsh-task-groups] save rejected', response.status)
146
+ },
147
+ (error) => console.warn('[dsh-task-groups] save failed', error),
148
+ )
149
+ } catch (error) {
150
+ console.warn('[dsh-task-groups] save failed', error)
151
+ }
152
+ }
153
+
154
+ const schedule = () => {
155
+ if (timer !== null) clearTimeout(timer)
156
+ timer = setTimeout(persist, 350)
157
+ }
158
+
159
+ return {
160
+ subscribe(listener) {
161
+ listeners.add(listener)
162
+ return () => listeners.delete(listener)
163
+ },
164
+ get() {
165
+ return state
166
+ },
167
+ loaded() {
168
+ return loaded
169
+ },
170
+ update(updater) {
171
+ const next = typeof updater === 'function' ? updater(state) : updater
172
+ if (next === state) return
173
+ state = next
174
+ emit()
175
+ schedule()
176
+ },
177
+ async load() {
178
+ try {
179
+ const response = await fetch(`${STATE_URL}?t=${Date.now()}`, { cache: 'no-store' })
180
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
181
+ const body = await response.json()
182
+ if (body?.ok && body.state) store.update(normalizeState(body.state))
183
+ } catch (error) {
184
+ console.warn('[dsh-task-groups] could not read the saved groups', error)
185
+ } finally {
186
+ loaded = true
187
+ emit()
188
+ }
189
+ },
190
+ flush() {
191
+ if (timer !== null) persist()
192
+ },
193
+ dispose() {
194
+ disposed = true
195
+ if (timer !== null) clearTimeout(timer)
196
+ timer = null
197
+ },
198
+ }
199
+ }
200
+
201
+ const store = createStore()
202
+
203
+ // ---------------------------------------------------------------------
204
+ // helpers
205
+ // ---------------------------------------------------------------------
206
+
207
+ const cx = (...parts) => parts.filter(Boolean).join(' ')
208
+
209
+ const colorOf = (group) => (GROUP_COLORS.find((entry) => entry.id === group?.color) ?? GROUP_COLORS[0]).value
210
+
211
+ function relativeLabel(ms) {
212
+ if (!Number.isFinite(ms)) return ''
213
+ const diff = Math.max(0, Date.now() - ms)
214
+ const minute = 60_000
215
+ const hour = 60 * minute
216
+ const day = 24 * hour
217
+ if (diff < minute) return '刚刚'
218
+ if (diff < hour) return `${Math.floor(diff / minute)}分钟`
219
+ if (diff < day) return `${Math.floor(diff / hour)}小时`
220
+ if (diff < 30 * day) return `${Math.floor(diff / day)}天`
221
+ if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
222
+ return `${Math.floor(diff / (365 * day))}年`
223
+ }
224
+
225
+ const statusOf = (map, id) => map?.get?.(id)
226
+
227
+ function BellIcon({ size = 16, filled = false }) {
228
+ return h(
229
+ 'svg',
230
+ {
231
+ width: size,
232
+ height: size,
233
+ viewBox: '0 0 16 16',
234
+ fill: 'none',
235
+ stroke: 'currentColor',
236
+ strokeWidth: 1.3,
237
+ strokeLinecap: 'round',
238
+ strokeLinejoin: 'round',
239
+ 'aria-hidden': 'true',
240
+ },
241
+ h('path', {
242
+ d: 'M8 2.4c-1.95 0-3.5 1.55-3.5 3.5v2.15c0 .78-.3 1.52-.85 2.07l-.5.5c-.42.42-.12 1.13.47 1.13h8.76c.59 0 .89-.71.47-1.13l-.5-.5a2.93 2.93 0 0 1-.85-2.07V5.9c0-1.95-1.55-3.5-3.5-3.5Z',
243
+ fill: filled ? 'currentColor' : 'none',
244
+ fillOpacity: filled ? 0.1 : 0,
245
+ }),
246
+ h('path', { d: 'M6.7 13.1a1.45 1.45 0 0 0 2.6 0' }),
247
+ )
248
+ }
249
+
250
+ /** Broom: sweeps read conversations out of the activity list. */
251
+ function BroomIcon({ size = 15 }) {
252
+ return h(
253
+ 'svg',
254
+ {
255
+ width: size,
256
+ height: size,
257
+ viewBox: '0 0 16 16',
258
+ fill: 'none',
259
+ stroke: 'currentColor',
260
+ strokeWidth: 1.3,
261
+ strokeLinecap: 'round',
262
+ strokeLinejoin: 'round',
263
+ 'aria-hidden': 'true',
264
+ },
265
+ h('path', { d: 'M13 2.8 9 6.8' }),
266
+ h('path', { d: 'M9 6.8 5.6 12.2' }),
267
+ h('path', { d: 'M9 6.8 10.9 12.2' }),
268
+ h('path', { d: 'M6.3 10.4c1.7-.35 3.2-.35 4.6 0' }),
269
+ h('path', { d: 'M5.5 12.2c2 .45 4.1.45 6 0' }),
270
+ )
271
+ }
272
+
273
+ /** Archive box: the row's hover action and the group's archive command. */
274
+ function ArchiveIcon({ size = 15 }) {
275
+ return h(
276
+ 'svg',
277
+ {
278
+ width: size,
279
+ height: size,
280
+ viewBox: '0 0 16 16',
281
+ fill: 'none',
282
+ stroke: 'currentColor',
283
+ strokeWidth: 1.3,
284
+ strokeLinecap: 'round',
285
+ strokeLinejoin: 'round',
286
+ 'aria-hidden': 'true',
287
+ },
288
+ h('path', { d: 'M2.6 3.2h10.8v2.6H2.6z' }),
289
+ h('path', { d: 'M3.6 5.8v7h8.8v-7' }),
290
+ h('path', { d: 'M6.4 8.6h3.2' }),
291
+ )
292
+ }
293
+
294
+ /** Circled plus used by the per-group "new task" affordances. */
295
+ function IconPlusCircle({ size = 16 }) {
296
+ return h(
297
+ 'svg',
298
+ {
299
+ width: size,
300
+ height: size,
301
+ viewBox: '0 0 16 16',
302
+ fill: 'none',
303
+ stroke: 'currentColor',
304
+ strokeWidth: 1.3,
305
+ strokeLinecap: 'round',
306
+ 'aria-hidden': 'true',
307
+ },
308
+ h('circle', { cx: 8, cy: 8, r: 5.7 }),
309
+ h('path', { d: 'M8 5.4v5.2M5.4 8h5.2' }),
310
+ )
311
+ }
312
+
313
+ function HashIcon({ color, size = 16, filled = false }) {
314
+ return h(
315
+ 'span',
316
+ {
317
+ className: cx('dtg-hash', filled && 'is-filled'),
318
+ style: {
319
+ '--dtg-color': color,
320
+ width: size,
321
+ height: size,
322
+ lineHeight: `${size}px`,
323
+ fontSize: Math.max(9, Math.round(size * 0.72)),
324
+ borderRadius: filled ? '50%' : Math.max(4, Math.round(size * 0.3)),
325
+ },
326
+ 'aria-hidden': 'true',
327
+ },
328
+ '#',
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Two-segment switch. Deliberately not the shipped `SegmentedControl`: its
334
+ * single sliding pill is positioned from the track width, and inside this
335
+ * narrow, sometimes-throttled sidebar it can be caught between tracks. A
336
+ * per-button raised fill renders in one paint and cannot sit between tabs.
337
+ */
338
+ function ViewToggle({ value, onChange, disabled }) {
339
+ const options = [
340
+ { value: 'groups', label: '分组' },
341
+ { value: 'projects', label: '项目' },
342
+ ]
343
+ return h(
344
+ 'div',
345
+ { className: 'dtg-toggle', role: 'tablist', 'aria-label': '任务分类方式' },
346
+ h('span', { className: cx('dtg-toggle-thumb', value === 'projects' && 'is-right'), 'aria-hidden': 'true' }),
347
+ options.map((option) =>
348
+ h(
349
+ 'button',
350
+ {
351
+ key: option.value,
352
+ type: 'button',
353
+ role: 'tab',
354
+ 'aria-selected': option.value === value,
355
+ className: cx('dtg-toggle-btn', option.value === value && 'is-active'),
356
+ disabled,
357
+ onClick: () => {
358
+ if (option.value !== value) onChange(option.value)
359
+ },
360
+ },
361
+ option.label,
362
+ ),
363
+ ),
364
+ )
365
+ }
366
+
367
+ function IconButton({ label, active, onClick, children, className }) {
368
+ return h(
369
+ Tooltip,
370
+ { label, side: 'bottom', portal: true },
371
+ h(
372
+ 'button',
373
+ {
374
+ type: 'button',
375
+ className: cx('dtg-icon-btn', active && 'is-active', className),
376
+ 'aria-label': label,
377
+ 'aria-pressed': active === undefined ? undefined : active,
378
+ onClick,
379
+ },
380
+ children,
381
+ ),
382
+ )
383
+ }
384
+
385
+ // ---------------------------------------------------------------------
386
+ // current conversation (selection highlight)
387
+ // ---------------------------------------------------------------------
388
+
389
+ function readCurrentSession() {
390
+ try {
391
+ return document.querySelector('[data-conversation-session]')?.getAttribute('data-conversation-session') ?? ''
392
+ } catch {
393
+ return ''
394
+ }
395
+ }
396
+
397
+ function useCurrentSessionId() {
398
+ const [current, setCurrent] = useState(readCurrentSession)
399
+ useEffect(() => {
400
+ let alive = true
401
+ const sync = () => {
402
+ if (!alive) return
403
+ const next = readCurrentSession()
404
+ setCurrent((previous) => (previous === next ? previous : next))
405
+ }
406
+ sync()
407
+ const observer = new MutationObserver(sync)
408
+ observer.observe(document.body, {
409
+ subtree: true,
410
+ childList: true,
411
+ attributes: true,
412
+ attributeFilter: ['data-conversation-session'],
413
+ })
414
+ return () => {
415
+ alive = false
416
+ observer.disconnect()
417
+ }
418
+ }, [])
419
+ return current
420
+ }
421
+
422
+ // ---------------------------------------------------------------------
423
+ // root
424
+ // ---------------------------------------------------------------------
425
+
426
+ function Browser(props) {
427
+ const { wide, expandSidebar, useSessions, useWorkspaces, useSessionStatus } = props
428
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get)
429
+ const stateRef = useRef(state)
430
+ stateRef.current = state
431
+ const loaded = useSyncExternalStore(store.subscribe, store.loaded, store.loaded)
432
+ const currentSession = useCurrentSessionId()
433
+
434
+ const sessionState = useSessions((value) => value)
435
+ const workspaceState = useWorkspaces((value) => value)
436
+ const statusMap = useSessionStatus((value) => value)
437
+
438
+ const [search, setSearch] = useState(null)
439
+ const searchInputRef = useRef(null)
440
+ const [editingGroup, setEditingGroup] = useState(null)
441
+ const [confirmingGroup, setConfirmingGroup] = useState(null)
442
+ const [menuOpen, setMenuOpen] = useState(false)
443
+ const [groupMenu, setGroupMenu] = useState(null)
444
+ const [groupActions, setGroupActions] = useState(null)
445
+ const [confirmingArchive, setConfirmingArchive] = useState(null)
446
+ const [sessionMenu, setSessionMenu] = useState(null)
447
+ const [toast, setToast] = useState(null)
448
+ const [renamingSession, setRenamingSession] = useState(null)
449
+ const [manualUnread, setManualUnread] = useState(() => new Set())
450
+ const manualUnreadRef = useRef(manualUnread)
451
+ manualUnreadRef.current = manualUnread
452
+ const [keptUnread, setKeptUnread] = useState(() => new Set())
453
+ const [dragging, setDragging] = useState(null)
454
+ const [dropTarget, setDropTarget] = useState(undefined)
455
+ const [ghost, setGhost] = useState(null)
456
+ const [sessionGhost, setSessionGhost] = useState(null)
457
+ const [sessionLine, setSessionLine] = useState(null)
458
+ const sessionGhostElRef = useRef(null)
459
+ const sessionDragRef = useRef(null)
460
+ const sortedRef = useRef([])
461
+ const [dropLine, setDropLine] = useState(null)
462
+ const groupsRef = useRef([])
463
+ const groupElsRef = useRef(new Map())
464
+ const ungroupedRef = useRef([])
465
+ const ungroupedElsRef = useRef(new Map())
466
+ const listRef = useRef(null)
467
+ const ghostElRef = useRef(null)
468
+ const dragRef = useRef(null)
469
+ const dropTargetRef = useRef(null)
470
+ const suppressClickRef = useRef(false)
471
+ const [readAt, setReadAt] = useState({})
472
+ const [collapsedProjects, setCollapsedProjects] = useState({})
473
+ const [projectLimits, setProjectLimits] = useState({})
474
+
475
+ useEffect(() => {
476
+ void store.load()
477
+ return () => store.flush()
478
+ }, [])
479
+
480
+ // Refresh relative times once a minute; without it a row's "5分钟" would
481
+ // stay frozen until the next session/status change.
482
+ const [, setClock] = useState(0)
483
+ useEffect(() => {
484
+ const timer = setInterval(() => setClock((value) => value + 1), 60_000)
485
+ return () => clearInterval(timer)
486
+ }, [])
487
+
488
+ useEffect(() => {
489
+ if (search !== null) {
490
+ const timer = setTimeout(() => searchInputRef.current?.focus(), 0)
491
+ return () => clearTimeout(timer)
492
+ }
493
+ return undefined
494
+ }, [search !== null])
495
+
496
+ const notify = useCallback((text) => setToast({ text, key: Date.now() }), [])
497
+
498
+ // ----- derived ------------------------------------------------------
499
+
500
+ const archived = useMemo(() => new Set(workspaceState.archivedSessionIds), [workspaceState.archivedSessionIds])
501
+ const pinnedIndex = useMemo(() => {
502
+ const map = new Map()
503
+ workspaceState.pinnedSessionIds.forEach((id, index) => map.set(id, index))
504
+ return map
505
+ }, [workspaceState.pinnedSessionIds])
506
+
507
+ const sessions = useMemo(() => {
508
+ const rows = []
509
+ for (const id of sessionState.ids) {
510
+ const summary = sessionState.byId[id]
511
+ if (!summary || summary.origin === 'subagent') continue
512
+ if (archived.has(id)) continue
513
+ if (summary.blank && id !== currentSession) continue
514
+ rows.push(summary)
515
+ }
516
+ return rows
517
+ }, [sessionState.ids, sessionState.byId, archived, currentSession])
518
+
519
+ const workspaceOf = useMemo(() => {
520
+ const map = new Map()
521
+ for (const item of workspaceState.items) {
522
+ for (const sessionId of item.sessionIds) map.set(sessionId, item)
523
+ }
524
+ return map
525
+ }, [workspaceState.items])
526
+
527
+ const groups = useMemo(
528
+ () => [...state.groups].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)),
529
+ [state.groups],
530
+ )
531
+ const groupById = useMemo(() => new Map(groups.map((group) => [group.id, group])), [groups])
532
+ groupsRef.current = groups
533
+
534
+ const sorted = useMemo(() => {
535
+ const compare = (a, b) => {
536
+ const pa = pinnedIndex.has(a.id) ? pinnedIndex.get(a.id) : Number.POSITIVE_INFINITY
537
+ const pb = pinnedIndex.has(b.id) ? pinnedIndex.get(b.id) : Number.POSITIVE_INFINITY
538
+ if (pa !== pb) return pa - pb
539
+ return b.updatedAt - a.updatedAt
540
+ }
541
+ const base = [...sessions].sort(compare)
542
+ const order = state.sessionOrder ?? []
543
+ if (order.length === 0) return base
544
+ // Manual order wins inside each pin band; pinned sessions still lead.
545
+ const position = new Map(order.map((id, index) => [id, index]))
546
+ return base
547
+ .map((session, index) => ({ session, index }))
548
+ .sort((a, b) => {
549
+ const pa = pinnedIndex.has(a.session.id) ? pinnedIndex.get(a.session.id) : Number.POSITIVE_INFINITY
550
+ const pb = pinnedIndex.has(b.session.id) ? pinnedIndex.get(b.session.id) : Number.POSITIVE_INFINITY
551
+ if (pa !== pb) return pa - pb
552
+ const oa = position.has(a.session.id) ? position.get(a.session.id) : -1
553
+ const ob = position.has(b.session.id) ? position.get(b.session.id) : -1
554
+ if (oa !== ob) return oa - ob
555
+ return a.index - b.index
556
+ })
557
+ .map((entry) => entry.session)
558
+ }, [sessions, pinnedIndex, state.sessionOrder])
559
+
560
+ sortedRef.current = sorted
561
+
562
+ const byGroup = useMemo(() => {
563
+ const buckets = new Map(groups.map((group) => [group.id, []]))
564
+ const ungrouped = []
565
+ for (const session of sorted) {
566
+ const bucket = buckets.get(state.assignments[session.id])
567
+ if (bucket) bucket.push(session)
568
+ else ungrouped.push(session)
569
+ }
570
+ return { buckets, ungrouped }
571
+ }, [sorted, groups, state.assignments])
572
+
573
+ const projectRows = useMemo(() => {
574
+ const buckets = []
575
+ const seen = new Set()
576
+ for (const item of workspaceState.items) {
577
+ const rows = []
578
+ for (const session of sorted) {
579
+ if (workspaceOf.get(session.id)?.workspaceId !== item.workspaceId) continue
580
+ rows.push(session)
581
+ seen.add(session.id)
582
+ }
583
+ buckets.push({ workspace: item, rows })
584
+ }
585
+ return { buckets, ungrouped: sorted.filter((session) => !seen.has(session.id)) }
586
+ }, [sorted, workspaceState.items, workspaceOf])
587
+
588
+ /**
589
+ * Native sidebar behaviour: an open Workspace shows its first five
590
+ * Sessions, raising the window just enough to cover the last running /
591
+ * waiting / unread / selected one in the first 50. "显示更多" adds five;
592
+ * "显示更少" returns to this baseline.
593
+ */
594
+ const projectWatermark = useCallback(
595
+ (rows) => {
596
+ let lastActive = -1
597
+ rows.forEach((session, index) => {
598
+ if (index >= 50) return
599
+ const status = statusOf(statusMap, session.id)
600
+ if (status?.running || status?.pendingInteraction || status?.completionUnread || session.id === currentSession) {
601
+ lastActive = index
602
+ }
603
+ })
604
+ return Math.max(5, lastActive + 1)
605
+ },
606
+ [statusMap, currentSession],
607
+ )
608
+
609
+ const projectLimit = (key, rows) => projectLimits[key] ?? projectWatermark(rows)
610
+
611
+ const showMoreProjects = useCallback(
612
+ (key, rows) => {
613
+ setProjectLimits((previous) => ({
614
+ ...previous,
615
+ [key]: (previous[key] ?? projectWatermark(rows)) + 5,
616
+ }))
617
+ },
618
+ [projectWatermark],
619
+ )
620
+
621
+ const showLessProjects = useCallback(
622
+ (key, rows) => {
623
+ setProjectLimits((previous) => ({ ...previous, [key]: projectWatermark(rows) }))
624
+ },
625
+ [projectWatermark],
626
+ )
627
+
628
+ const isPriority = useCallback(
629
+ (session) => {
630
+ const status = statusOf(statusMap, session.id)
631
+ return Boolean(status?.running || status?.pendingInteraction || status?.completionUnread)
632
+ },
633
+ [statusMap],
634
+ )
635
+
636
+ const dismissed = useCallback(
637
+ (session) => {
638
+ const at = readAt[session.id]
639
+ if (at === undefined) return false
640
+ const status = statusOf(statusMap, session.id)
641
+ if (status?.running || status?.pendingInteraction) return false
642
+ return session.updatedAt <= at
643
+ },
644
+ [readAt, statusMap],
645
+ )
646
+
647
+ // Unread conversations stay in 优先级 after being opened; only the broom
648
+ // (清除已读) sweeps them into 其他.
649
+ useEffect(() => {
650
+ const unreadIds = sorted
651
+ .filter((session) => statusOf(statusMap, session.id)?.completionUnread)
652
+ .map((session) => session.id)
653
+ if (unreadIds.length === 0) return
654
+ setKeptUnread((previous) => {
655
+ let changed = false
656
+ const next = new Set(previous)
657
+ for (const id of unreadIds) {
658
+ if (!next.has(id)) {
659
+ next.add(id)
660
+ changed = true
661
+ }
662
+ }
663
+ return changed ? next : previous
664
+ })
665
+ }, [sorted, statusMap])
666
+
667
+ const priority = useMemo(
668
+ () =>
669
+ sorted.filter(
670
+ (session) =>
671
+ (isPriority(session) || manualUnread.has(session.id) || keptUnread.has(session.id)) && !dismissed(session),
672
+ ),
673
+ [sorted, isPriority, dismissed, manualUnread, keptUnread],
674
+ )
675
+ const rest = useMemo(() => {
676
+ const inPriority = new Set(priority.map((session) => session.id))
677
+ return sorted.filter((session) => !inPriority.has(session.id))
678
+ }, [sorted, priority])
679
+
680
+ const query = (search ?? '').trim().toLowerCase()
681
+ const searchResults = useMemo(() => {
682
+ if (query === '') return []
683
+ return sorted.filter((session) => {
684
+ const group = groupById.get(state.assignments[session.id])
685
+ const workspace = workspaceOf.get(session.id)
686
+ return [session.displayTitle, group?.name, workspace?.title]
687
+ .filter(Boolean)
688
+ .some((text) => String(text).toLowerCase().includes(query))
689
+ })
690
+ }, [query, sorted, groupById, state.assignments, workspaceOf])
691
+
692
+ // ----- mutations ----------------------------------------------------
693
+
694
+ const update = store.update
695
+
696
+ const assign = useCallback(
697
+ (sessionId, groupId) => {
698
+ update((previous) => {
699
+ const assignments = { ...previous.assignments }
700
+ if (groupId) assignments[sessionId] = groupId
701
+ else delete assignments[sessionId]
702
+ return { ...previous, assignments }
703
+ })
704
+ if (groupId) {
705
+ const group = state.groups.find((entry) => entry.id === groupId)
706
+ if (group?.collapsed) {
707
+ update((previous) => ({
708
+ ...previous,
709
+ groups: previous.groups.map((entry) => (entry.id === groupId ? { ...entry, collapsed: false } : entry)),
710
+ }))
711
+ }
712
+ }
713
+ },
714
+ [update, state.groups],
715
+ )
716
+
717
+ const setPrefs = useCallback(
718
+ (patch) => update((previous) => ({ ...previous, prefs: { ...previous.prefs, ...patch } })),
719
+ [update],
720
+ )
721
+
722
+ const openSession = useCallback(
723
+ (sessionId) => {
724
+ try {
725
+ rootCtx.uiWorkspace.openSession(sessionId)
726
+ // Opening marks it read, but a previously unread conversation stays
727
+ // in 优先级 until the broom sweeps it into 其他.
728
+ if (manualUnreadRef.current.has(sessionId)) {
729
+ setManualUnread((previous) => {
730
+ if (!previous.has(sessionId)) return previous
731
+ const next = new Set(previous)
732
+ next.delete(sessionId)
733
+ return next
734
+ })
735
+ setKeptUnread((previous) => (previous.has(sessionId) ? previous : new Set(previous).add(sessionId)))
736
+ }
737
+ } catch (error) {
738
+ console.warn('[dsh-task-groups] open failed', error)
739
+ notify('无法打开这个任务')
740
+ }
741
+ },
742
+ [notify],
743
+ )
744
+
745
+ /** Rename a Session through the Host; the title projection updates itself. */
746
+ const renameSessionTo = useCallback(
747
+ async (sessionId, title) => {
748
+ const trimmed = String(title ?? '').trim()
749
+ if (trimmed === '') return false
750
+ try {
751
+ // The public `sessions` service has no rename; the Host contract is
752
+ // exposed on the remote session channel (the catalog wraps it).
753
+ const channel = rootCtx.remote?.session
754
+ if (typeof channel?.rename === 'function') {
755
+ const result = await channel.rename({ sessionId, title: trimmed })
756
+ if (result && result.ok === false) throw new Error(result.error?.message ?? 'rename failed')
757
+ return true
758
+ }
759
+ if (typeof rootCtx.sessions?.rename === 'function') {
760
+ const result = await rootCtx.sessions.rename(sessionId, trimmed)
761
+ if (result && result.ok === false) throw new Error(result.error?.message ?? 'rename failed')
762
+ return true
763
+ }
764
+ throw new Error('rename is not available')
765
+ } catch (error) {
766
+ console.warn('[dsh-task-groups] rename failed', error)
767
+ const detail = error?.rpcError?.message ?? error?.message ?? ''
768
+ notify(detail ? `重命名失败:${String(detail).slice(0, 40)}` : '重命名失败')
769
+ return false
770
+ }
771
+ },
772
+ [notify],
773
+ )
774
+
775
+ /** Fork at the last completed turn, then open the child conversation. */
776
+ const forkSession = useCallback(
777
+ async (sessionId) => {
778
+ try {
779
+ if (typeof rootCtx.sessions?.fork === 'function') {
780
+ const childId = await rootCtx.sessions.fork({ sessionId, increaseTitle: true })
781
+ if (typeof childId === 'string' && childId !== '') {
782
+ openSession(childId)
783
+ return
784
+ }
785
+ }
786
+ const result = await rootCtx.remote?.session?.fork({ sessionId })
787
+ if (result && result.ok === false) throw new Error(result.error?.message ?? 'fork failed')
788
+ const childId = result?.value?.sessionId
789
+ if (typeof childId === 'string' && childId !== '') openSession(childId)
790
+ } catch (error) {
791
+ console.warn('[dsh-task-groups] fork failed', error)
792
+ notify('分支失败')
793
+ }
794
+ },
795
+ [notify, openSession],
796
+ )
797
+
798
+ const copyText = useCallback(
799
+ async (text) => {
800
+ try {
801
+ const clipboard = typeof window !== 'undefined' ? window.navigator?.clipboard : undefined
802
+ if (!clipboard?.writeText) throw new Error('clipboard unavailable')
803
+ await clipboard.writeText(text)
804
+ notify('已复制')
805
+ return true
806
+ } catch {
807
+ try {
808
+ const area = document.createElement('textarea')
809
+ area.value = text
810
+ area.style.position = 'fixed'
811
+ area.style.top = '-1000px'
812
+ area.style.opacity = '0'
813
+ document.body.append(area)
814
+ area.select()
815
+ const ok = document.execCommand('copy')
816
+ area.remove()
817
+ if (ok) {
818
+ notify('已复制')
819
+ return true
820
+ }
821
+ } catch {}
822
+ notify('复制失败')
823
+ return false
824
+ }
825
+ },
826
+ [notify],
827
+ )
828
+
829
+ const archiveSession = useCallback(
830
+ async (sessionId) => {
831
+ try {
832
+ await rootCtx.uiWorkspace.archiveSession(sessionId)
833
+ } catch (error) {
834
+ notify(error?.rpcError?.code === 'workspace/session-active' ? '任务还在运行,无法归档' : '归档失败')
835
+ }
836
+ },
837
+ [notify],
838
+ )
839
+
840
+ const togglePin = useCallback(
841
+ async (sessionId, pinned) => {
842
+ try {
843
+ if (pinned) await rootCtx.uiWorkspace.unpinSession(sessionId)
844
+ else await rootCtx.uiWorkspace.pinSession(sessionId)
845
+ } catch {
846
+ notify('置顶失败')
847
+ }
848
+ },
849
+ [notify],
850
+ )
851
+
852
+ /**
853
+ * Start a New Session in the current (or first known) Workspace and put
854
+ * it in this group. `startSession` returns nothing, so watch the on-screen
855
+ * conversation id until it changes to the fresh blank, then assign it;
856
+ * a reused blank keeps its id, so fall back to it only when it is blank.
857
+ */
858
+ const startTaskInGroup = useCallback(
859
+ (groupId) => {
860
+ const workspaceId = workspaceOf.get(currentSession)?.workspaceId ?? workspaceState.items[0]?.workspaceId
861
+ const before = readCurrentSession()
862
+ try {
863
+ rootCtx.uiWorkspace.startSession(workspaceId)
864
+ } catch (error) {
865
+ console.warn('[dsh-task-groups] new task failed', error)
866
+ notify('新建任务失败')
867
+ return
868
+ }
869
+ const startedAt = Date.now()
870
+ const timer = setInterval(() => {
871
+ const now = readCurrentSession()
872
+ if (now && now !== before) {
873
+ clearInterval(timer)
874
+ assign(now, groupId)
875
+ return
876
+ }
877
+ if (Date.now() - startedAt < 3000) return
878
+ clearInterval(timer)
879
+ const fallback = now || before
880
+ if (fallback && sessionState.byId[fallback]?.blank) assign(fallback, groupId)
881
+ }, 120)
882
+ },
883
+ [workspaceOf, currentSession, workspaceState.items, assign, notify, sessionState.byId],
884
+ )
885
+
886
+ const createGroup = useCallback(
887
+ (name) => {
888
+ const trimmed = name.trim()
889
+ if (trimmed === '') return false
890
+ update((previous) => {
891
+ const id = `g${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-4)}`
892
+ const palette = GROUP_COLORS.filter((entry) => entry.id !== 'gray')
893
+ const color = palette[previous.groups.length % palette.length].id
894
+ // New groups land right below the existing ones, not at slot 0.
895
+ const maxOrder = previous.groups.reduce((max, group) => Math.max(max, Number(group.order) || 0), -1)
896
+ return {
897
+ ...previous,
898
+ groups: [...previous.groups, { id, name: trimmed, color, collapsed: false, order: maxOrder + 1 }],
899
+ }
900
+ })
901
+ return true
902
+ },
903
+ [update],
904
+ )
905
+
906
+ const renameGroup = useCallback(
907
+ (groupId, name) => {
908
+ const trimmed = name.trim()
909
+ if (trimmed === '') return
910
+ update((previous) => ({
911
+ ...previous,
912
+ groups: previous.groups.map((group) => (group.id === groupId ? { ...group, name: trimmed } : group)),
913
+ }))
914
+ },
915
+ [update],
916
+ )
917
+
918
+ const patchGroup = useCallback(
919
+ (groupId, patch) =>
920
+ update((previous) => ({
921
+ ...previous,
922
+ groups: previous.groups.map((group) => (group.id === groupId ? { ...group, ...patch } : group)),
923
+ })),
924
+ [update],
925
+ )
926
+
927
+ const deleteGroup = useCallback(
928
+ (groupId) =>
929
+ update((previous) => {
930
+ const assignments = {}
931
+ for (const [sessionId, value] of Object.entries(previous.assignments)) {
932
+ if (value !== groupId) assignments[sessionId] = value
933
+ }
934
+ return {
935
+ ...previous,
936
+ groups: previous.groups.filter((group) => group.id !== groupId),
937
+ assignments,
938
+ }
939
+ }),
940
+ [update],
941
+ )
942
+
943
+ /**
944
+ * Move a dragged group to `targetOrder` (composite), then renumber every
945
+ * group as slot*SLOT_UNIT + rank so the file stays integral and tidy.
946
+ */
947
+ const placeGroup = useCallback(
948
+ (dragId, targetOrder) =>
949
+ update((previous) => {
950
+ if (!previous.groups.some((group) => group.id === dragId)) return previous
951
+ const next = previous.groups.map((group) => (group.id === dragId ? { ...group, order: targetOrder } : group))
952
+ const sorted = [...next].sort((a, b) => a.order - b.order)
953
+ const counters = new Map()
954
+ const normalized = sorted.map((group) => {
955
+ const slot = Math.max(0, Math.floor(group.order / SLOT_UNIT))
956
+ const rank = counters.get(slot) ?? 0
957
+ counters.set(slot, rank + 1)
958
+ return { ...group, order: slot * SLOT_UNIT + rank }
959
+ })
960
+ return { ...previous, groups: normalized }
961
+ }),
962
+ [update],
963
+ )
964
+
965
+ /** Arm a pointer drag; it only becomes a drag after 4px of movement. */
966
+ const beginGroupDrag = useCallback((event, id) => {
967
+ dragRef.current = { id, startX: event.clientX, startY: event.clientY, el: event.currentTarget }
968
+ }, [])
969
+
970
+ // Pointer-driven group dragging: HTML5 DnD can only show the browser's
971
+ // translucent ghost, while the reference lifts the whole group card and
972
+ // opens a gap where it will land. Window listeners keep tracking outside
973
+ // the narrow column.
974
+ useEffect(() => {
975
+ const finish = () => {
976
+ const drag = dragRef.current
977
+ dragRef.current = null
978
+ document.body.classList.remove('dtg-group-dragging')
979
+ if (drag?.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(drag.raf)
980
+ if (drag?.active && dropTargetRef.current) {
981
+ placeGroup(drag.id, dropTargetRef.current.order)
982
+ }
983
+ if (drag?.active) setTimeout(() => { suppressClickRef.current = false }, 0)
984
+ else suppressClickRef.current = false
985
+ dropTargetRef.current = null
986
+ setDropLine(null)
987
+ setGhost(null)
988
+ }
989
+ const onMove = (event) => {
990
+ const drag = dragRef.current
991
+ if (!drag) return
992
+ if (!drag.active) {
993
+ if (Math.abs(event.clientX - drag.startX) < 4 && Math.abs(event.clientY - drag.startY) < 4) return
994
+ const rect = drag.el.getBoundingClientRect()
995
+ drag.active = true
996
+ drag.width = rect.width
997
+ drag.height = rect.height
998
+ // Keep the card under the grab point (no jump on pickup); the drop
999
+ // line follows the card's centre, so what you see is where it lands.
1000
+ drag.offsetX = drag.startX - rect.left
1001
+ drag.offsetY = drag.startY - rect.top
1002
+ suppressClickRef.current = true
1003
+ document.body.classList.add('dtg-group-dragging')
1004
+ setGhost({
1005
+ id: drag.id,
1006
+ left: Math.round(event.clientX - drag.offsetX),
1007
+ top: Math.round(event.clientY - drag.offsetY),
1008
+ width: drag.width,
1009
+ })
1010
+ }
1011
+
1012
+ const listEl = listRef.current
1013
+ const listBox = listEl ? listEl.getBoundingClientRect() : null
1014
+ if (listEl && listBox) {
1015
+ const edge = 40
1016
+ if (event.clientY < listBox.top + edge) listEl.scrollTop -= 14
1017
+ else if (event.clientY > listBox.bottom - edge) listEl.scrollTop += 14
1018
+ }
1019
+
1020
+ const ungrouped = ungroupedRef.current
1021
+ const others = groupsRef.current.filter((group) => group.id !== drag.id)
1022
+ const slotOf = (group) =>
1023
+ Math.max(0, Math.min(ungrouped.length, Math.floor((group.order ?? 0) / SLOT_UNIT)))
1024
+ const groupsAt = (slot) => others.filter((group) => slotOf(group) === slot)
1025
+
1026
+ // Top-level rows in render order: each slot emits its group cards,
1027
+ // then that slot's session row.
1028
+ const items = []
1029
+ for (let index = 0; index <= ungrouped.length; index += 1) {
1030
+ for (const group of groupsAt(index)) items.push({ kind: 'group', group, el: groupElsRef.current.get(group.id) })
1031
+ if (index < ungrouped.length) {
1032
+ items.push({ kind: 'session', session: ungrouped[index], el: ungroupedElsRef.current.get(ungrouped[index].id) })
1033
+ }
1034
+ }
1035
+
1036
+ let insertAt = items.length
1037
+ // The lifted card's top edge is what the user lines up with a gap, so
1038
+ // compare tops (with a small tolerance) rather than midpoints.
1039
+ const anchorTop = event.clientY - drag.offsetY
1040
+ for (let index = 0; index < items.length; index += 1) {
1041
+ const el = items[index].el
1042
+ if (!el) continue
1043
+ const rect = el.getBoundingClientRect()
1044
+ if (rect.height === 0) continue
1045
+ if (anchorTop < rect.top + 6) {
1046
+ insertAt = index
1047
+ break
1048
+ }
1049
+ }
1050
+ // Hovering the very top of the list always means "put it first", even
1051
+ // if the card was grabbed near its bottom edge.
1052
+ if (listBox && event.clientY < listBox.top + 28) insertAt = 0
1053
+
1054
+ const above = insertAt > 0 ? items[insertAt - 1] : null
1055
+ let targetOrder
1056
+ if (above?.kind === 'session') {
1057
+ const slot = items.slice(0, insertAt).filter((item) => item.kind === 'session').length
1058
+ const peers = groupsAt(slot)
1059
+ targetOrder = peers.length ? Math.max(...peers.map((group) => group.order)) + 0.5 : slot * SLOT_UNIT
1060
+ } else if (above?.kind === 'group') {
1061
+ targetOrder = above.group.order + 0.5
1062
+ } else {
1063
+ // Inserting above the first row must land in slot 0. Using the
1064
+ // minimum of all groups was wrong when every other group lived in a
1065
+ // later slot (it produced that slot instead of the very top).
1066
+ targetOrder = Math.min(0, ...others.map((group) => group.order)) - 0.5
1067
+ }
1068
+
1069
+ dropTargetRef.current = { order: targetOrder }
1070
+ setDropLine(insertAt)
1071
+
1072
+ // The ghost is positioned outside React on every frame: a state
1073
+ // update per pointermove made the drag stutter on large lists.
1074
+ drag.left = Math.round(event.clientX - drag.offsetX)
1075
+ drag.top = Math.round(event.clientY - drag.offsetY)
1076
+ if (!drag.raf) {
1077
+ const apply = () => {
1078
+ drag.raf = 0
1079
+ const node = ghostElRef.current
1080
+ if (!node) return
1081
+ node.style.left = `${drag.left}px`
1082
+ node.style.top = `${drag.top}px`
1083
+ }
1084
+ if (typeof requestAnimationFrame === 'function') drag.raf = requestAnimationFrame(apply)
1085
+ else apply()
1086
+ }
1087
+ }
1088
+ window.addEventListener('pointermove', onMove, { passive: true })
1089
+ window.addEventListener('pointerup', finish)
1090
+ window.addEventListener('pointercancel', finish)
1091
+ window.addEventListener('blur', finish)
1092
+ return () => {
1093
+ window.removeEventListener('pointermove', onMove)
1094
+ window.removeEventListener('pointerup', finish)
1095
+ window.removeEventListener('pointercancel', finish)
1096
+ window.removeEventListener('blur', finish)
1097
+ document.body.classList.remove('dtg-group-dragging')
1098
+ }
1099
+ }, [placeGroup])
1100
+
1101
+ // Pointer-driven session dragging: same feel as group dragging — a
1102
+ // floating card follows the cursor, a line shows the gap it will land in,
1103
+ // and dropping sets both the manual order and the group.
1104
+ const beginSessionDrag = useCallback(
1105
+ (event, sessionId) => {
1106
+ if (event.button !== 0 && event.button !== -1) return
1107
+ if (event.target.closest('button, input, [data-no-drag]')) return
1108
+ if (state.prefs.activity || search !== null) return
1109
+ if (state.prefs.view !== 'groups' && state.prefs.view !== 'projects') return
1110
+ sessionDragRef.current = {
1111
+ id: sessionId,
1112
+ startX: event.clientX,
1113
+ startY: event.clientY,
1114
+ el: event.currentTarget,
1115
+ }
1116
+ },
1117
+ [state.prefs.view, state.prefs.activity, search],
1118
+ )
1119
+
1120
+ useEffect(() => {
1121
+ const finish = () => {
1122
+ const drag = sessionDragRef.current
1123
+ sessionDragRef.current = null
1124
+ document.body.classList.remove('dtg-group-dragging')
1125
+ if (drag?.raf && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(drag.raf)
1126
+ if (drag?.active && drag.result) {
1127
+ // Projects view only reorders; group membership changes in 分组.
1128
+ if (stateRef.current.prefs.view === 'groups') {
1129
+ const groupId = drag.result.groupId ?? null
1130
+ const currentGroup = stateRef.current.assignments[drag.id] ?? null
1131
+ if (groupId !== currentGroup) assign(drag.id, groupId)
1132
+ }
1133
+ update((previous) => ({ ...previous, sessionOrder: drag.result.order }))
1134
+ }
1135
+ if (drag?.active) setTimeout(() => { suppressClickRef.current = false }, 0)
1136
+ setSessionGhost(null)
1137
+ setSessionLine(null)
1138
+ }
1139
+ const onMove = (event) => {
1140
+ const drag = sessionDragRef.current
1141
+ if (!drag) return
1142
+ if (!drag.active) {
1143
+ if (Math.abs(event.clientX - drag.startX) < 4 && Math.abs(event.clientY - drag.startY) < 4) return
1144
+ const rect = drag.el.getBoundingClientRect()
1145
+ drag.active = true
1146
+ drag.width = rect.width
1147
+ drag.offsetX = drag.startX - rect.left
1148
+ drag.offsetY = drag.startY - rect.top
1149
+ drag.height = rect.height
1150
+ suppressClickRef.current = true
1151
+ document.body.classList.add('dtg-group-dragging')
1152
+ setSessionGhost({
1153
+ id: drag.id,
1154
+ left: Math.round(event.clientX - drag.offsetX),
1155
+ top: Math.round(event.clientY - drag.offsetY),
1156
+ width: drag.width,
1157
+ })
1158
+ }
1159
+
1160
+ const listEl = listRef.current
1161
+ if (listEl) {
1162
+ const box = listEl.getBoundingClientRect()
1163
+ if (event.clientY < box.top + 40) listEl.scrollTop -= 12
1164
+ else if (event.clientY > box.bottom - 40) listEl.scrollTop += 12
1165
+ }
1166
+
1167
+ const rows = [...document.querySelectorAll('[data-session-id]')]
1168
+ .map((element) => ({
1169
+ el: element,
1170
+ id: element.getAttribute('data-session-id'),
1171
+ rect: element.getBoundingClientRect(),
1172
+ }))
1173
+ .filter((row) => row.id !== drag.id && row.rect.height > 0)
1174
+ .sort((a, b) => a.rect.top - b.rect.top)
1175
+
1176
+ let at = rows.length
1177
+ for (let index = 0; index < rows.length; index += 1) {
1178
+ if (event.clientY < rows[index].rect.top + rows[index].rect.height / 2) {
1179
+ at = index
1180
+ break
1181
+ }
1182
+ }
1183
+ const below = rows[at] ?? null
1184
+ const above = at > 0 ? rows[at - 1] : null
1185
+ const ref = below ?? above
1186
+ const gapY = below ? below.rect.top : above ? above.rect.bottom : 0
1187
+
1188
+ let groupId = null
1189
+ try {
1190
+ const hit = document.elementFromPoint?.(event.clientX, event.clientY)
1191
+ groupId = hit?.closest?.('[data-group-id]')?.getAttribute('data-group-id') ?? null
1192
+ } catch {}
1193
+
1194
+ // Reorder the full stored order (not just the visible rows) so hidden
1195
+ // conversations keep their place.
1196
+ // Rebuild the manual order from what is actually on screen, then keep
1197
+ // rows hidden behind collapsed groups at the end.
1198
+ const visible = rows.map((row) => row.id)
1199
+ const next = [...visible.slice(0, at), drag.id, ...visible.slice(at)]
1200
+ const seen = new Set(next)
1201
+ for (const session of sortedRef.current) {
1202
+ if (seen.has(session.id)) continue
1203
+ seen.add(session.id)
1204
+ next.push(session.id)
1205
+ }
1206
+ drag.result = { order: next, groupId }
1207
+
1208
+ setSessionLine({
1209
+ y: Math.round(gapY),
1210
+ groupId,
1211
+ left: Math.round(ref?.rect.left ?? 8),
1212
+ width: Math.round(ref?.rect.width ?? 214),
1213
+ })
1214
+
1215
+ drag.left = Math.round(event.clientX - drag.offsetX)
1216
+ drag.top = Math.round(event.clientY - drag.offsetY)
1217
+ if (!drag.raf) {
1218
+ const apply = () => {
1219
+ drag.raf = 0
1220
+ const node = sessionGhostElRef.current
1221
+ if (!node) return
1222
+ node.style.left = `${drag.left}px`
1223
+ node.style.top = `${drag.top}px`
1224
+ }
1225
+ if (typeof requestAnimationFrame === 'function') drag.raf = requestAnimationFrame(apply)
1226
+ else apply()
1227
+ }
1228
+ }
1229
+ window.addEventListener('pointermove', onMove, { passive: true })
1230
+ window.addEventListener('pointerup', finish)
1231
+ window.addEventListener('pointercancel', finish)
1232
+ window.addEventListener('blur', finish)
1233
+ return () => {
1234
+ window.removeEventListener('pointermove', onMove)
1235
+ window.removeEventListener('pointerup', finish)
1236
+ window.removeEventListener('pointercancel', finish)
1237
+ window.removeEventListener('blur', finish)
1238
+ document.body.classList.remove('dtg-group-dragging')
1239
+ }
1240
+ }, [assign, update])
1241
+
1242
+ const collapseAll = useCallback(
1243
+ (collapsed) =>
1244
+ update((previous) => ({ ...previous, groups: previous.groups.map((group) => ({ ...group, collapsed })) })),
1245
+ [update],
1246
+ )
1247
+
1248
+ const markRead = useCallback((ids) => {
1249
+ const now = Date.now()
1250
+ setReadAt((previous) => {
1251
+ const next = { ...previous }
1252
+ for (const id of ids) next[id] = now
1253
+ return next
1254
+ })
1255
+ }, [])
1256
+
1257
+ /** Group ⋯ → 全部标为已读: clear unread markers for every row in a group. */
1258
+ const markGroupRead = useCallback(
1259
+ (groupId) => {
1260
+ const ids = (byGroup.buckets.get(groupId) ?? []).map((session) => session.id)
1261
+ if (ids.length === 0) return
1262
+ markRead(ids)
1263
+ setManualUnread((previous) => {
1264
+ let changed = false
1265
+ const next = new Set(previous)
1266
+ for (const id of ids) changed = next.delete(id) || changed
1267
+ return changed ? next : previous
1268
+ })
1269
+ },
1270
+ [byGroup, markRead],
1271
+ )
1272
+
1273
+ /** Group ⋯ → 归档聊天: archive every finished conversation in the group. */
1274
+ const archiveGroup = useCallback(
1275
+ async (groupId) => {
1276
+ const rows = byGroup.buckets.get(groupId) ?? []
1277
+ let archived = 0
1278
+ for (const session of rows) {
1279
+ const status = statusOf(statusMap, session.id)
1280
+ if (status?.running || status?.pendingInteraction) continue
1281
+ try {
1282
+ await rootCtx.uiWorkspace.archiveSession(session.id)
1283
+ archived += 1
1284
+ } catch {}
1285
+ }
1286
+ if (archived === 0) notify('这个分组没有可归档的对话')
1287
+ },
1288
+ [byGroup, statusMap, notify],
1289
+ )
1290
+
1291
+ const clearableCount = priority.filter((session) => {
1292
+ const status = statusOf(statusMap, session.id)
1293
+ return !status?.running && !status?.pendingInteraction
1294
+ }).length
1295
+
1296
+ const clearRead = useCallback(() => {
1297
+ const ids = priority
1298
+ .filter((session) => {
1299
+ const status = statusOf(statusMap, session.id)
1300
+ return !status?.running && !status?.pendingInteraction
1301
+ })
1302
+ .map((session) => session.id)
1303
+ if (ids.length === 0) {
1304
+ notify('没有可清除的已读对话')
1305
+ return
1306
+ }
1307
+ markRead(ids)
1308
+ setKeptUnread((previous) => {
1309
+ let changed = false
1310
+ const next = new Set(previous)
1311
+ for (const id of ids) changed = next.delete(id) || changed
1312
+ return changed ? next : previous
1313
+ })
1314
+ setManualUnread((previous) => {
1315
+ let changed = false
1316
+ const next = new Set(previous)
1317
+ for (const id of ids) changed = next.delete(id) || changed
1318
+ return changed ? next : previous
1319
+ })
1320
+ }, [priority, statusMap, markRead, notify])
1321
+
1322
+ /** 标记为已读 from the row menu: clears the dot and the kept priority. */
1323
+ const markSessionRead = useCallback(
1324
+ (sessionId) => {
1325
+ markRead([sessionId])
1326
+ setManualUnread((previous) => {
1327
+ if (!previous.has(sessionId)) return previous
1328
+ const next = new Set(previous)
1329
+ next.delete(sessionId)
1330
+ return next
1331
+ })
1332
+ setKeptUnread((previous) => {
1333
+ if (!previous.has(sessionId)) return previous
1334
+ const next = new Set(previous)
1335
+ next.delete(sessionId)
1336
+ return next
1337
+ })
1338
+ },
1339
+ [markRead],
1340
+ )
1341
+
1342
+ // ----- drag & drop --------------------------------------------------
1343
+
1344
+ const dropProps = (dropId) => ({
1345
+ onDragOver: (event) => {
1346
+ if (dragging === null) return
1347
+ event.preventDefault()
1348
+ event.stopPropagation()
1349
+ event.dataTransfer.dropEffect = 'move'
1350
+ if (dropTarget !== dropId) setDropTarget(dropId)
1351
+ },
1352
+ onDragLeave: (event) => {
1353
+ if (event.currentTarget.contains(event.relatedTarget)) return
1354
+ setDropTarget((previous) => (previous === dropId ? undefined : previous))
1355
+ },
1356
+ onDrop: (event) => {
1357
+ event.preventDefault()
1358
+ event.stopPropagation()
1359
+ const sessionId = dragging ?? event.dataTransfer.getData('text/plain')
1360
+ setDragging(null)
1361
+ setDropTarget(undefined)
1362
+ if (!sessionId) return
1363
+ assign(sessionId, dropId === 'ungrouped' ? null : dropId)
1364
+ },
1365
+ })
1366
+
1367
+ const rowApi = {
1368
+ ctx: { currentSession, statusMap, groupById, workspaceOf, state, pinnedIndex },
1369
+ groups,
1370
+ menuId: sessionMenu,
1371
+ renaming: renamingSession,
1372
+ manualUnread,
1373
+ onOpen: openSession,
1374
+ onMenu: setSessionMenu,
1375
+ onCloseMenu: () => setSessionMenu(null),
1376
+ onAssign: assign,
1377
+ onPin: togglePin,
1378
+ onArchive: archiveSession,
1379
+ onStartRename: (sessionId, currentTitle) => setRenamingSession({ id: sessionId, title: currentTitle }),
1380
+ onRename: async (sessionId, title) => {
1381
+ const trimmed = String(title ?? '').trim()
1382
+ const current = renamingSession?.title ?? ''
1383
+ // Clicking elsewhere with no change simply closes the editor.
1384
+ setRenamingSession((previous) => (previous?.id === sessionId ? null : previous))
1385
+ if (trimmed === '' || trimmed === current) return
1386
+ await renameSessionTo(sessionId, title)
1387
+ },
1388
+ onCancelRename: () => setRenamingSession(null),
1389
+ onMarkUnread: (sessionId, unread) => {
1390
+ if (unread) {
1391
+ setManualUnread((previous) => new Set(previous).add(sessionId))
1392
+ return
1393
+ }
1394
+ markSessionRead(sessionId)
1395
+ },
1396
+ onFork: forkSession,
1397
+ onCopy: copyText,
1398
+ onRowPointerDown: beginSessionDrag,
1399
+ draggedRef: suppressClickRef,
1400
+ }
1401
+
1402
+ // ----- rail ---------------------------------------------------------
1403
+
1404
+ if (!wide) {
1405
+ return h(
1406
+ 'div',
1407
+ { className: 'dtg-root dtg-rail' },
1408
+ h(
1409
+ IconButton,
1410
+ {
1411
+ label: priority.length > 0 ? `查看活动(${priority.length})` : '查看活动',
1412
+ active: state.prefs.activity,
1413
+ onClick: () => setPrefs({ activity: !state.prefs.activity }),
1414
+ },
1415
+ h(BellIcon, { filled: priority.length > 0 }),
1416
+ priority.length > 0 ? h('span', { className: 'dtg-bell-dot' }) : null,
1417
+ ),
1418
+ h(IconButton, { label: '展开任务列表', onClick: () => expandSidebar() }, h(HashIcon, { color: 'currentColor', size: 18 })),
1419
+ )
1420
+ }
1421
+
1422
+ // ----- header -------------------------------------------------------
1423
+
1424
+ const bellButton = (active) =>
1425
+ h(
1426
+ IconButton,
1427
+ {
1428
+ label: active ? '收起活动,返回任务列表' : priority.length > 0 ? `查看活动(${priority.length} 个进行中或未读)` : '查看活动',
1429
+ active,
1430
+ className: 'dtg-bell-btn',
1431
+ onClick: () => setPrefs({ activity: !active }),
1432
+ },
1433
+ h(BellIcon, { filled: priority.length > 0 }),
1434
+ priority.length > 0 ? h('span', { className: 'dtg-bell-dot' }) : null,
1435
+ )
1436
+
1437
+ const header = state.prefs.activity
1438
+ ? h(
1439
+ 'div',
1440
+ { className: 'dtg-header' },
1441
+ h('span', { className: 'dtg-header-title dtg-activity-title' }, '活动'),
1442
+ h('span', { className: 'dtg-spacer' }),
1443
+ bellButton(true),
1444
+ h(
1445
+ Tooltip,
1446
+ { label: '清除已读', side: 'bottom', portal: true },
1447
+ h(
1448
+ 'button',
1449
+ {
1450
+ type: 'button',
1451
+ className: cx('dtg-icon-btn', clearableCount === 0 && 'is-disabled'),
1452
+ 'aria-label': '清除已读',
1453
+ disabled: clearableCount === 0,
1454
+ onClick: clearRead,
1455
+ },
1456
+ h(BroomIcon, { size: 15 }),
1457
+ ),
1458
+ ),
1459
+ )
1460
+ : h(
1461
+ 'div',
1462
+ { className: 'dtg-header' },
1463
+ h(ViewToggle, {
1464
+ value: state.prefs.view,
1465
+ onChange: (value) => setPrefs({ view: value }),
1466
+ disabled: search !== null,
1467
+ }),
1468
+ h('span', { className: 'dtg-spacer' }),
1469
+ bellButton(false),
1470
+ h(Menu, {
1471
+ open: menuOpen,
1472
+ onClose: () => setMenuOpen(false),
1473
+ align: 'end',
1474
+ side: 'bottom',
1475
+ portal: true,
1476
+ anchor: h(
1477
+ 'button',
1478
+ {
1479
+ type: 'button',
1480
+ className: cx('dtg-icon-btn', menuOpen && 'is-active'),
1481
+ 'aria-label': '任务列表选项',
1482
+ onClick: () => setMenuOpen((value) => !value),
1483
+ },
1484
+ h(IconEllipsisOutlineRegular, { size: 16 }),
1485
+ ),
1486
+ items: [
1487
+ { id: 'search', label: '搜索任务', icon: h(IconSearchOutlineRegular, { size: 16 }) },
1488
+ { id: 'new-group', label: '新建分组', icon: h(IconPlusOutlineRegular, { size: 16 }) },
1489
+ { type: 'separator', id: 'sep-1' },
1490
+ { id: 'collapse', label: '全部折叠' },
1491
+ { id: 'expand', label: '全部展开' },
1492
+ { type: 'separator', id: 'sep-2' },
1493
+ { id: 'view-groups', label: '按分组查看', disabled: state.prefs.view === 'groups' },
1494
+ { id: 'view-projects', label: '按项目查看', disabled: state.prefs.view === 'projects' },
1495
+ ],
1496
+ onSelect: (id) => {
1497
+ setMenuOpen(false)
1498
+ if (id === 'search') setSearch('')
1499
+ else if (id === 'new-group') setEditingGroup({ id: null, name: '' })
1500
+ else if (id === 'collapse') collapseAll(true)
1501
+ else if (id === 'expand') collapseAll(false)
1502
+ else if (id === 'view-groups') setPrefs({ view: 'groups' })
1503
+ else if (id === 'view-projects') setPrefs({ view: 'projects' })
1504
+ },
1505
+ }),
1506
+ )
1507
+
1508
+ const searchRow =
1509
+ search === null
1510
+ ? null
1511
+ : h(
1512
+ 'div',
1513
+ { className: 'dtg-search-row' },
1514
+ h(IconSearchOutlineRegular, { size: 16, className: 'dtg-search-icon' }),
1515
+ h('input', {
1516
+ ref: searchInputRef,
1517
+ className: 'dtg-search-input',
1518
+ value: search,
1519
+ placeholder: '搜索任务、分组或项目',
1520
+ 'aria-label': '搜索任务',
1521
+ onChange: (event) => setSearch(event.target.value),
1522
+ onKeyDown: (event) => {
1523
+ if (event.key === 'Escape') setSearch(null)
1524
+ },
1525
+ }),
1526
+ h(
1527
+ 'button',
1528
+ { type: 'button', className: 'dtg-icon-btn dtg-icon-btn-sm', 'aria-label': '关闭搜索', onClick: () => setSearch(null) },
1529
+ h(IconCloseOutlineRegular, { size: 14 }),
1530
+ ),
1531
+ )
1532
+
1533
+ // ----- body ---------------------------------------------------------
1534
+
1535
+ let body
1536
+ if (!loaded || sessionState.phase !== 'ready') {
1537
+ body = h(
1538
+ 'div',
1539
+ { key: 'empty', className: 'dtg-list' },
1540
+ h('div', { className: 'dtg-skeleton' }),
1541
+ h('div', { className: 'dtg-skeleton dtg-skeleton-short' }),
1542
+ h('div', { className: 'dtg-skeleton' }),
1543
+ h('div', { className: 'dtg-skeleton dtg-skeleton-short' }),
1544
+ )
1545
+ } else if (query !== '') {
1546
+ body = h(
1547
+ 'div',
1548
+ { key: 'search', className: 'dtg-list', role: 'tree', 'aria-label': '搜索结果' },
1549
+ searchResults.length === 0
1550
+ ? h('div', { className: 'dtg-empty' }, `没有找到“${search?.trim()}”`)
1551
+ : searchResults.map((session) =>
1552
+ h(SessionRow, {
1553
+ key: session.id,
1554
+ session,
1555
+ api: rowApi,
1556
+ hint: groupById.get(state.assignments[session.id])?.name ?? workspaceOf.get(session.id)?.title ?? '',
1557
+ }),
1558
+ ),
1559
+ )
1560
+ } else if (state.prefs.activity) {
1561
+ body = h(
1562
+ 'div',
1563
+ { key: 'activity', className: 'dtg-list', role: 'tree', 'aria-label': '活动' },
1564
+ h(
1565
+ 'div',
1566
+ { className: 'dtg-section-label' },
1567
+ '优先级',
1568
+ h('span', { className: 'dtg-spacer' }),
1569
+ h('span', { className: 'dtg-count' }, String(priority.length)),
1570
+ ),
1571
+ priority.length === 0
1572
+ ? h('div', { className: 'dtg-empty dtg-empty-sm' }, '当前没有正在进行的对话')
1573
+ : priority.map((session) =>
1574
+ h(SessionRow, {
1575
+ key: session.id,
1576
+ session,
1577
+ api: rowApi,
1578
+ priority: true,
1579
+ }),
1580
+ ),
1581
+ h(
1582
+ 'div',
1583
+ { className: 'dtg-section-label' },
1584
+ '其他',
1585
+ h('span', { className: 'dtg-spacer' }),
1586
+ h('span', { className: 'dtg-count' }, String(rest.length)),
1587
+ ),
1588
+ rest.map((session) => h(SessionRow, { key: session.id, session, api: rowApi })),
1589
+ )
1590
+ } else if (state.prefs.view === 'projects') {
1591
+ body = h(
1592
+ 'div',
1593
+ { key: 'projects', className: 'dtg-list', role: 'tree', 'aria-label': '项目' },
1594
+ projectRows.buckets.map(({ workspace, rows }) =>
1595
+ h(ProjectSection, {
1596
+ key: workspace.workspaceId,
1597
+ workspace,
1598
+ rows,
1599
+ api: rowApi,
1600
+ collapsed: Boolean(collapsedProjects[workspace.workspaceId]),
1601
+ onToggle: () => setCollapsedProjects((previous) => ({ ...previous, [workspace.workspaceId]: !previous[workspace.workspaceId] })),
1602
+ onCreate: () => rootCtx.uiWorkspace.startSession(workspace.workspaceId),
1603
+ limit: projectLimit(workspace.workspaceId, rows),
1604
+ expanded: projectLimits[workspace.workspaceId] !== undefined,
1605
+ onShowMore: () => showMoreProjects(workspace.workspaceId, rows),
1606
+ onShowLess: () => showLessProjects(workspace.workspaceId, rows),
1607
+ }),
1608
+ ),
1609
+ projectRows.ungrouped.length > 0
1610
+ ? h(ProjectSection, {
1611
+ key: '__ungrouped',
1612
+ workspace: null,
1613
+ rows: projectRows.ungrouped,
1614
+ api: rowApi,
1615
+ collapsed: Boolean(collapsedProjects.__ungrouped),
1616
+ onToggle: () => setCollapsedProjects((previous) => ({ ...previous, __ungrouped: !previous.__ungrouped })),
1617
+ limit: projectLimit('__ungrouped', projectRows.ungrouped),
1618
+ expanded: projectLimits.__ungrouped !== undefined,
1619
+ onShowMore: () => showMoreProjects('__ungrouped', projectRows.ungrouped),
1620
+ onShowLess: () => showLessProjects('__ungrouped', projectRows.ungrouped),
1621
+ })
1622
+ : null,
1623
+ projectRows.buckets.length === 0 && projectRows.ungrouped.length === 0
1624
+ ? h('div', { className: 'dtg-empty' }, '还没有任务')
1625
+ : null,
1626
+ )
1627
+ } else {
1628
+ // One flat list: ungrouped tasks and group cards interleaved. A group's
1629
+ // `slot` counts how many ungrouped rows render above its card, so it can
1630
+ // sit anywhere — including between two ungrouped tasks. There is no
1631
+ // "未分组" header; ungrouped tasks are simply rows without a group.
1632
+ const visibleGroups = ghost ? groups.filter((group) => group.id !== ghost.id) : groups
1633
+ const ungroupedRows = byGroup.ungrouped
1634
+ ungroupedRef.current = ungroupedRows
1635
+ const visibleSessionIds = new Set(ungroupedRows.map((session) => session.id))
1636
+ for (const id of [...ungroupedElsRef.current.keys()]) {
1637
+ if (!visibleSessionIds.has(id)) ungroupedElsRef.current.delete(id)
1638
+ }
1639
+
1640
+ const groupsBySlot = new Map()
1641
+ for (const group of visibleGroups) {
1642
+ const slot = Math.max(0, Math.min(ungroupedRows.length, Math.floor((group.order ?? 0) / SLOT_UNIT)))
1643
+ if (!groupsBySlot.has(slot)) groupsBySlot.set(slot, [])
1644
+ groupsBySlot.get(slot).push(group)
1645
+ }
1646
+
1647
+ const renderGroup = (group) =>
1648
+ h(GroupSection, {
1649
+ key: group.id,
1650
+ group,
1651
+ rows: byGroup.buckets.get(group.id) ?? [],
1652
+ api: rowApi,
1653
+ editing: editingGroup?.id === group.id ? editingGroup : null,
1654
+ confirming: confirmingGroup === group.id,
1655
+ menuOpen: groupMenu === group.id,
1656
+ actionsOpen: groupActions === group.id,
1657
+ onActionsOpen: () => setGroupActions(group.id),
1658
+ onActionsClose: () => setGroupActions(null),
1659
+ onMarkAllRead: () => markGroupRead(group.id),
1660
+ onArchiveAll: () => { void archiveGroup(group.id) },
1661
+ archiveConfirm: confirmingArchive === group.id,
1662
+ onArchiveRequest: () => setConfirmingArchive(group.id),
1663
+ onArchiveCancel: () => setConfirmingArchive(null),
1664
+ onArchiveConfirm: () => {
1665
+ setConfirmingArchive(null)
1666
+ void archiveGroup(group.id)
1667
+ },
1668
+ onNewGroup: () => setEditingGroup({ id: null, name: '' }),
1669
+ isDrop: dropTarget === group.id,
1670
+ dropProps: dropProps(group.id),
1671
+ onToggle: () => patchGroup(group.id, { collapsed: !group.collapsed }),
1672
+ onMenuOpen: () => setGroupMenu(group.id),
1673
+ onMenuClose: () => setGroupMenu(null),
1674
+ onRename: (name) => {
1675
+ renameGroup(group.id, name)
1676
+ setEditingGroup(null)
1677
+ },
1678
+ onEditCancel: () => setEditingGroup(null),
1679
+ onStartRename: () => setEditingGroup({ id: group.id, name: group.name }),
1680
+ onDelete: () => {
1681
+ deleteGroup(group.id)
1682
+ setConfirmingGroup(null)
1683
+ },
1684
+ onConfirmCancel: () => setConfirmingGroup(null),
1685
+ onRequestDelete: () => setConfirmingGroup(group.id),
1686
+ onColor: (color) => patchGroup(group.id, { color }),
1687
+ onNewTask: () => startTaskInGroup(group.id),
1688
+ dragging: ghost?.id === group.id,
1689
+ suppressClickRef,
1690
+ dragRefStart: beginGroupDrag,
1691
+ registerEl: (element) => {
1692
+ if (element) groupElsRef.current.set(group.id, element)
1693
+ else groupElsRef.current.delete(group.id)
1694
+ },
1695
+ })
1696
+
1697
+ const renderSession = (session) =>
1698
+ h(SessionRow, {
1699
+ key: session.id,
1700
+ session,
1701
+ api: rowApi,
1702
+ rowRef: (element) => {
1703
+ if (element) ungroupedElsRef.current.set(session.id, element)
1704
+ else ungroupedElsRef.current.delete(session.id)
1705
+ },
1706
+ })
1707
+
1708
+ const rows = []
1709
+ // The "new group" affordance sits right below the last group rather than
1710
+ // at the very bottom of the list.
1711
+ let newGroupAt = 0
1712
+ for (let index = 0; index <= ungroupedRows.length; index += 1) {
1713
+ for (const group of groupsBySlot.get(index) ?? []) {
1714
+ rows.push(renderGroup(group))
1715
+ newGroupAt = rows.length
1716
+ }
1717
+ if (index < ungroupedRows.length) rows.push(renderSession(ungroupedRows[index]))
1718
+ }
1719
+ // Only while creating: a name field appears below the last group. The
1720
+ // resting list has no permanent "new group" button.
1721
+ if (editingGroup && editingGroup.id === null) {
1722
+ rows.splice(
1723
+ newGroupAt,
1724
+ 0,
1725
+ h(GroupEditor, {
1726
+ key: 'new-group',
1727
+ initial: '',
1728
+ label: '新分组名称',
1729
+ onSubmit: (name) => {
1730
+ if (createGroup(name)) setEditingGroup(null)
1731
+ },
1732
+ onCancel: () => setEditingGroup(null),
1733
+ }),
1734
+ )
1735
+ }
1736
+ if (dropLine !== null) {
1737
+ // The editor shifts every row after it, so offset the insertion line.
1738
+ const shifted = editingGroup && editingGroup.id === null && newGroupAt <= dropLine ? 1 : 0
1739
+ const at = dropLine + shifted
1740
+ rows.splice(Math.max(0, Math.min(rows.length, at)), 0, h('div', {
1741
+ key: 'drop-line',
1742
+ className: 'dtg-drop-line',
1743
+ 'aria-hidden': 'true',
1744
+ }))
1745
+ }
1746
+
1747
+ body = h(
1748
+ 'div',
1749
+ {
1750
+ key: 'groups',
1751
+ ref: listRef,
1752
+ className: 'dtg-list',
1753
+ role: 'tree',
1754
+ 'aria-label': '分组',
1755
+ // Dropping a session on the list background moves it out of its group.
1756
+ onDragOver: (event) => {
1757
+ if (dragging === null) return
1758
+ event.preventDefault()
1759
+ event.dataTransfer.dropEffect = 'move'
1760
+ if (dropTarget !== 'ungrouped') setDropTarget('ungrouped')
1761
+ },
1762
+ onDragLeave: (event) => {
1763
+ if (event.currentTarget.contains(event.relatedTarget)) return
1764
+ setDropTarget((previous) => (previous === 'ungrouped' ? undefined : previous))
1765
+ },
1766
+ onDrop: (event) => {
1767
+ event.preventDefault()
1768
+ const sessionId = dragging ?? event.dataTransfer.getData('text/plain')
1769
+ setDragging(null)
1770
+ setDropTarget(undefined)
1771
+ if (sessionId) assign(sessionId, null)
1772
+ },
1773
+ },
1774
+ rows,
1775
+ groups.length === 0 && ungroupedRows.length === 0 ? h('div', { className: 'dtg-empty' }, '还没有任务') : null,
1776
+ )
1777
+ }
1778
+
1779
+ const ghostGroup = ghost ? groupById.get(ghost.id) : null
1780
+ const ghostNode =
1781
+ ghost && ghostGroup
1782
+ ? portalTo(
1783
+ h(
1784
+ 'div',
1785
+ {
1786
+ ref: ghostElRef,
1787
+ className: 'dtg-group-head dtg-group-card dtg-group-ghost',
1788
+ style: { left: ghost.left, top: ghost.top, width: ghost.width, '--dtg-group-color': colorOf(ghostGroup) },
1789
+ 'aria-hidden': 'true',
1790
+ },
1791
+ h('span', { className: cx('dtg-chevron', !ghostGroup.collapsed && 'is-open') }, h(IconChevronRightOutlineRegular, { size: 14 })),
1792
+ h(HashIcon, { color: colorOf(ghostGroup) }),
1793
+ h('span', { className: 'dtg-group-name' }, ghostGroup.name),
1794
+ h('span', { className: 'dtg-count' }, String((byGroup.buckets.get(ghostGroup.id) ?? []).length)),
1795
+ ),
1796
+ )
1797
+ : null
1798
+
1799
+ const sessionGhostSession = sessionGhost ? sortedRef.current.find((session) => session.id === sessionGhost.id) : null
1800
+ const sessionGhostNode =
1801
+ sessionGhost && sessionGhostSession
1802
+ ? portalTo(
1803
+ h(
1804
+ 'div',
1805
+ {
1806
+ ref: sessionGhostElRef,
1807
+ className: 'dtg-session-ghost',
1808
+ style: { left: sessionGhost.left, top: sessionGhost.top, width: sessionGhost.width },
1809
+ 'aria-hidden': 'true',
1810
+ },
1811
+ h('span', { className: 'dtg-session-ghost-title' }, sessionGhostSession.displayTitle || '新会话'),
1812
+ ),
1813
+ )
1814
+ : null
1815
+ const sessionLineNode = sessionLine
1816
+ ? portalTo(
1817
+ h('div', {
1818
+ className: 'dtg-session-line',
1819
+ style: { top: sessionLine.y, left: sessionLine.left, width: sessionLine.width },
1820
+ 'aria-hidden': 'true',
1821
+ }),
1822
+ )
1823
+ : null
1824
+
1825
+ return h(
1826
+ 'div',
1827
+ { className: 'dtg-root', 'data-dtg-root': '' },
1828
+ header,
1829
+ searchRow,
1830
+ body,
1831
+ ghostNode,
1832
+ sessionGhostNode,
1833
+ sessionLineNode,
1834
+ toast ? h(Toast, { key: toast.key, text: toast.text, onDone: () => setToast(null) }) : null,
1835
+ )
1836
+ }
1837
+
1838
+ // ---------------------------------------------------------------------
1839
+ // session row
1840
+ // ---------------------------------------------------------------------
1841
+
1842
+ function SessionRow({ session, api, priority, trailing, hint, rowRef }) {
1843
+ const { ctx, groups } = api
1844
+ const committedRef = useRef(false)
1845
+ const renamingId = api.renaming?.id ?? null
1846
+ useEffect(() => {
1847
+ committedRef.current = false
1848
+ }, [renamingId])
1849
+ const status = statusOf(ctx.statusMap, session.id)
1850
+ const selected = ctx.currentSession === session.id
1851
+ const pinned = ctx.pinnedIndex.has(session.id)
1852
+ const groupId = ctx.state.assignments[session.id]
1853
+ const group = groupId ? ctx.groupById.get(groupId) : undefined
1854
+ const menuOpen = api.menuId === session.id
1855
+ const manualUnread = Boolean(api.manualUnread?.has(session.id))
1856
+
1857
+ let meta = relativeLabel(session.updatedAt)
1858
+ let metaClass = ''
1859
+ if (status?.pendingInteraction) {
1860
+ meta = '待确认'
1861
+ metaClass = 'is-warn'
1862
+ } else if (status?.running) {
1863
+ meta = ''
1864
+ metaClass = 'is-live'
1865
+ } else if (status?.completionUnread || manualUnread) {
1866
+ // Unread shows a blue dot instead of the word.
1867
+ meta = ''
1868
+ metaClass = 'is-unread'
1869
+ }
1870
+ const unread = Boolean(status?.completionUnread) || manualUnread
1871
+
1872
+ const dotState = status?.pendingInteraction
1873
+ ? 'warning'
1874
+ : status?.completionUnread
1875
+ ? 'done'
1876
+ : manualUnread
1877
+ ? 'done'
1878
+ : null
1879
+
1880
+ // Submenu parents carry a trailing chevron; the primitive only marks them
1881
+ // with aria-haspopup, which is invisible.
1882
+ const submenuLabel = (text) =>
1883
+ h(
1884
+ 'span',
1885
+ { className: 'dtg-submenu-label' },
1886
+ h('span', null, text),
1887
+ h(IconChevronRightOutlineRegular, { size: 12, className: 'dtg-submenu-caret' }),
1888
+ )
1889
+
1890
+ const items = [
1891
+ { id: 'open', label: '打开任务' },
1892
+ { type: 'separator', id: 'sep-open' },
1893
+ { id: 'rename', label: '重命名' },
1894
+ {
1895
+ id: 'pin',
1896
+ label: pinned ? '取消置顶' : '置顶',
1897
+ },
1898
+ { id: 'mark-unread', label: manualUnread ? '标记为已读' : '标记为未读' },
1899
+ { id: 'fork', label: '分支' },
1900
+ { type: 'separator', id: 'sep-copy' },
1901
+ {
1902
+ id: 'copy',
1903
+ label: submenuLabel('复制'),
1904
+ submenu: [
1905
+ { id: 'copy-cwd', label: '复制工作目录', disabled: !ctx.workspaceOf.get(session.id)?.path },
1906
+ { id: 'copy-id', label: '复制会话 ID' },
1907
+ ],
1908
+ },
1909
+ {
1910
+ id: 'move',
1911
+ label: submenuLabel('移动到分组'),
1912
+ submenu: [
1913
+ ...groups.map((entry) => ({
1914
+ id: `move:${entry.id}`,
1915
+ label: entry.name,
1916
+ icon: h(HashIcon, { color: colorOf(entry), size: 14 }),
1917
+ })),
1918
+ { id: 'move:', label: '移出分组', disabled: !groupId },
1919
+ ],
1920
+ },
1921
+ { type: 'separator', id: 'sep-archive' },
1922
+ { id: 'archive', label: status?.running ? '运行中,无法归档' : '归档', disabled: Boolean(status?.running) },
1923
+ ]
1924
+
1925
+ const onSelect = (id) => {
1926
+ api.onCloseMenu()
1927
+ if (id === 'open') api.onOpen(session.id)
1928
+ else if (id === 'rename') api.onStartRename(session.id, title)
1929
+ else if (id === 'pin') api.onPin(session.id, pinned)
1930
+ else if (id === 'mark-unread') api.onMarkUnread(session.id, !manualUnread)
1931
+ else if (id === 'fork') api.onFork(session.id)
1932
+ else if (id === 'copy-cwd') api.onCopy(ctx.workspaceOf.get(session.id)?.path ?? '')
1933
+ else if (id === 'copy-id') api.onCopy(session.id)
1934
+ else if (id === 'archive') api.onArchive(session.id)
1935
+ else if (id.startsWith('move:')) api.onAssign(session.id, id.slice(5) || null)
1936
+ }
1937
+
1938
+ const title = session.blank ? '新会话' : session.displayTitle || '未命名'
1939
+ const workspaceTitle = ctx.workspaceOf.get(session.id)?.title ?? ''
1940
+
1941
+ if (api.renaming && api.renaming.id === session.id) {
1942
+ const commitRename = (event) => {
1943
+ if (committedRef.current) return
1944
+ committedRef.current = true
1945
+ api.onRename(session.id, event.currentTarget.value)
1946
+ }
1947
+ return h(
1948
+ 'div',
1949
+ { ref: rowRef, className: 'dtg-row dtg-renaming' },
1950
+ h('input', {
1951
+ className: 'dtg-rename-input',
1952
+ defaultValue: api.renaming.title,
1953
+ autoFocus: true,
1954
+ 'aria-label': '重命名任务',
1955
+ onKeyDown: (event) => {
1956
+ if (event.key === 'Enter') {
1957
+ event.preventDefault()
1958
+ commitRename(event)
1959
+ } else if (event.key === 'Escape') {
1960
+ event.preventDefault()
1961
+ committedRef.current = true
1962
+ api.onCancelRename()
1963
+ }
1964
+ },
1965
+ onBlur: commitRename,
1966
+ }),
1967
+ )
1968
+ }
1969
+
1970
+ return h(
1971
+ 'div',
1972
+ {
1973
+ ref: rowRef,
1974
+ className: cx('dtg-row', 'dtg-sessionRow', selected && 'is-selected', menuOpen && 'is-menu-open'),
1975
+ role: 'treeitem',
1976
+ 'aria-selected': selected,
1977
+ 'aria-label': title,
1978
+ title: [group ? `#${group.name}` : '', hint || workspaceTitle, title].filter(Boolean).join(' · '),
1979
+ tabIndex: -1,
1980
+ 'data-session-id': session.id,
1981
+ draggable: false,
1982
+ onDragStart: (event) => event.preventDefault(),
1983
+ onClick: () => {
1984
+ if (api.draggedRef?.current) return
1985
+ api.onOpen(session.id)
1986
+ },
1987
+ onContextMenu: (event) => {
1988
+ event.preventDefault()
1989
+ event.stopPropagation()
1990
+ api.onMenu(session.id)
1991
+ },
1992
+ onPointerDown: (event) => api.onRowPointerDown(event, session.id),
1993
+ onKeyDown: (event) => {
1994
+ if (event.key === 'Enter' || event.key === ' ') {
1995
+ event.preventDefault()
1996
+ api.onOpen(session.id)
1997
+ }
1998
+ },
1999
+ },
2000
+ h('span', { className: 'dtg-dot-slot' }, dotState ? h(StateDot, { state: dotState, size: dotState === 'ongoing' ? 12 : 8 }) : null),
2001
+ h('span', { className: 'dtg-title' }, title),
2002
+ hint ? h('span', { className: 'dtg-hint' }, hint) : null,
2003
+ h(
2004
+ 'span',
2005
+ { className: 'dtg-indicators' },
2006
+ pinned ? h('span', { className: 'dtg-pin' }, h(IconPinFillRegular, { size: 14 })) : null,
2007
+ status?.running
2008
+ ? h('span', { className: 'dtg-unread-slot' }, h(StateDot, { state: 'ongoing', size: 12 }))
2009
+ : unread
2010
+ ? h('span', { className: 'dtg-unread-slot' }, h('span', { className: 'dtg-unread-dot', title: '未读' }))
2011
+ : h('span', { className: cx('dtg-meta', metaClass) }, meta),
2012
+ ),
2013
+ h(
2014
+ 'span',
2015
+ { className: 'dtg-row-actions', onClick: (event) => event.stopPropagation() },
2016
+ h(
2017
+ Tooltip,
2018
+ { label: pinned ? '取消置顶' : '置顶', side: 'bottom', portal: true },
2019
+ h(
2020
+ 'button',
2021
+ {
2022
+ type: 'button',
2023
+ className: 'dtg-action',
2024
+ 'aria-label': pinned ? '取消置顶' : '置顶',
2025
+ onClick: (event) => {
2026
+ event.stopPropagation()
2027
+ api.onPin(session.id, pinned)
2028
+ },
2029
+ },
2030
+ pinned ? h(IconPinFillRegular, { size: 14 }) : h(IconPinOutlineRegular, { size: 14 }),
2031
+ ),
2032
+ ),
2033
+ h(
2034
+ Tooltip,
2035
+ { label: status?.running ? '运行中,无法归档' : '归档', side: 'bottom', portal: true },
2036
+ h(
2037
+ 'button',
2038
+ {
2039
+ type: 'button',
2040
+ className: 'dtg-action',
2041
+ 'aria-label': '归档',
2042
+ disabled: Boolean(status?.running),
2043
+ onClick: (event) => {
2044
+ event.stopPropagation()
2045
+ api.onArchive(session.id)
2046
+ },
2047
+ },
2048
+ h(ArchiveIcon, { size: 14 }),
2049
+ ),
2050
+ ),
2051
+ h(Menu, {
2052
+ open: menuOpen,
2053
+ onClose: api.onCloseMenu,
2054
+ align: 'end',
2055
+ side: 'bottom',
2056
+ portal: true,
2057
+ listClassName: 'dtg-menu-wide',
2058
+ anchor: h('span', { className: 'dtg-row-menu-anchor', 'aria-hidden': 'true' }),
2059
+ items,
2060
+ onSelect,
2061
+ }),
2062
+ trailing ?? null,
2063
+ ),
2064
+ )
2065
+ }
2066
+
2067
+ // ---------------------------------------------------------------------
2068
+ // sections
2069
+ // ---------------------------------------------------------------------
2070
+
2071
+ function GroupSection({
2072
+ group,
2073
+ rows,
2074
+ api,
2075
+ editing,
2076
+ confirming,
2077
+ menuOpen,
2078
+ isDrop,
2079
+ dropProps,
2080
+ onToggle,
2081
+ onMenuOpen,
2082
+ onMenuClose,
2083
+ actionsOpen,
2084
+ onActionsOpen,
2085
+ onActionsClose,
2086
+ onMarkAllRead,
2087
+ onArchiveAll,
2088
+ archiveConfirm,
2089
+ onArchiveRequest,
2090
+ onArchiveCancel,
2091
+ onArchiveConfirm,
2092
+ onNewGroup,
2093
+ onRename,
2094
+ onEditCancel,
2095
+ onStartRename,
2096
+ onDelete,
2097
+ onConfirmCancel,
2098
+ onRequestDelete,
2099
+ onColor,
2100
+ onNewTask,
2101
+ dragging,
2102
+ suppressClickRef,
2103
+ registerEl,
2104
+ dragRefStart,
2105
+ }) {
2106
+ const color = colorOf(group)
2107
+ const lastClickRef = useRef(0)
2108
+ const dragBlocked = () => Boolean(suppressClickRef?.current)
2109
+ const head = confirming
2110
+ ? h(
2111
+ 'div',
2112
+ { className: 'dtg-confirm' },
2113
+ h('span', { className: 'dtg-confirm-text' }, `删除“${group.name}”?`),
2114
+ h('button', { type: 'button', className: 'dtg-confirm-danger', onClick: onDelete }, '删除'),
2115
+ h('button', { type: 'button', className: 'dtg-confirm-cancel', onClick: onConfirmCancel }, '取消'),
2116
+ )
2117
+ : editing
2118
+ ? h(GroupEditor, { initial: editing.name, label: '分组名称', onSubmit: onRename, onCancel: onEditCancel })
2119
+ : h(
2120
+ 'div',
2121
+ {
2122
+ ref: registerEl,
2123
+ className: cx('dtg-group-head', 'dtg-group-card', dragging && 'is-dragging', menuOpen && 'is-menu-open', actionsOpen && 'is-menu-open'),
2124
+ onContextMenu: (event) => {
2125
+ event.preventDefault()
2126
+ event.stopPropagation()
2127
+ onActionsOpen()
2128
+ },
2129
+ onClick: (event) => {
2130
+ if (dragBlocked()) {
2131
+ event.stopPropagation()
2132
+ event.preventDefault()
2133
+ return
2134
+ }
2135
+ // A short (250ms) manual double-click window: the OS default
2136
+ // is ~500ms, which turned two ordinary expand/collapse clicks
2137
+ // into a rename.
2138
+ const now = Date.now()
2139
+ if (now - lastClickRef.current <= 250) {
2140
+ lastClickRef.current = 0
2141
+ event.stopPropagation()
2142
+ onStartRename()
2143
+ return
2144
+ }
2145
+ lastClickRef.current = now
2146
+ onToggle()
2147
+ },
2148
+ role: 'treeitem',
2149
+ 'aria-expanded': !group.collapsed,
2150
+ tabIndex: -1,
2151
+ title: `${group.name}(拖动排序,双击重命名)`,
2152
+ style: { '--dtg-group-color': color },
2153
+ onPointerDown: (event) => {
2154
+ if (event.button !== 0 && event.button !== -1) return
2155
+ if (event.target.closest('[data-no-drag]')) return
2156
+ dragRefStart(event, group.id)
2157
+ },
2158
+ },
2159
+ h(Menu, {
2160
+ open: menuOpen,
2161
+ onClose: onMenuClose,
2162
+ align: 'start',
2163
+ side: 'bottom',
2164
+ portal: true,
2165
+ selectedId: `color:${group.color}`,
2166
+ anchor: h(
2167
+ Tooltip,
2168
+ { label: '分组颜色 / 重命名 / 删除', side: 'bottom', portal: true },
2169
+ h(
2170
+ 'button',
2171
+ {
2172
+ type: 'button',
2173
+ className: 'dtg-group-color',
2174
+ 'aria-label': '分组颜色',
2175
+ 'data-no-drag': '',
2176
+ onPointerDown: (event) => event.stopPropagation(),
2177
+ onClick: (event) => {
2178
+ event.stopPropagation()
2179
+ onMenuOpen()
2180
+ },
2181
+ },
2182
+ h(HashIcon, { color, filled: true, size: 22 }),
2183
+ ),
2184
+ ),
2185
+ items: [
2186
+ { type: 'label', id: 'color-label', text: '分组颜色' },
2187
+ ...GROUP_COLORS.map((entry) => ({
2188
+ id: `color:${entry.id}`,
2189
+ label: entry.label,
2190
+ icon: h('span', { className: 'dtg-swatch', style: { background: entry.value } }),
2191
+ })),
2192
+ { type: 'separator', id: 'sep-group-1' },
2193
+ { id: 'rename', label: '重命名' },
2194
+ { id: 'delete', label: '删除分组', danger: true },
2195
+ ],
2196
+ onSelect: (id) => {
2197
+ onMenuClose()
2198
+ if (id.startsWith('color:')) onColor(id.slice(6))
2199
+ else if (id === 'rename') onStartRename()
2200
+ else if (id === 'delete') onRequestDelete()
2201
+ },
2202
+ }),
2203
+ h('span', { className: 'dtg-group-name' }, group.name),
2204
+ h('span', { className: cx('dtg-chevron', !group.collapsed && 'is-open') }, h(IconChevronRightOutlineRegular, { size: 14 })),
2205
+ h('span', { className: 'dtg-spacer' }),
2206
+ h('span', { className: 'dtg-count' }, String(rows.length)),
2207
+ h(
2208
+ Tooltip,
2209
+ { label: '在此分组新建任务', side: 'bottom', portal: true },
2210
+ h(
2211
+ 'button',
2212
+ {
2213
+ type: 'button',
2214
+ className: 'dtg-group-add',
2215
+ 'aria-label': '在分组中新建任务',
2216
+ 'data-no-drag': '',
2217
+ onPointerDown: (event) => event.stopPropagation(),
2218
+ onClick: (event) => {
2219
+ event.stopPropagation()
2220
+ onNewTask()
2221
+ },
2222
+ },
2223
+ h(IconPlusCircle, { size: 16 }),
2224
+ ),
2225
+ ),
2226
+ h(Menu, {
2227
+ open: actionsOpen,
2228
+ onClose: onActionsClose,
2229
+ align: 'end',
2230
+ side: 'bottom',
2231
+ portal: true,
2232
+ listClassName: 'dtg-menu-wide',
2233
+ anchor: h(
2234
+ Tooltip,
2235
+ { label: '分组操作', side: 'bottom', portal: true },
2236
+ h(
2237
+ 'button',
2238
+ {
2239
+ type: 'button',
2240
+ className: 'dtg-action',
2241
+ 'aria-label': '分组操作',
2242
+ 'data-no-drag': '',
2243
+ onPointerDown: (event) => event.stopPropagation(),
2244
+ onClick: (event) => {
2245
+ event.stopPropagation()
2246
+ onActionsOpen()
2247
+ },
2248
+ },
2249
+ h(IconEllipsisOutlineRegular, { size: 14 }),
2250
+ ),
2251
+ ),
2252
+ items: [
2253
+ { id: 'edit', label: '编辑' },
2254
+ { type: 'separator', id: 'sep-ga1' },
2255
+ { id: 'read-all', label: '全部标为已读', disabled: rows.length === 0 },
2256
+ { id: 'archive-all', label: '归档聊天', disabled: rows.length === 0 },
2257
+ { type: 'separator', id: 'sep-ga2' },
2258
+ { id: 'new-group', label: '新建分组' },
2259
+ { id: 'delete', label: '删除分组', danger: true },
2260
+ ],
2261
+ onSelect: (id) => {
2262
+ onActionsClose()
2263
+ if (id === 'edit') onStartRename()
2264
+ else if (id === 'read-all') onMarkAllRead()
2265
+ else if (id === 'archive-all') onArchiveRequest()
2266
+ else if (id === 'new-group') onNewGroup()
2267
+ else if (id === 'delete') onRequestDelete()
2268
+ },
2269
+ }),
2270
+ )
2271
+
2272
+ return h(
2273
+ 'div',
2274
+ {
2275
+ className: cx('dtg-section', isDrop && 'is-drop'),
2276
+ 'data-group-id': group.id,
2277
+ ...dropProps,
2278
+ },
2279
+ head,
2280
+ group.collapsed
2281
+ ? null
2282
+ : archiveConfirm
2283
+ ? h(
2284
+ 'div',
2285
+ { key: 'body', className: 'dtg-group-body' },
2286
+ h(
2287
+ 'div',
2288
+ { className: 'dtg-confirm dtg-confirm-inline' },
2289
+ h('span', { className: 'dtg-confirm-text' }, `归档“${group.name}”里的已完成对话?`),
2290
+ h('button', { type: 'button', className: 'dtg-confirm-danger', onClick: onArchiveConfirm }, '归档'),
2291
+ h('button', { type: 'button', className: 'dtg-confirm-cancel', onClick: onArchiveCancel }, '取消'),
2292
+ ),
2293
+ )
2294
+ : h(
2295
+ 'div',
2296
+ {
2297
+ key: 'body',
2298
+ className: cx('dtg-group-body', rows.length === 0 && 'is-empty'),
2299
+ style: { '--dtg-group-color': color },
2300
+ },
2301
+ rows.length === 0
2302
+ ? h(
2303
+ 'button',
2304
+ {
2305
+ type: 'button',
2306
+ className: cx('dtg-group-drop', isDrop && 'is-active'),
2307
+ onClick: (event) => {
2308
+ event.stopPropagation()
2309
+ onNewTask()
2310
+ },
2311
+ },
2312
+ h(IconPlusCircle, { size: 14 }),
2313
+ h('span', null, '新建任务,或拖拽到这里。'),
2314
+ )
2315
+ : rows.map((session) => h(SessionRow, { key: session.id, session, api })),
2316
+ ),
2317
+ )
2318
+ }
2319
+
2320
+ function ProjectSection({ workspace, rows, api, collapsed, onToggle, onCreate, limit, expanded, onShowMore, onShowLess }) {
2321
+ const visible = rows.slice(0, limit)
2322
+ const hidden = rows.length - visible.length
2323
+ return h(
2324
+ 'div',
2325
+ { className: 'dtg-section' },
2326
+ h(
2327
+ 'div',
2328
+ {
2329
+ className: 'dtg-group-head',
2330
+ onClick: onToggle,
2331
+ role: 'treeitem',
2332
+ 'aria-expanded': !collapsed,
2333
+ tabIndex: -1,
2334
+ },
2335
+ h('span', { className: cx('dtg-chevron', !collapsed && 'is-open') }, h(IconChevronRightOutlineRegular, { size: 14 })),
2336
+ h('span', { className: 'dtg-folder' }, collapsed ? h(IconFolderCloseRegular, { size: 15 }) : h(IconFolderOpenRegular, { size: 15 })),
2337
+ h('span', { className: 'dtg-group-name' }, workspace ? workspace.title || workspace.path : '未分组'),
2338
+ h('span', { className: 'dtg-count' }, String(rows.length)),
2339
+ onCreate
2340
+ ? h(
2341
+ 'span',
2342
+ { className: 'dtg-row-actions', onClick: (event) => event.stopPropagation() },
2343
+ h(
2344
+ Tooltip,
2345
+ { label: '在此项目新建任务', side: 'bottom', portal: true },
2346
+ h(
2347
+ 'button',
2348
+ { type: 'button', className: 'dtg-action', 'aria-label': '在此项目新建任务', onClick: onCreate },
2349
+ h(IconPlusOutlineRegular, { size: 14 }),
2350
+ ),
2351
+ ),
2352
+ )
2353
+ : null,
2354
+ ),
2355
+ collapsed
2356
+ ? null
2357
+ : rows.length === 0
2358
+ ? h('div', { className: 'dtg-group-empty' }, '还没有任务')
2359
+ : [
2360
+ ...visible.map((session) => h(SessionRow, { key: session.id, session, api })),
2361
+ hidden > 0
2362
+ ? h(
2363
+ 'button',
2364
+ { key: 'more', type: 'button', className: 'dtg-more', onClick: onShowMore },
2365
+ `显示更多(还有 ${hidden} 个)`,
2366
+ )
2367
+ : expanded
2368
+ ? h('button', { key: 'less', type: 'button', className: 'dtg-more', onClick: onShowLess }, '显示更少')
2369
+ : null,
2370
+ ],
2371
+ )
2372
+ }
2373
+
2374
+ // ---------------------------------------------------------------------
2375
+ // inline group editor
2376
+ // ---------------------------------------------------------------------
2377
+
2378
+ function GroupEditor({ initial, label, onSubmit, onCancel }) {
2379
+ const [value, setValue] = useState(initial)
2380
+ const ref = useRef(null)
2381
+ useEffect(() => {
2382
+ ref.current?.focus()
2383
+ ref.current?.select()
2384
+ }, [])
2385
+ return h(
2386
+ 'div',
2387
+ { className: 'dtg-editor' },
2388
+ h(HashIcon, { color: '#8b8d98' }),
2389
+ h('input', {
2390
+ ref,
2391
+ className: 'dtg-editor-input',
2392
+ value,
2393
+ placeholder: label,
2394
+ 'aria-label': label,
2395
+ onChange: (event) => setValue(event.target.value),
2396
+ onKeyDown: (event) => {
2397
+ if (event.key === 'Enter') {
2398
+ event.preventDefault()
2399
+ onSubmit(value)
2400
+ } else if (event.key === 'Escape') {
2401
+ event.preventDefault()
2402
+ onCancel()
2403
+ }
2404
+ },
2405
+ }),
2406
+ h(
2407
+ Tooltip,
2408
+ { label: '确定', side: 'bottom', portal: true },
2409
+ h('button', { type: 'button', className: 'dtg-action', 'aria-label': '确定', onClick: () => onSubmit(value) }, h(IconCheckOutlineRegular, { size: 14 })),
2410
+ ),
2411
+ h(
2412
+ Tooltip,
2413
+ { label: '取消', side: 'bottom', portal: true },
2414
+ h('button', { type: 'button', className: 'dtg-action', 'aria-label': '取消', onClick: onCancel }, h(IconCloseOutlineRegular, { size: 14 })),
2415
+ ),
2416
+ )
2417
+ }
2418
+
2419
+ // ---------------------------------------------------------------------
2420
+ // styles
2421
+ // ---------------------------------------------------------------------
2422
+
2423
+ const CSS = `
2424
+ .dtg-root{display:flex;flex-direction:column;flex:1;min-height:0;box-sizing:border-box;padding-right:var(--dsh-sidebar-inline-padding,8px)}
2425
+ .dtg-root.dtg-rail{padding-right:0;align-items:center;gap:8px;padding-top:4px}
2426
+ .dtg-header{display:flex;align-items:center;gap:4px;height:36px;flex:none;margin:2px -4px 4px 0;padding-left:4px;border-radius:var(--dsw-radius-md);overflow:hidden}
2427
+ .dtg-header-title{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);font-weight:500;padding-left:2px}
2428
+ .dtg-toggle{display:inline-flex;align-items:center;gap:2px;padding:3px;flex:1;min-width:0;max-width:132px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);box-sizing:border-box;position:relative}
2429
+ .dtg-toggle-thumb{position:absolute;top:3px;left:3px;width:calc(50% - 1px);height:calc(100% - 6px);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:var(--dsw-elevation-soft,0 1px 2px rgba(0,0,0,.08));transform:translateX(0);transition:transform .24s cubic-bezier(.22,.61,.36,1);pointer-events:none}
2430
+ .dtg-toggle-thumb.is-right{transform:translateX(calc(100% + 2px))}
2431
+ .dtg-toggle-btn{position:relative;z-index:1;flex:1;min-width:0;height:24px;padding:0 6px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;font-weight:500;line-height:20px;white-space:nowrap;cursor:pointer;transition:color .2s var(--ds-ease-in-out,ease)}
2432
+ .dtg-toggle-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
2433
+ .dtg-toggle-btn.is-active{background:transparent;color:var(--dsw-alias-label-primary);box-shadow:none}
2434
+ .dtg-toggle-btn:disabled{opacity:.4;cursor:default}
2435
+ .dtg-toggle-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2436
+ .dtg-spacer{flex:1;min-width:0}
2437
+ .dtg-icon-btn{width:28px;height:28px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;padding:0;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);cursor:pointer;position:relative;font:inherit}
2438
+ .dtg-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
2439
+ .dtg-icon-btn:disabled,.dtg-icon-btn.is-disabled{opacity:.35;cursor:default;pointer-events:none}
2440
+ .dtg-icon-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2441
+ .dtg-icon-btn.is-active{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
2442
+ .dtg-icon-btn-sm{width:24px;height:24px}
2443
+ .dtg-text-btn{display:inline-flex;align-items:center;gap:3px;border:0;background:transparent;padding:2px 4px;border-radius:var(--dsw-radius-xs,4px);color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:16px;cursor:pointer;white-space:nowrap}
2444
+ .dtg-text-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
2445
+ .dtg-text-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2446
+ .dtg-header-note{padding-right:6px}
2447
+ .dtg-bell-dot{position:absolute;top:5px;right:5px;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#3f7fdd);box-shadow:0 0 0 2px var(--dsw-specific-sidebar-fill,transparent);pointer-events:none}
2448
+ .dtg-icon-btn.dtg-bell-btn.is-active{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 14%,transparent);color:var(--dsw-alias-state-business-primary,#3f7fdd)}
2449
+ .dtg-icon-btn.dtg-bell-btn.is-active:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 22%,transparent)}
2450
+ .dtg-bell-btn.is-active .dtg-bell-dot{box-shadow:none}
2451
+ .dtg-activity-title{padding-left:6px}
2452
+ .dtg-flip{transform:rotate(180deg)}
2453
+ .dtg-search-row{display:flex;align-items:center;gap:4px;height:30px;flex:none;margin:0 -4px 4px 0;padding:0 2px 0 6px;border:0.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-caption)}
2454
+ .dtg-search-icon{flex:none;color:var(--dsw-alias-label-tertiary)}
2455
+ .dtg-search-input{flex:1;min-width:0;border:0;background:transparent;outline:none;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary)}
2456
+ .dtg-search-input::placeholder{color:var(--dsw-alias-label-tertiary)}
2457
+ .dtg-list{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 0 20px;scrollbar-gutter:stable;display:flex;flex-direction:column;animation:dtg-list-in .18s var(--ds-ease-out,ease)}
2458
+ @keyframes dtg-list-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
2459
+ .dtg-row{transition:background-color .13s ease}
2460
+ .dtg-menu-wide{min-width:212px}
2461
+ .dtg-menu-wide .submenu{min-width:184px}
2462
+ .dtg-submenu-label{display:flex;align-items:center;justify-content:space-between;gap:18px;width:100%}
2463
+ .dtg-submenu-caret{flex:none;color:var(--dsw-alias-label-tertiary);opacity:.8}
2464
+ .dtg-renaming{padding:2px 4px 2px 22px}
2465
+ .dtg-rename-input{width:100%;height:28px;box-sizing:border-box;padding:0 8px 0 20px;border:1px solid var(--dsw-alias-state-business-primary,#3f7fdd);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;outline:none}
2466
+ .dtg-icon-btn,.dtg-group-add,.dtg-group-color,.dtg-newgroup,.dtg-action,.dtg-text-btn,.dtg-more,.dtg-confirm-danger,.dtg-confirm-cancel{transition:background-color .14s ease,color .14s ease,opacity .14s ease}
2467
+ .dtg-drop-line{height:2px;margin:-1px 8px;border-radius:1px;background:var(--dsw-alias-state-business-primary,#3f7fdd);opacity:.95;flex:none;pointer-events:none;animation:dtg-line-in .14s ease}
2468
+ .dtg-drop-hint{display:flex;align-items:center;justify-content:center;margin:6px 6px 2px;height:28px;flex:none;border:1px dashed var(--dsw-alias-border-l4,rgba(0,0,0,.16));border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-tertiary);font-size:11px}
2469
+ @keyframes dtg-line-in{from{opacity:0;transform:scaleX(.6)}to{opacity:.95;transform:none}}
2470
+ .dtg-section{position:relative;border-radius:var(--dsw-radius-md);flex:none}
2471
+ .dtg-section+.dtg-section{margin-top:4px}
2472
+ .dtg-section.is-drop{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 9%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 35%,transparent)}
2473
+ .dtg-group-head{display:flex;align-items:center;gap:6px;height:30px;padding:0 4px 0 2px;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);cursor:pointer;user-select:none;font:inherit;flex:none}
2474
+ .dtg-group-head:hover,.dtg-group-head.is-menu-open{background:var(--dsw-alias-interactive-bg-hover)}
2475
+ .dtg-group-card{height:34px;width:100%;box-sizing:border-box;gap:4px;margin:2px 0;padding:0 4px 0 2px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;box-shadow:none;cursor:grab}
2476
+ .dtg-group-card:hover,.dtg-group-card.is-menu-open{background:var(--dsw-alias-interactive-bg-hover)}
2477
+ .dtg-group-card .dtg-group-name{flex:0 1 auto;font-weight:600}
2478
+ .dtg-group-card .dtg-count{min-width:0;height:auto;padding:0;background:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}
2479
+ .dtg-group-color{display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;padding:0;margin:0;border-radius:6px;cursor:pointer;flex:none}
2480
+ .dtg-group-color:hover{background:var(--dsw-alias-interactive-bg-hover)}
2481
+ .dtg-group-color:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2482
+ .dtg-group-add{width:22px;height:22px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;padding:0;border-radius:6px;color:var(--dsw-alias-label-tertiary);cursor:pointer}
2483
+ .dtg-group-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2484
+ .dtg-group-add:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2485
+ .dtg-group-body{margin-left:6px;padding-left:0;border-left:2px solid color-mix(in srgb,var(--dtg-group-color,#8b8d98) 55%,transparent)}
2486
+ .dtg-group-body.is-empty{border-left-color:color-mix(in srgb,var(--dtg-group-color,#8b8d98) 32%,transparent)}
2487
+ .dtg-group-drop{display:flex;align-items:center;gap:6px;margin:2px 4px 4px 0;padding:0 10px;height:30px;border:1px dashed var(--dsw-alias-border-l4,rgba(0,0,0,.16));border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:16px;text-align:left;white-space:nowrap;overflow:hidden;cursor:pointer}
2488
+ .dtg-group-drop:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);border-color:var(--dsw-alias-border-l3,rgba(0,0,0,.26))}
2489
+ .dtg-group-drop.is-active{border-color:var(--dsw-alias-state-business-primary,#3f7fdd);background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 7%,transparent);color:var(--dsw-alias-state-business-primary,#3f7fdd)}
2490
+ .dtg-more{display:flex;align-items:center;height:26px;margin:0 8px 2px 22px;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:16px;cursor:pointer;text-align:left;flex:none}
2491
+ .dtg-more:hover{color:var(--dsw-alias-label-secondary)}
2492
+ .dtg-group-card.is-dragging{opacity:.35;box-shadow:none}
2493
+ .dtg-group-ghost{position:fixed;z-index:2147483000;margin:0;pointer-events:none;background:var(--dsw-alias-bg-layer-1,#fff);border:0.5px solid var(--dsw-alias-border-l4,rgba(0,0,0,.08));border-radius:var(--dsw-radius-lg,10px);box-shadow:0 12px 30px rgba(0,0,0,.20),0 2px 6px rgba(0,0,0,.10);cursor:grabbing;animation:dtg-ghost-in .13s ease;will-change:left,top}
2494
+ .dtg-session-ghost{position:fixed;z-index:2147483001;display:flex;align-items:center;height:32px;padding:0 12px;box-sizing:border-box;border-radius:var(--dsw-radius-sm);border:0.5px solid var(--dsw-alias-border-l4,rgba(0,0,0,.08));background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 10px 26px rgba(0,0,0,.22),0 2px 6px rgba(0,0,0,.12);color:var(--dsw-alias-label-primary);font-size:14px;pointer-events:none;overflow:hidden;white-space:nowrap;animation:dtg-ghost-in .12s ease;will-change:left,top}
2495
+ .dtg-session-ghost-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2496
+ .dtg-session-line{position:fixed;z-index:2147483002;height:2px;border-radius:1px;background:var(--dsw-alias-state-business-primary,#3f7fdd);pointer-events:none;box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 20%,transparent);animation:dtg-line-in .12s ease}
2497
+ @keyframes dtg-ghost-in{from{opacity:.5;transform:scale(.985)}to{opacity:1;transform:none}}
2498
+ body.dtg-group-dragging,body.dtg-group-dragging *{cursor:grabbing!important}
2499
+ .dtg-group-head:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2500
+ .dtg-chevron{width:16px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);transition:transform .15s var(--ds-ease-in-out,ease)}
2501
+ .dtg-chevron.is-open{transform:rotate(90deg)}
2502
+ .dtg-hash{flex:none;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:5px;background:color-mix(in srgb,var(--dtg-color,currentColor) 15%,transparent);color:var(--dtg-color,currentColor);font-size:12px;font-weight:700;letter-spacing:0}
2503
+ .dtg-hash.is-filled{background:var(--dtg-color,currentColor);color:#fff}
2504
+ .dtg-folder{flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary)}
2505
+ .dtg-group-name{flex:1;min-width:0;font-size:13px;line-height:20px;font-weight:500;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2506
+ .dtg-count{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2507
+ .dtg-row{display:flex;align-items:center;gap:6px;height:32px;padding:0 4px 0 22px;border-radius:var(--dsw-radius-sm);cursor:pointer;color:var(--dsw-alias-label-primary);font-size:14px;position:relative;flex:none;user-select:none;-webkit-user-select:none}
2508
+ .dtg-row:hover,.dtg-row.is-selected,.dtg-row.is-menu-open{background:var(--dsw-alias-interactive-bg-hover)}
2509
+ .dtg-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2510
+ .dtg-dot-slot{width:14px;height:16px;flex:none;display:inline-flex;align-items:center;justify-content:center}
2511
+ .dtg-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:20px}
2512
+ .dtg-hint{flex:none;max-width:38%;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2513
+ .dtg-indicators{display:inline-flex;align-items:center;gap:2px;flex:none}
2514
+ .dtg-pin{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:var(--dsw-alias-label-caption)}
2515
+ .dtg-unread-slot{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;flex:none}
2516
+ .dtg-meta{flex:none;font-size:10px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2517
+ .dtg-meta.is-live{color:var(--dsw-alias-state-business-primary,#3f7fdd)}
2518
+ .dtg-meta.is-warn{color:var(--dsw-alias-state-warning-primary,#c27c0e)}
2519
+ .dtg-unread-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#3f7fdd);vertical-align:middle}
2520
+ .dtg-row-actions{display:none;flex:none;align-items:center;gap:2px}
2521
+ .dtg-row:hover .dtg-row-actions,.dtg-row.is-menu-open .dtg-row-actions,.dtg-row.is-selected .dtg-row-actions,.dtg-group-head:hover .dtg-row-actions,.dtg-group-head.is-menu-open .dtg-row-actions{display:inline-flex}
2522
+ .dtg-row:hover .dtg-meta,.dtg-row.is-menu-open .dtg-meta{display:none}
2523
+ .dtg-row:hover .dtg-pin,.dtg-row.is-menu-open .dtg-pin,.dtg-row.is-selected .dtg-pin{display:none}
2524
+ .dtg-row:hover .dtg-unread-slot,.dtg-row.is-menu-open .dtg-unread-slot,.dtg-row.is-selected .dtg-unread-slot{display:none}
2525
+ .dtg-row-actions{animation:dtg-actions-in .12s ease}
2526
+ @keyframes dtg-actions-in{from{opacity:0}to{opacity:1}}
2527
+ .dtg-row:hover .dtg-hint,.dtg-row.is-menu-open .dtg-hint{display:none}
2528
+ .dtg-action{width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;padding:0;border-radius:var(--dsw-radius-xs,4px);color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit}
2529
+ .dtg-action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2530
+ .dtg-action:disabled{opacity:.3;cursor:default;pointer-events:none}
2531
+ .dtg-row-menu-anchor{display:inline-block;width:0;height:1px}
2532
+ .dtg-action:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
2533
+ .dtg-group-empty{padding:2px 8px 6px 24px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
2534
+ .dtg-section-label{display:flex;align-items:center;gap:6px;padding:10px 6px 4px;font-size:12.5px;line-height:18px;font-weight:500;color:var(--dsw-alias-label-tertiary);letter-spacing:.01em}
2535
+ .dtg-empty{padding:28px 12px;text-align:center;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
2536
+ .dtg-empty-sm{padding:10px 8px;text-align:left}
2537
+ .dtg-newgroup{display:flex;align-items:center;gap:6px;height:30px;margin-top:4px;padding:0 4px 0 6px;border:0;background:transparent;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;cursor:pointer;text-align:left;flex:none}
2538
+ .dtg-newgroup:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
2539
+ .dtg-editor{display:flex;align-items:center;gap:4px;height:32px;padding:0 4px}
2540
+ .dtg-editor-input{flex:1;min-width:0;height:24px;border:0.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-button-elevated-fill,transparent);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:0 6px;outline:none}
2541
+ .dtg-editor-input:focus{border-color:var(--dsw-alias-state-business-primary)}
2542
+ .dtg-confirm{display:flex;align-items:center;gap:6px;height:34px;padding:0 4px 0 8px;border-radius:var(--dsw-radius-sm);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#d9534f) 10%,transparent)}
2543
+ .dtg-confirm-inline{margin:4px 0 6px 6px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#3f7fdd) 8%,transparent)}
2544
+ .dtg-confirm-inline .dtg-confirm-danger{color:var(--dsw-alias-state-business-primary,#3f7fdd)}
2545
+ .dtg-confirm-text{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
2546
+ .dtg-confirm-danger,.dtg-confirm-cancel{border:0;background:transparent;font:inherit;font-size:12px;padding:2px 4px;border-radius:var(--dsw-radius-xs,4px);cursor:pointer}
2547
+ .dtg-confirm-danger{color:var(--dsw-alias-state-error-primary,#d9534f);font-weight:600}
2548
+ .dtg-confirm-cancel{color:var(--dsw-alias-label-tertiary)}
2549
+ .dtg-confirm-danger:hover,.dtg-confirm-cancel:hover{background:var(--dsw-alias-interactive-bg-hover)}
2550
+ .dtg-swatch{display:inline-block;width:10px;height:10px;border-radius:50%}
2551
+ .dtg-skeleton{height:26px;margin:4px 6px;border-radius:var(--dsw-radius-xs,4px);background:var(--dsw-alias-bg-skeleton,rgba(0,0,0,.06));animation:dtg-pulse 1.8s ease-in-out infinite}
2552
+ .dtg-skeleton-short{width:60%}
2553
+ @keyframes dtg-pulse{0%{opacity:1}50%{opacity:.55}100%{opacity:1}}
2554
+ @media (prefers-reduced-motion:reduce){.dtg-skeleton{animation:none}.dtg-chevron{transition:none}}
2555
+ `
2556
+
2557
+ // ---------------------------------------------------------------------
2558
+ // apply
2559
+ // ---------------------------------------------------------------------
2560
+
2561
+ function apply(ctx) {
2562
+ rootCtx = ctx
2563
+
2564
+ const style = document.createElement('style')
2565
+ style.dataset.plugin = 'dsh-task-groups'
2566
+ style.textContent = CSS
2567
+ document.head.append(style)
2568
+ ctx.effect(() => () => style.remove(), 'dsh-task-groups: styles')
2569
+
2570
+ ctx.effect(() => () => store.dispose(), 'dsh-task-groups: store')
2571
+
2572
+ ctx.effect(
2573
+ () =>
2574
+ ctx.slots.inject('sidebar.workspaces', () =>
2575
+ ctx.slots.register(
2576
+ {
2577
+ name: 'sidebar.workspaces',
2578
+ id: 'task-groups.browser',
2579
+ priority: -1,
2580
+ },
2581
+ Browser,
2582
+ ),
2583
+ ),
2584
+ 'dsh-task-groups: sidebar browser',
2585
+ )
2586
+
2587
+ const flush = () => store.flush()
2588
+ window.addEventListener('beforeunload', flush)
2589
+ ctx.effect(() => () => window.removeEventListener('beforeunload', flush), 'dsh-task-groups: unload flush')
2590
+ }
2591
+
2592
+ exports.apply = apply
2593
+ exports.inject = ['slots', 'uiWorkspace', 'sessions', 'workspaces', 'remote', 'remote.session']
2594
+ return module.exports
2595
+ },
2596
+ })