@workerdeck/ui 3.4.0 → 3.5.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/build/index.d.mts +55 -4
- package/build/index.mjs +592 -157
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +88 -0
- package/package.json +4 -4
- package/src/components/agent/AgentAvatar.tsx +117 -0
- package/src/components/agent/SessionBrowser.tsx +243 -42
- package/src/components/agent/SessionGroups.tsx +130 -20
- package/src/components/agent/SessionItem.tsx +180 -69
- package/src/index.ts +11 -0
- package/src/lib/team-drop.ts +65 -0
- package/src/styles/theme.css +17 -0
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import type { HTMLAttributes, ReactNode } from 'react'
|
|
3
|
-
import { BedDouble, Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { BedDouble, ChevronDown, ChevronRight, Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
clearFilters,
|
|
6
|
+
filterRows,
|
|
7
|
+
groupRows,
|
|
8
|
+
hasFacetFilter,
|
|
9
|
+
isTeamCollapsed,
|
|
10
|
+
scopeActive,
|
|
11
|
+
sessionKey,
|
|
12
|
+
subsetSummary,
|
|
13
|
+
teamSummary,
|
|
14
|
+
toggleTeamCollapsed,
|
|
15
|
+
} from '@workerdeck/protocol'
|
|
16
|
+
import type {
|
|
17
|
+
SessionGroup,
|
|
18
|
+
SessionRow,
|
|
19
|
+
SessionState,
|
|
20
|
+
SessionTask,
|
|
21
|
+
StepDisplay,
|
|
22
|
+
SubagentDisplay,
|
|
23
|
+
ViewConfig,
|
|
24
|
+
WorkspaceScope,
|
|
25
|
+
} from '@workerdeck/protocol'
|
|
26
|
+
import { AgentAvatar, avatarOf, type AgentAvatars } from './AgentAvatar.tsx'
|
|
6
27
|
import { Button } from '../ui/Button.tsx'
|
|
7
28
|
import { Empty } from '../ui/Empty.tsx'
|
|
8
29
|
import { Input } from '../ui/Input.tsx'
|
|
9
30
|
import { ProjectIcon } from './ProjectIcon.tsx'
|
|
10
31
|
import { SessionFilters } from './SessionFilters.tsx'
|
|
11
32
|
import { SessionItem, type SelectModifiers } from './SessionItem.tsx'
|
|
12
|
-
import { CustomGroupHeader, GroupHeading, HeadingAction, NewGroupButton, useGroupDrag } from './SessionGroups.tsx'
|
|
33
|
+
import { CustomGroupHeader, GroupHeading, HeadingAction, NewGroupButton, useGroupDrag, type DropCue } from './SessionGroups.tsx'
|
|
34
|
+
import type { TeamMove } from '../../lib/team-drop.ts'
|
|
13
35
|
import { cn } from '../../lib/utils.ts'
|
|
14
36
|
|
|
15
37
|
export { SessionStatusIcon } from './SessionStatusIcon.tsx'
|
|
@@ -33,6 +55,8 @@ export interface SessionBrowserProps {
|
|
|
33
55
|
onSelect?: (row: SessionRow, modifiers: SelectModifiers) => void
|
|
34
56
|
onDelete?: (row: SessionRow) => void
|
|
35
57
|
onRename?: (row: SessionRow, title: string) => void
|
|
58
|
+
// An agent card's name is the agent's, not the session title; without this, agent cards are not renamable.
|
|
59
|
+
onRenameAgent?: (row: SessionRow, name: string) => void
|
|
36
60
|
onClearContext?: (row: SessionRow) => void
|
|
37
61
|
onSleep?: (row: SessionRow) => void
|
|
38
62
|
onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
|
|
@@ -41,6 +65,10 @@ export interface SessionBrowserProps {
|
|
|
41
65
|
onSelectShell?: (row: SessionRow, shellId: string) => void
|
|
42
66
|
onKillShell?: (row: SessionRow, shellId: string) => void
|
|
43
67
|
onShellAgentWrite?: (row: SessionRow, shellId: string, enabled: boolean) => void
|
|
68
|
+
avatars?: AgentAvatars
|
|
69
|
+
// Makes cards draggable onto each other: the middle of a card joins its team, a member dropped outside leaves.
|
|
70
|
+
// Reject with an Error to draw its message under the card.
|
|
71
|
+
onTeamMove?: (move: TeamMove) => Promise<void> | void
|
|
44
72
|
// Draws a `+` on each project heading: start a session on that gateway, in that project's root.
|
|
45
73
|
onCreateInGroup?: (target: GroupTarget) => void
|
|
46
74
|
emptyState?: ReactNode
|
|
@@ -74,6 +102,7 @@ export function SessionBrowser({
|
|
|
74
102
|
onSelect,
|
|
75
103
|
onDelete,
|
|
76
104
|
onRename,
|
|
105
|
+
onRenameAgent,
|
|
77
106
|
onClearContext,
|
|
78
107
|
onSleep,
|
|
79
108
|
onSelectSubagent,
|
|
@@ -82,6 +111,8 @@ export function SessionBrowser({
|
|
|
82
111
|
onSelectShell,
|
|
83
112
|
onKillShell,
|
|
84
113
|
onShellAgentWrite,
|
|
114
|
+
avatars,
|
|
115
|
+
onTeamMove,
|
|
85
116
|
onCreateInGroup,
|
|
86
117
|
emptyState,
|
|
87
118
|
showControls = true,
|
|
@@ -93,13 +124,19 @@ export function SessionBrowser({
|
|
|
93
124
|
const rowGateways = useMemo(() => new Set(rows.map((row) => row.hostId)).size, [rows])
|
|
94
125
|
const gateways = gatewayCount ?? rowGateways
|
|
95
126
|
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
96
|
-
const groups = useMemo(() => groupRows(visible, config, { gatewayCount: gateways }), [visible, config, gateways])
|
|
127
|
+
const groups = useMemo(() => groupRows(visible, config, { gatewayCount: gateways, all: rows }), [visible, config, gateways, rows])
|
|
97
128
|
const subset = subsetSummary(config, scope, visible.length, rows.length)
|
|
129
|
+
const tiles = useMemo(() => rows.some((row) => row.info.agent !== undefined), [rows])
|
|
98
130
|
const custom = config.groupBy === 'custom'
|
|
99
131
|
const [editingGroup, setEditingGroup] = useState<string>()
|
|
100
132
|
|
|
101
133
|
const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
|
|
102
|
-
const
|
|
134
|
+
const byKey = useMemo(() => new Map(rows.map((row) => [sessionKey(row), row])), [rows])
|
|
135
|
+
const drag = useGroupDrag(config.customGroups ?? [], (customGroups) => set({ customGroups }), {
|
|
136
|
+
custom,
|
|
137
|
+
team: onTeamMove ? { rowOf: (key) => byKey.get(key), onMove: onTeamMove } : undefined,
|
|
138
|
+
})
|
|
139
|
+
const draggable = custom || onTeamMove !== undefined
|
|
103
140
|
|
|
104
141
|
const heading = (group: SessionGroup) => {
|
|
105
142
|
if (custom) {
|
|
@@ -114,6 +151,19 @@ export function SessionBrowser({
|
|
|
114
151
|
/>
|
|
115
152
|
)
|
|
116
153
|
}
|
|
154
|
+
if (group.earlier) {
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
aria-expanded={config.earlierOpen === true}
|
|
159
|
+
onClick={() => set({ earlierOpen: !config.earlierOpen })}
|
|
160
|
+
className="flex items-center gap-1 px-2 text-left text-label text-fg-4 hover:text-fg-2"
|
|
161
|
+
>
|
|
162
|
+
{config.earlierOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
|
163
|
+
Earlier · {group.rows.length} one-off session{group.rows.length === 1 ? '' : 's'}
|
|
164
|
+
</button>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
117
167
|
if (config.groupBy === 'none' || !group.label) {
|
|
118
168
|
return null
|
|
119
169
|
}
|
|
@@ -200,8 +250,8 @@ export function SessionBrowser({
|
|
|
200
250
|
{groups.map((group) => (
|
|
201
251
|
<div
|
|
202
252
|
key={group.key}
|
|
203
|
-
{...(
|
|
204
|
-
className={cn('flex flex-col gap-1 rounded-md',
|
|
253
|
+
{...(draggable ? drag.container(group) : {})}
|
|
254
|
+
className={cn('flex flex-col gap-1 rounded-md', draggable && drag.isOver(`group:${group.key}`) && 'bg-row-hover/50')}
|
|
205
255
|
>
|
|
206
256
|
{heading(group)}
|
|
207
257
|
{custom && group.custom !== undefined && group.rows.length === 0 ? (
|
|
@@ -209,36 +259,70 @@ export function SessionBrowser({
|
|
|
209
259
|
Drag sessions here
|
|
210
260
|
</div>
|
|
211
261
|
) : null}
|
|
212
|
-
{group.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
262
|
+
{group.earlier && !config.earlierOpen
|
|
263
|
+
? null
|
|
264
|
+
: group.rows.map((row) => {
|
|
265
|
+
const item = (member: SessionRow, variant: 'card' | 'member', extra: Partial<SessionRowItemProps> = {}) => (
|
|
266
|
+
<SessionRowItem
|
|
267
|
+
key={sessionKey(member)}
|
|
268
|
+
dragProps={
|
|
269
|
+
onTeamMove || (custom && variant === 'card')
|
|
270
|
+
? drag.session(sessionKey(member), group, variant === 'member' ? { row: member, lead: row } : { row })
|
|
271
|
+
: undefined
|
|
272
|
+
}
|
|
273
|
+
dropCue={draggable ? drag.cue(sessionKey(member)) : undefined}
|
|
274
|
+
row={member}
|
|
275
|
+
variant={variant}
|
|
276
|
+
active={isActive(member)}
|
|
277
|
+
activeSubagentId={activeSubagentId}
|
|
278
|
+
activeShellId={activeShellId}
|
|
279
|
+
actions={rowActions?.(member)}
|
|
280
|
+
now={now}
|
|
281
|
+
showGateway={gateways > 1 && config.groupBy !== 'project'}
|
|
282
|
+
showProject={config.groupBy !== 'project'}
|
|
283
|
+
subagents={config.subagents}
|
|
284
|
+
shells={config.shells}
|
|
285
|
+
tasks={config.tasks}
|
|
286
|
+
projectIcons={projectIcons}
|
|
287
|
+
avatars={avatars}
|
|
288
|
+
tile={tiles}
|
|
289
|
+
onSelect={onSelect}
|
|
290
|
+
onDelete={onDelete}
|
|
291
|
+
onRename={member.info.agent ? onRenameAgent : onRename}
|
|
292
|
+
onClearContext={onClearContext}
|
|
293
|
+
onSleep={onSleep}
|
|
294
|
+
onSelectSubagent={onSelectSubagent}
|
|
295
|
+
onSelectTask={onSelectTask}
|
|
296
|
+
onStopTask={onStopTask}
|
|
297
|
+
onSelectShell={onSelectShell}
|
|
298
|
+
onKillShell={onKillShell}
|
|
299
|
+
onShellAgentWrite={onShellAgentWrite}
|
|
300
|
+
{...extra}
|
|
301
|
+
/>
|
|
302
|
+
)
|
|
303
|
+
if (!row.members?.length) {
|
|
304
|
+
return item(row, 'card', { className: row.context ? 'opacity-50' : undefined })
|
|
305
|
+
}
|
|
306
|
+
const collapsed = isTeamCollapsed(config, row)
|
|
307
|
+
return (
|
|
308
|
+
<TeamBlock
|
|
309
|
+
key={sessionKey(row)}
|
|
310
|
+
lead={row}
|
|
311
|
+
collapsed={collapsed}
|
|
312
|
+
avatars={avatars}
|
|
313
|
+
onToggle={() => onConfigChange(toggleTeamCollapsed(config, row))}
|
|
314
|
+
renderLead={(summary, tray) =>
|
|
315
|
+
item(row, 'card', {
|
|
316
|
+
className: row.context ? 'opacity-50' : undefined,
|
|
317
|
+
badgeState: collapsed ? row.teamState : undefined,
|
|
318
|
+
teamUnseen: collapsed ? summary.unseen : 0,
|
|
319
|
+
teamTray: tray,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
renderMember={(member) => item(member, 'member')}
|
|
323
|
+
/>
|
|
324
|
+
)
|
|
325
|
+
})}
|
|
242
326
|
</div>
|
|
243
327
|
))}
|
|
244
328
|
{custom ? <NewGroupButton onClick={() => setEditingGroup(drag.create())} /> : null}
|
|
@@ -255,8 +339,15 @@ function iconSrcOf(row: SessionRow | undefined, icons: Record<string, string> |
|
|
|
255
339
|
|
|
256
340
|
interface SessionRowItemProps {
|
|
257
341
|
row: SessionRow
|
|
342
|
+
variant?: 'card' | 'member'
|
|
343
|
+
avatars?: AgentAvatars
|
|
344
|
+
badgeState?: SessionState
|
|
345
|
+
teamUnseen?: number
|
|
346
|
+
teamTray?: ReactNode
|
|
347
|
+
tile?: boolean
|
|
348
|
+
className?: string
|
|
258
349
|
dragProps?: HTMLAttributes<HTMLDivElement>
|
|
259
|
-
|
|
350
|
+
dropCue?: DropCue
|
|
260
351
|
active?: boolean
|
|
261
352
|
actions?: ReactNode
|
|
262
353
|
activeSubagentId?: string
|
|
@@ -283,8 +374,15 @@ interface SessionRowItemProps {
|
|
|
283
374
|
|
|
284
375
|
function SessionRowItem({
|
|
285
376
|
row,
|
|
377
|
+
variant,
|
|
378
|
+
avatars,
|
|
379
|
+
badgeState,
|
|
380
|
+
teamUnseen,
|
|
381
|
+
teamTray,
|
|
382
|
+
tile,
|
|
383
|
+
className,
|
|
286
384
|
dragProps,
|
|
287
|
-
|
|
385
|
+
dropCue,
|
|
288
386
|
active,
|
|
289
387
|
actions,
|
|
290
388
|
activeSubagentId,
|
|
@@ -314,6 +412,13 @@ function SessionRowItem({
|
|
|
314
412
|
const item = (
|
|
315
413
|
<SessionItem
|
|
316
414
|
row={row}
|
|
415
|
+
variant={variant}
|
|
416
|
+
avatars={avatars}
|
|
417
|
+
badgeState={badgeState}
|
|
418
|
+
teamUnseen={teamUnseen}
|
|
419
|
+
teamTray={teamTray}
|
|
420
|
+
tile={tile}
|
|
421
|
+
className={className}
|
|
317
422
|
active={active === true}
|
|
318
423
|
activeStepKey={active ? (activeShellId ?? activeSubagentId) : undefined}
|
|
319
424
|
now={now}
|
|
@@ -338,7 +443,7 @@ function SessionRowItem({
|
|
|
338
443
|
actions ?? (
|
|
339
444
|
<>
|
|
340
445
|
{onRename && !editing ? (
|
|
341
|
-
<RowAction label=
|
|
446
|
+
<RowAction label={info.agent ? 'Rename agent' : 'Rename session'} onClick={() => setEditing(true)}>
|
|
342
447
|
<Pencil className="size-3 text-fg-3" />
|
|
343
448
|
</RowAction>
|
|
344
449
|
) : null}
|
|
@@ -373,9 +478,105 @@ function SessionRowItem({
|
|
|
373
478
|
if (!dragProps) {
|
|
374
479
|
return item
|
|
375
480
|
}
|
|
481
|
+
const zone = dropCue?.zone
|
|
376
482
|
return (
|
|
377
|
-
<div
|
|
483
|
+
<div
|
|
484
|
+
{...dragProps}
|
|
485
|
+
title={zone === 'refused' ? dropCue?.message : undefined}
|
|
486
|
+
className={cn(
|
|
487
|
+
'rounded-md',
|
|
488
|
+
zone === 'before' && 'shadow-[inset_0_2px_0_0_var(--color-accent)]',
|
|
489
|
+
zone === 'after' && 'shadow-[inset_0_-2px_0_0_var(--color-accent)]',
|
|
490
|
+
zone === 'join' && 'bg-accent/10 ring-1 ring-accent',
|
|
491
|
+
zone === 'refused' && 'ring-1 ring-danger/60',
|
|
492
|
+
)}
|
|
493
|
+
>
|
|
378
494
|
{item}
|
|
495
|
+
{zone === 'refused' && dropCue?.message ? (
|
|
496
|
+
<div role="status" className="px-3 pb-1 text-label text-danger">
|
|
497
|
+
{dropCue.message}
|
|
498
|
+
</div>
|
|
499
|
+
) : null}
|
|
500
|
+
</div>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const TRAY_MAX = 4
|
|
505
|
+
|
|
506
|
+
// A folded team draws its members on the lead's first line (avatar and status each) and no row of its own; the tray
|
|
507
|
+
// unfolds it. Expanded, a tree line runs from the lead's avatar with the knob atop it, so neither state needs a twistie.
|
|
508
|
+
function TeamBlock({
|
|
509
|
+
lead,
|
|
510
|
+
collapsed,
|
|
511
|
+
avatars,
|
|
512
|
+
onToggle,
|
|
513
|
+
renderLead,
|
|
514
|
+
renderMember,
|
|
515
|
+
}: {
|
|
516
|
+
lead: SessionRow
|
|
517
|
+
collapsed: boolean
|
|
518
|
+
avatars?: AgentAvatars
|
|
519
|
+
onToggle: () => void
|
|
520
|
+
renderLead: (summary: ReturnType<typeof teamSummary>, tray?: ReactNode) => ReactNode
|
|
521
|
+
renderMember: (member: SessionRow) => ReactNode
|
|
522
|
+
}) {
|
|
523
|
+
const members = lead.members ?? []
|
|
524
|
+
const summary = teamSummary(lead)
|
|
525
|
+
if (collapsed) {
|
|
526
|
+
const surfaced = members.filter((member) => member.state === 'attention')
|
|
527
|
+
const counts = [
|
|
528
|
+
`${summary.members} member${summary.members === 1 ? '' : 's'}`,
|
|
529
|
+
summary.working ? `${summary.working} working` : undefined,
|
|
530
|
+
summary.attention ? `${summary.attention} need${summary.attention === 1 ? 's' : ''} you` : undefined,
|
|
531
|
+
].filter(Boolean)
|
|
532
|
+
const hidden = members.length - TRAY_MAX
|
|
533
|
+
const tray = (
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
aria-expanded={false}
|
|
537
|
+
aria-label={`Expand ${lead.info.agent?.name ?? 'team'}: ${counts.join(', ')}`}
|
|
538
|
+
title={counts.join(' · ')}
|
|
539
|
+
onClick={(e) => {
|
|
540
|
+
e.stopPropagation()
|
|
541
|
+
onToggle()
|
|
542
|
+
}}
|
|
543
|
+
className="flex shrink-0 items-center gap-1 rounded-[4px] px-0.5 hover:bg-row-hover"
|
|
544
|
+
>
|
|
545
|
+
{members.slice(0, hidden > 0 ? TRAY_MAX - 1 : TRAY_MAX).map((member) => (
|
|
546
|
+
<AgentAvatar
|
|
547
|
+
key={sessionKey(member)}
|
|
548
|
+
row={member}
|
|
549
|
+
image={avatarOf(avatars, member.info.agent)}
|
|
550
|
+
size={16}
|
|
551
|
+
className="rounded-[3px]"
|
|
552
|
+
/>
|
|
553
|
+
))}
|
|
554
|
+
{hidden > 0 ? <span className="text-label text-fg-4 tabular-nums">+{hidden + 1}</span> : null}
|
|
555
|
+
</button>
|
|
556
|
+
)
|
|
557
|
+
return (
|
|
558
|
+
<div data-slot="team" data-collapsed className="flex flex-col">
|
|
559
|
+
{renderLead(summary, tray)}
|
|
560
|
+
{surfaced.length ? <div className="ml-3 flex flex-col">{surfaced.map(renderMember)}</div> : null}
|
|
561
|
+
</div>
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
return (
|
|
565
|
+
<div data-slot="team" className="flex flex-col">
|
|
566
|
+
{renderLead(summary)}
|
|
567
|
+
<div className="relative ml-3.5 flex flex-col pl-1">
|
|
568
|
+
<span aria-hidden className="absolute top-0 bottom-3 left-0 w-px bg-border" />
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
aria-expanded
|
|
572
|
+
aria-label={`Collapse ${lead.info.agent?.name ?? 'team'}`}
|
|
573
|
+
onClick={onToggle}
|
|
574
|
+
className="absolute -top-1 -left-[7px] flex size-3.5 items-center justify-center rounded-full border border-border bg-bg text-fg-4 hover:text-fg-1"
|
|
575
|
+
>
|
|
576
|
+
<ChevronDown className="size-2.5" />
|
|
577
|
+
</button>
|
|
578
|
+
{members.map(renderMember)}
|
|
579
|
+
</div>
|
|
379
580
|
</div>
|
|
380
581
|
)
|
|
381
582
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import type { DragEvent, HTMLAttributes, ReactNode } from 'react'
|
|
3
3
|
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
4
4
|
import {
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
newCustomGroupId,
|
|
9
9
|
removeCustomGroup,
|
|
10
10
|
renameCustomGroup,
|
|
11
|
+
sessionKey,
|
|
11
12
|
} from '@workerdeck/protocol'
|
|
12
|
-
import type { CustomGroup, SessionGroup } from '@workerdeck/protocol'
|
|
13
|
+
import type { CustomGroup, SessionGroup, SessionRow } from '@workerdeck/protocol'
|
|
14
|
+
import { dropZone, joinDrop, memberDrop, type DropZone, type TeamMove } from '../../lib/team-drop.ts'
|
|
13
15
|
import { Button } from '../ui/Button.tsx'
|
|
14
16
|
import { Input } from '../ui/Input.tsx'
|
|
15
17
|
import { cn } from '../../lib/utils.ts'
|
|
@@ -18,11 +20,36 @@ type DragItem = { kind: 'session'; key: string } | { kind: 'group'; id: string }
|
|
|
18
20
|
|
|
19
21
|
export type GroupDrag = ReturnType<typeof useGroupDrag>
|
|
20
22
|
|
|
23
|
+
// Where a card sits: `lead` is set for a member, the team it is drawn under.
|
|
24
|
+
export type DragPlace = { row: SessionRow; lead?: SessionRow }
|
|
25
|
+
|
|
26
|
+
export type TeamDrag = {
|
|
27
|
+
rowOf: (key: string) => SessionRow | undefined
|
|
28
|
+
// A rejection's message is drawn under the card it was dropped on: a gateway 409 is user-facing copy.
|
|
29
|
+
onMove: (move: TeamMove) => Promise<void> | void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Over = { spot: string; refusal?: string }
|
|
33
|
+
|
|
34
|
+
export type DropCue = { zone?: DropZone | 'refused'; message?: string }
|
|
35
|
+
type Landing = Over & { run?: () => void }
|
|
36
|
+
|
|
37
|
+
const ERROR_MS = 5000
|
|
38
|
+
|
|
21
39
|
// The payload lives in state rather than `dataTransfer`, which is unreadable during `dragover`; `setData` is still
|
|
22
|
-
// written because some engines refuse to start a drag without it.
|
|
23
|
-
|
|
40
|
+
// written because some engines refuse to start a drag without it. A card is split in three: the middle joins its
|
|
41
|
+
// team, the edges reorder (custom groups) or leave a team.
|
|
42
|
+
export function useGroupDrag(
|
|
43
|
+
groups: readonly CustomGroup[],
|
|
44
|
+
onChange: (groups: CustomGroup[]) => void,
|
|
45
|
+
options: { custom: boolean; team?: TeamDrag },
|
|
46
|
+
) {
|
|
47
|
+
const { custom, team } = options
|
|
24
48
|
const [item, setItem] = useState<DragItem>()
|
|
25
|
-
const [over, setOver] = useState<
|
|
49
|
+
const [over, setOver] = useState<Over>()
|
|
50
|
+
const [failure, setFailure] = useState<{ key: string; message: string }>()
|
|
51
|
+
const failureTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
52
|
+
useEffect(() => () => clearTimeout(failureTimer.current), [])
|
|
26
53
|
const end = () => {
|
|
27
54
|
setItem(undefined)
|
|
28
55
|
setOver(undefined)
|
|
@@ -33,49 +60,109 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
|
|
|
33
60
|
e.dataTransfer.setData('text/plain', next.kind === 'session' ? next.key : next.id)
|
|
34
61
|
setItem(next)
|
|
35
62
|
}
|
|
36
|
-
const accept = (e: DragEvent,
|
|
63
|
+
const accept = (e: DragEvent, next: Over) => {
|
|
37
64
|
e.preventDefault()
|
|
38
65
|
e.stopPropagation()
|
|
39
|
-
e.dataTransfer.dropEffect = 'move'
|
|
40
|
-
|
|
66
|
+
e.dataTransfer.dropEffect = next.refusal ? 'none' : 'move'
|
|
67
|
+
if (over?.spot !== next.spot || over.refusal !== next.refusal) {
|
|
68
|
+
setOver(next)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const fail = (key: string, err: unknown) => {
|
|
72
|
+
clearTimeout(failureTimer.current)
|
|
73
|
+
setFailure({ key, message: err instanceof Error ? err.message : String(err) })
|
|
74
|
+
failureTimer.current = setTimeout(() => setFailure(undefined), ERROR_MS)
|
|
75
|
+
}
|
|
76
|
+
const move = (key: string, next: TeamMove) => () => {
|
|
77
|
+
setFailure(undefined)
|
|
78
|
+
void Promise.resolve()
|
|
79
|
+
.then(() => team!.onMove(next))
|
|
80
|
+
.catch((err: unknown) => fail(key, err))
|
|
41
81
|
}
|
|
42
82
|
|
|
43
|
-
function
|
|
83
|
+
function land(e: DragEvent<HTMLDivElement>, key: string, group: SessionGroup, place?: DragPlace): Landing | undefined {
|
|
84
|
+
if (item?.kind !== 'session' || item.key === key) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
const reorder = custom
|
|
88
|
+
? { spot: `row:${key}`, run: () => onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined)) }
|
|
89
|
+
: undefined
|
|
90
|
+
const dragged = team?.rowOf(item.key)
|
|
91
|
+
if (!team || !dragged || !place) {
|
|
92
|
+
return reorder
|
|
93
|
+
}
|
|
94
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
95
|
+
const zone = dropZone(e.clientY - rect.top, rect.height)
|
|
96
|
+
if (place.lead) {
|
|
97
|
+
const result = memberDrop(dragged, place.lead, place.row, zone)
|
|
98
|
+
return typeof result === 'string'
|
|
99
|
+
? { spot: `refused:${key}`, refusal: result }
|
|
100
|
+
: { spot: `${zone === 'before' ? 'before' : 'after'}:${key}`, run: move(key, result) }
|
|
101
|
+
}
|
|
102
|
+
if (zone === 'join') {
|
|
103
|
+
const result = joinDrop(dragged, place.row)
|
|
104
|
+
if (typeof result !== 'string') {
|
|
105
|
+
return { spot: `join:${key}`, run: move(key, result) }
|
|
106
|
+
}
|
|
107
|
+
return reorder ?? { spot: `refused:${key}`, refusal: result }
|
|
108
|
+
}
|
|
109
|
+
if (dragged.info.agent?.lead !== undefined) {
|
|
110
|
+
const leave = move(key, { row: dragged, lead: null })
|
|
111
|
+
return {
|
|
112
|
+
spot: `${zone}:${key}`,
|
|
113
|
+
run: () => {
|
|
114
|
+
leave()
|
|
115
|
+
reorder?.run()
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return reorder ? { ...reorder, spot: `${zone}:${key}` } : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function session(key: string, group: SessionGroup, place?: DragPlace): HTMLAttributes<HTMLDivElement> {
|
|
44
123
|
return {
|
|
45
124
|
draggable: true,
|
|
46
125
|
onDragStart: (e) => start(e, { kind: 'session', key }),
|
|
47
126
|
onDragEnd: end,
|
|
48
127
|
onDragOver: (e) => {
|
|
49
|
-
|
|
50
|
-
|
|
128
|
+
const landing = land(e, key, group, place)
|
|
129
|
+
if (landing) {
|
|
130
|
+
accept(e, landing)
|
|
51
131
|
}
|
|
52
132
|
},
|
|
53
133
|
onDrop: (e) => {
|
|
54
|
-
|
|
134
|
+
const landing = land(e, key, group, place)
|
|
135
|
+
if (!landing) {
|
|
55
136
|
return
|
|
56
137
|
}
|
|
57
138
|
e.preventDefault()
|
|
58
139
|
e.stopPropagation()
|
|
59
|
-
|
|
60
|
-
onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined))
|
|
61
|
-
}
|
|
140
|
+
landing.run?.()
|
|
62
141
|
end()
|
|
63
142
|
},
|
|
64
143
|
}
|
|
65
144
|
}
|
|
66
145
|
|
|
67
146
|
function container(group: SessionGroup): HTMLAttributes<HTMLDivElement> {
|
|
147
|
+
const leaving = () => {
|
|
148
|
+
const dragged = item?.kind === 'session' ? team?.rowOf(item.key) : undefined
|
|
149
|
+
return dragged?.info.agent?.lead !== undefined ? dragged : undefined
|
|
150
|
+
}
|
|
68
151
|
return {
|
|
69
152
|
onDragOver: (e) => {
|
|
70
|
-
if (item) {
|
|
71
|
-
accept(e, `group:${group.key}`)
|
|
153
|
+
if (item && (custom || leaving())) {
|
|
154
|
+
accept(e, { spot: `group:${group.key}` })
|
|
72
155
|
}
|
|
73
156
|
},
|
|
74
157
|
onDrop: (e) => {
|
|
75
158
|
e.preventDefault()
|
|
76
|
-
|
|
159
|
+
const member = leaving()
|
|
160
|
+
if (member) {
|
|
161
|
+
move(sessionKey(member), { row: member, lead: null })()
|
|
162
|
+
}
|
|
163
|
+
if (custom && item?.kind === 'session') {
|
|
77
164
|
onChange(moveToCustomGroup(groups, item.key, group.custom))
|
|
78
|
-
} else if (item?.kind === 'group') {
|
|
165
|
+
} else if (custom && item?.kind === 'group') {
|
|
79
166
|
onChange(moveCustomGroup(groups, item.id, group.custom))
|
|
80
167
|
}
|
|
81
168
|
end()
|
|
@@ -91,9 +178,32 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
|
|
|
91
178
|
return { draggable: true, onDragStart: (e) => start(e, { kind: 'group', id }), onDragEnd: end }
|
|
92
179
|
}
|
|
93
180
|
|
|
181
|
+
// What to draw on a card while something hovers it, and a rejected drop's message for a while after.
|
|
182
|
+
function cue(key: string): DropCue {
|
|
183
|
+
if (failure?.key === key) {
|
|
184
|
+
return { zone: 'refused', message: failure.message }
|
|
185
|
+
}
|
|
186
|
+
const spot = over?.spot
|
|
187
|
+
if (!spot || spot.slice(spot.indexOf(':') + 1) !== key) {
|
|
188
|
+
return {}
|
|
189
|
+
}
|
|
190
|
+
const kind = spot.slice(0, spot.indexOf(':'))
|
|
191
|
+
if (kind === 'refused') {
|
|
192
|
+
return { zone: 'refused', message: over?.refusal }
|
|
193
|
+
}
|
|
194
|
+
return kind === 'row' || kind === 'before'
|
|
195
|
+
? { zone: 'before' }
|
|
196
|
+
: kind === 'after'
|
|
197
|
+
? { zone: 'after' }
|
|
198
|
+
: kind === 'join'
|
|
199
|
+
? { zone: 'join' }
|
|
200
|
+
: {}
|
|
201
|
+
}
|
|
202
|
+
|
|
94
203
|
return {
|
|
95
204
|
dragging: item !== undefined,
|
|
96
|
-
isOver: (spot: string) => over === spot,
|
|
205
|
+
isOver: (spot: string) => over?.spot === spot,
|
|
206
|
+
cue,
|
|
97
207
|
session,
|
|
98
208
|
container,
|
|
99
209
|
header,
|