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/LICENSE +21 -0
- package/README.en.md +153 -0
- package/README.md +152 -2
- package/cordis.patch.yml +4 -0
- package/docs/badges/dsh.svg +5 -0
- package/docs/badges/license.svg +5 -0
- package/docs/badges/node.svg +5 -0
- package/docs/images/activity.png +0 -0
- package/docs/images/groups.png +0 -0
- package/docs/images/projects.png +0 -0
- package/lib/client.js +2596 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +194 -0
- package/package.json +69 -4
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
|
+
})
|