@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.
- package/build/{SessionPanel-BNNc8grK.d.mts → SessionPanel-DIi4sEgh.d.mts} +5 -2
- package/build/index.d.mts +71 -6
- package/build/index.mjs +613 -159
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +104 -3
- package/build/{use-path-links-Cby4i_VL.mjs → use-path-links-WCsysQPy.mjs} +332 -79
- package/build/use-path-links-WCsysQPy.mjs.map +1 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +4 -4
- package/src/components/agent/AgentAvatar.tsx +117 -0
- package/src/components/agent/Composer.tsx +29 -6
- package/src/components/agent/EffortSelect.tsx +64 -0
- package/src/components/agent/ModelSelect.tsx +1 -1
- package/src/components/agent/SessionBrowser.tsx +267 -42
- package/src/components/agent/SessionGroups.tsx +131 -21
- package/src/components/agent/SessionItem.tsx +182 -69
- package/src/components/agent/SessionPanel.tsx +75 -28
- package/src/components/agent/TranscriptItemView.tsx +20 -2
- package/src/components/agent/composer-commands.ts +24 -0
- package/src/components/terminal/affordances.tsx +29 -1
- package/src/components/terminal/height.ts +12 -2
- package/src/components/terminal/items.tsx +33 -1
- package/src/components/terminal/live-tool.ts +71 -0
- package/src/index.ts +12 -0
- package/src/lib/team-drop.ts +65 -0
- package/src/styles/terminal.css +9 -2
- package/src/styles/theme.css +20 -1
- package/build/use-path-links-Cby4i_VL.mjs.map +0 -1
|
@@ -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 {
|
|
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,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
|
|
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={
|
|
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
|
-
{...(
|
|
192
|
-
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')}
|
|
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.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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,
|
|
@@ -191,7 +301,7 @@ export function GroupHeading({
|
|
|
191
301
|
className,
|
|
192
302
|
...rest
|
|
193
303
|
}: {
|
|
194
|
-
label:
|
|
304
|
+
label: ReactNode
|
|
195
305
|
count: number
|
|
196
306
|
leading?: ReactNode
|
|
197
307
|
actions?: ReactNode
|