@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.
@@ -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 { 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,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 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
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
- {...(custom ? drag.container(group) : {})}
204
- 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')}
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.rows.map((row) => (
213
- <SessionRowItem
214
- key={sessionKey(row)}
215
- dragProps={custom ? drag.session(sessionKey(row), group) : undefined}
216
- dropTarget={custom && drag.isOver(`row:${sessionKey(row)}`)}
217
- row={row}
218
- active={isActive(row)}
219
- activeSubagentId={activeSubagentId}
220
- activeShellId={activeShellId}
221
- actions={rowActions?.(row)}
222
- now={now}
223
- showGateway={gateways > 1 && config.groupBy !== 'project'}
224
- showProject={config.groupBy !== 'project'}
225
- subagents={config.subagents}
226
- shells={config.shells}
227
- tasks={config.tasks}
228
- projectIcons={projectIcons}
229
- onSelect={onSelect}
230
- onDelete={onDelete}
231
- onRename={onRename}
232
- onClearContext={onClearContext}
233
- onSleep={onSleep}
234
- onSelectSubagent={onSelectSubagent}
235
- onSelectTask={onSelectTask}
236
- onStopTask={onStopTask}
237
- onSelectShell={onSelectShell}
238
- onKillShell={onKillShell}
239
- onShellAgentWrite={onShellAgentWrite}
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
- dropTarget?: boolean
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
- dropTarget,
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="Rename session" onClick={() => setEditing(true)}>
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 {...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
+ >
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
- 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))
41
81
  }
42
82
 
43
- function session(key: string, group: SessionGroup): HTMLAttributes<HTMLDivElement> {
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
- 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,