@workerdeck/ui 3.3.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.
@@ -1,15 +1,37 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import type { HTMLAttributes, ReactNode } from 'react'
3
- import { Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
4
- import { clearFilters, filterRows, groupRows, hasFacetFilter, scopeActive, sessionKey, subsetSummary } from '@workerdeck/protocol'
5
- import type { SessionGroup, SessionRow, SessionTask, StepDisplay, SubagentDisplay, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
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,13 +55,20 @@ 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
61
+ onSleep?: (row: SessionRow) => void
37
62
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
38
63
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
39
64
  onStopTask?: (row: SessionRow, toolUseId: string) => void
40
65
  onSelectShell?: (row: SessionRow, shellId: string) => void
41
66
  onKillShell?: (row: SessionRow, shellId: string) => void
42
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
43
72
  // Draws a `+` on each project heading: start a session on that gateway, in that project's root.
44
73
  onCreateInGroup?: (target: GroupTarget) => void
45
74
  emptyState?: ReactNode
@@ -73,13 +102,17 @@ export function SessionBrowser({
73
102
  onSelect,
74
103
  onDelete,
75
104
  onRename,
105
+ onRenameAgent,
76
106
  onClearContext,
107
+ onSleep,
77
108
  onSelectSubagent,
78
109
  onSelectTask,
79
110
  onStopTask,
80
111
  onSelectShell,
81
112
  onKillShell,
82
113
  onShellAgentWrite,
114
+ avatars,
115
+ onTeamMove,
83
116
  onCreateInGroup,
84
117
  emptyState,
85
118
  showControls = true,
@@ -91,13 +124,19 @@ export function SessionBrowser({
91
124
  const rowGateways = useMemo(() => new Set(rows.map((row) => row.hostId)).size, [rows])
92
125
  const gateways = gatewayCount ?? rowGateways
93
126
  const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
94
- 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])
95
128
  const subset = subsetSummary(config, scope, visible.length, rows.length)
129
+ const tiles = useMemo(() => rows.some((row) => row.info.agent !== undefined), [rows])
96
130
  const custom = config.groupBy === 'custom'
97
131
  const [editingGroup, setEditingGroup] = useState<string>()
98
132
 
99
133
  const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
100
- const drag = useGroupDrag(config.customGroups ?? [], (customGroups) => set({ customGroups }))
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
101
140
 
102
141
  const heading = (group: SessionGroup) => {
103
142
  if (custom) {
@@ -112,6 +151,19 @@ export function SessionBrowser({
112
151
  />
113
152
  )
114
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
+ }
115
167
  if (config.groupBy === 'none' || !group.label) {
116
168
  return null
117
169
  }
@@ -119,7 +171,17 @@ export function SessionBrowser({
119
171
  const target = project && group.hostId ? { hostId: group.hostId, cwd: group.cwd } : undefined
120
172
  return (
121
173
  <GroupHeading
122
- label={group.label}
174
+ label={
175
+ group.gateway && group.project ? (
176
+ <>
177
+ {group.gateway}
178
+ <span aria-hidden> · </span>
179
+ <span className="text-fg-1">{group.project}</span>
180
+ </>
181
+ ) : (
182
+ group.label
183
+ )
184
+ }
123
185
  count={group.rows.length}
124
186
  title={project && group.cwd ? group.cwd : undefined}
125
187
  leading={
@@ -188,8 +250,8 @@ export function SessionBrowser({
188
250
  {groups.map((group) => (
189
251
  <div
190
252
  key={group.key}
191
- {...(custom ? drag.container(group) : {})}
192
- className={cn('flex flex-col gap-1 rounded-md', custom && drag.isOver(`group:${group.key}`) && 'bg-row-hover/50')}
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')}
193
255
  >
194
256
  {heading(group)}
195
257
  {custom && group.custom !== undefined && group.rows.length === 0 ? (
@@ -197,35 +259,70 @@ export function SessionBrowser({
197
259
  Drag sessions here
198
260
  </div>
199
261
  ) : null}
200
- {group.rows.map((row) => (
201
- <SessionRowItem
202
- key={sessionKey(row)}
203
- dragProps={custom ? drag.session(sessionKey(row), group) : undefined}
204
- dropTarget={custom && drag.isOver(`row:${sessionKey(row)}`)}
205
- row={row}
206
- active={isActive(row)}
207
- activeSubagentId={activeSubagentId}
208
- activeShellId={activeShellId}
209
- actions={rowActions?.(row)}
210
- now={now}
211
- showGateway={gateways > 1 && config.groupBy !== 'project'}
212
- showProject={config.groupBy !== 'project'}
213
- subagents={config.subagents}
214
- shells={config.shells}
215
- tasks={config.tasks}
216
- projectIcons={projectIcons}
217
- onSelect={onSelect}
218
- onDelete={onDelete}
219
- onRename={onRename}
220
- onClearContext={onClearContext}
221
- onSelectSubagent={onSelectSubagent}
222
- onSelectTask={onSelectTask}
223
- onStopTask={onStopTask}
224
- onSelectShell={onSelectShell}
225
- onKillShell={onKillShell}
226
- onShellAgentWrite={onShellAgentWrite}
227
- />
228
- ))}
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
+ })}
229
326
  </div>
230
327
  ))}
231
328
  {custom ? <NewGroupButton onClick={() => setEditingGroup(drag.create())} /> : null}
@@ -242,8 +339,15 @@ function iconSrcOf(row: SessionRow | undefined, icons: Record<string, string> |
242
339
 
243
340
  interface SessionRowItemProps {
244
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
245
349
  dragProps?: HTMLAttributes<HTMLDivElement>
246
- dropTarget?: boolean
350
+ dropCue?: DropCue
247
351
  active?: boolean
248
352
  actions?: ReactNode
249
353
  activeSubagentId?: string
@@ -259,6 +363,7 @@ interface SessionRowItemProps {
259
363
  onDelete?: (row: SessionRow) => void
260
364
  onRename?: (row: SessionRow, title: string) => void
261
365
  onClearContext?: (row: SessionRow) => void
366
+ onSleep?: (row: SessionRow) => void
262
367
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
263
368
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
264
369
  onStopTask?: (row: SessionRow, toolUseId: string) => void
@@ -269,8 +374,15 @@ interface SessionRowItemProps {
269
374
 
270
375
  function SessionRowItem({
271
376
  row,
377
+ variant,
378
+ avatars,
379
+ badgeState,
380
+ teamUnseen,
381
+ teamTray,
382
+ tile,
383
+ className,
272
384
  dragProps,
273
- dropTarget,
385
+ dropCue,
274
386
  active,
275
387
  actions,
276
388
  activeSubagentId,
@@ -286,6 +398,7 @@ function SessionRowItem({
286
398
  onDelete,
287
399
  onRename,
288
400
  onClearContext,
401
+ onSleep,
289
402
  onSelectSubagent,
290
403
  onSelectTask,
291
404
  onStopTask,
@@ -299,6 +412,13 @@ function SessionRowItem({
299
412
  const item = (
300
413
  <SessionItem
301
414
  row={row}
415
+ variant={variant}
416
+ avatars={avatars}
417
+ badgeState={badgeState}
418
+ teamUnseen={teamUnseen}
419
+ teamTray={teamTray}
420
+ tile={tile}
421
+ className={className}
302
422
  active={active === true}
303
423
  activeStepKey={active ? (activeShellId ?? activeSubagentId) : undefined}
304
424
  now={now}
@@ -323,7 +443,7 @@ function SessionRowItem({
323
443
  actions ?? (
324
444
  <>
325
445
  {onRename && !editing ? (
326
- <RowAction label="Rename session" onClick={() => setEditing(true)}>
446
+ <RowAction label={info.agent ? 'Rename agent' : 'Rename session'} onClick={() => setEditing(true)}>
327
447
  <Pencil className="size-3 text-fg-3" />
328
448
  </RowAction>
329
449
  ) : null}
@@ -336,6 +456,15 @@ function SessionRowItem({
336
456
  <Eraser className="size-3 text-fg-3" />
337
457
  </RowAction>
338
458
  ) : null}
459
+ {onSleep && info.capabilities?.engineSleep && info.status === 'idle' && !info.engineAsleep ? (
460
+ <RowAction
461
+ label="Sleep session"
462
+ title="Stop the engine process to free its memory - the session stays here and wakes on your next message"
463
+ onClick={() => onSleep(row)}
464
+ >
465
+ <BedDouble className="size-3 text-fg-3" />
466
+ </RowAction>
467
+ ) : null}
339
468
  {onDelete ? (
340
469
  <RowAction label="Close session" onClick={() => onDelete(row)}>
341
470
  <Trash2 className="size-3 text-fg-3" />
@@ -349,9 +478,105 @@ function SessionRowItem({
349
478
  if (!dragProps) {
350
479
  return item
351
480
  }
481
+ const zone = dropCue?.zone
352
482
  return (
353
- <div {...dragProps} className={cn('rounded-md', dropTarget && 'shadow-[inset_0_2px_0_0_var(--color-accent)]')}>
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
+ >
354
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>
355
580
  </div>
356
581
  )
357
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
- export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups: CustomGroup[]) => void) {
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<string>()
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, spot: string) => {
63
+ const accept = (e: DragEvent, next: Over) => {
37
64
  e.preventDefault()
38
65
  e.stopPropagation()
39
- e.dataTransfer.dropEffect = 'move'
40
- setOver(spot)
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))
81
+ }
82
+
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
41
120
  }
42
121
 
43
- function session(key: string, group: SessionGroup): HTMLAttributes<HTMLDivElement> {
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
- if (item?.kind === 'session') {
50
- accept(e, `row:${key}`)
128
+ const landing = land(e, key, group, place)
129
+ if (landing) {
130
+ accept(e, landing)
51
131
  }
52
132
  },
53
133
  onDrop: (e) => {
54
- if (item?.kind !== 'session') {
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
- if (item.key !== key) {
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
- if (item?.kind === 'session') {
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,
@@ -191,7 +301,7 @@ export function GroupHeading({
191
301
  className,
192
302
  ...rest
193
303
  }: {
194
- label: string
304
+ label: ReactNode
195
305
  count: number
196
306
  leading?: ReactNode
197
307
  actions?: ReactNode