@workerdeck/ui 3.4.0 → 3.6.1

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,19 +124,27 @@ 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) {
106
143
  return (
107
144
  <CustomGroupHeader
108
145
  group={group}
146
+ look={config.customGroups?.find((custom) => custom.id === group.custom)}
147
+ onStyle={(look) => group.custom && drag.style(group.custom, look)}
109
148
  editing={group.custom !== undefined && editingGroup === group.custom}
110
149
  onEditingChange={(editing) => setEditingGroup(editing ? group.custom : undefined)}
111
150
  onRename={(name) => group.custom && drag.rename(group.custom, name)}
@@ -114,6 +153,19 @@ export function SessionBrowser({
114
153
  />
115
154
  )
116
155
  }
156
+ if (group.earlier) {
157
+ return (
158
+ <button
159
+ type="button"
160
+ aria-expanded={config.earlierOpen === true}
161
+ onClick={() => set({ earlierOpen: !config.earlierOpen })}
162
+ className="flex items-center gap-1 px-2 text-left text-label text-fg-4 hover:text-fg-2"
163
+ >
164
+ {config.earlierOpen ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
165
+ Earlier · {group.rows.length} one-off session{group.rows.length === 1 ? '' : 's'}
166
+ </button>
167
+ )
168
+ }
117
169
  if (config.groupBy === 'none' || !group.label) {
118
170
  return null
119
171
  }
@@ -196,12 +248,12 @@ export function SessionBrowser({
196
248
  <Empty icon={<Layers />} title="Nothing here" description="No session to show." />
197
249
  )
198
250
  ) : (
199
- <div className="flex flex-col gap-4 px-1">
251
+ <div className="flex flex-col gap-2 px-1">
200
252
  {groups.map((group) => (
201
253
  <div
202
254
  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')}
255
+ {...(draggable ? drag.container(group) : {})}
256
+ className={cn('flex flex-col gap-1 rounded-md', draggable && drag.isOver(`group:${group.key}`) && 'bg-row-hover/50')}
205
257
  >
206
258
  {heading(group)}
207
259
  {custom && group.custom !== undefined && group.rows.length === 0 ? (
@@ -209,36 +261,70 @@ export function SessionBrowser({
209
261
  Drag sessions here
210
262
  </div>
211
263
  ) : 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
- ))}
264
+ {group.earlier && !config.earlierOpen
265
+ ? null
266
+ : group.rows.map((row) => {
267
+ const item = (member: SessionRow, variant: 'card' | 'member', extra: Partial<SessionRowItemProps> = {}) => (
268
+ <SessionRowItem
269
+ key={sessionKey(member)}
270
+ dragProps={
271
+ onTeamMove || (custom && variant === 'card')
272
+ ? drag.session(sessionKey(member), group, variant === 'member' ? { row: member, lead: row } : { row })
273
+ : undefined
274
+ }
275
+ dropCue={draggable ? drag.cue(sessionKey(member)) : undefined}
276
+ row={member}
277
+ variant={variant}
278
+ active={isActive(member)}
279
+ activeSubagentId={activeSubagentId}
280
+ activeShellId={activeShellId}
281
+ actions={rowActions?.(member)}
282
+ now={now}
283
+ showGateway={gateways > 1 && config.groupBy !== 'project'}
284
+ showProject={config.groupBy !== 'project'}
285
+ subagents={config.subagents}
286
+ shells={config.shells}
287
+ tasks={config.tasks}
288
+ projectIcons={projectIcons}
289
+ avatars={avatars}
290
+ tile={tiles}
291
+ onSelect={onSelect}
292
+ onDelete={onDelete}
293
+ onRename={member.info.agent ? onRenameAgent : onRename}
294
+ onClearContext={onClearContext}
295
+ onSleep={onSleep}
296
+ onSelectSubagent={onSelectSubagent}
297
+ onSelectTask={onSelectTask}
298
+ onStopTask={onStopTask}
299
+ onSelectShell={onSelectShell}
300
+ onKillShell={onKillShell}
301
+ onShellAgentWrite={onShellAgentWrite}
302
+ {...extra}
303
+ />
304
+ )
305
+ if (!row.members?.length) {
306
+ return item(row, 'card', { className: row.context ? 'opacity-50' : undefined })
307
+ }
308
+ const collapsed = isTeamCollapsed(config, row)
309
+ return (
310
+ <TeamBlock
311
+ key={sessionKey(row)}
312
+ lead={row}
313
+ collapsed={collapsed}
314
+ avatars={avatars}
315
+ onToggle={() => onConfigChange(toggleTeamCollapsed(config, row))}
316
+ renderLead={(summary, tray) =>
317
+ item(row, 'card', {
318
+ className: row.context ? 'opacity-50' : undefined,
319
+ badgeState: collapsed ? row.teamState : undefined,
320
+ teamUnseen: collapsed ? summary.unseen : 0,
321
+ teamTray: tray,
322
+ })
323
+ }
324
+ renderMember={(member) => item(member, 'member')}
325
+ />
326
+ )
327
+ })}
242
328
  </div>
243
329
  ))}
244
330
  {custom ? <NewGroupButton onClick={() => setEditingGroup(drag.create())} /> : null}
@@ -255,8 +341,15 @@ function iconSrcOf(row: SessionRow | undefined, icons: Record<string, string> |
255
341
 
256
342
  interface SessionRowItemProps {
257
343
  row: SessionRow
344
+ variant?: 'card' | 'member'
345
+ avatars?: AgentAvatars
346
+ badgeState?: SessionState
347
+ teamUnseen?: number
348
+ teamTray?: ReactNode
349
+ tile?: boolean
350
+ className?: string
258
351
  dragProps?: HTMLAttributes<HTMLDivElement>
259
- dropTarget?: boolean
352
+ dropCue?: DropCue
260
353
  active?: boolean
261
354
  actions?: ReactNode
262
355
  activeSubagentId?: string
@@ -283,8 +376,15 @@ interface SessionRowItemProps {
283
376
 
284
377
  function SessionRowItem({
285
378
  row,
379
+ variant,
380
+ avatars,
381
+ badgeState,
382
+ teamUnseen,
383
+ teamTray,
384
+ tile,
385
+ className,
286
386
  dragProps,
287
- dropTarget,
387
+ dropCue,
288
388
  active,
289
389
  actions,
290
390
  activeSubagentId,
@@ -314,6 +414,13 @@ function SessionRowItem({
314
414
  const item = (
315
415
  <SessionItem
316
416
  row={row}
417
+ variant={variant}
418
+ avatars={avatars}
419
+ badgeState={badgeState}
420
+ teamUnseen={teamUnseen}
421
+ teamTray={teamTray}
422
+ tile={tile}
423
+ className={className}
317
424
  active={active === true}
318
425
  activeStepKey={active ? (activeShellId ?? activeSubagentId) : undefined}
319
426
  now={now}
@@ -338,7 +445,7 @@ function SessionRowItem({
338
445
  actions ?? (
339
446
  <>
340
447
  {onRename && !editing ? (
341
- <RowAction label="Rename session" onClick={() => setEditing(true)}>
448
+ <RowAction label={info.agent ? 'Rename agent' : 'Rename session'} onClick={() => setEditing(true)}>
342
449
  <Pencil className="size-3 text-fg-3" />
343
450
  </RowAction>
344
451
  ) : null}
@@ -373,9 +480,105 @@ function SessionRowItem({
373
480
  if (!dragProps) {
374
481
  return item
375
482
  }
483
+ const zone = dropCue?.zone
376
484
  return (
377
- <div {...dragProps} className={cn('rounded-md', dropTarget && 'shadow-[inset_0_2px_0_0_var(--color-accent)]')}>
485
+ <div
486
+ {...dragProps}
487
+ title={zone === 'refused' ? dropCue?.message : undefined}
488
+ className={cn(
489
+ 'rounded-md',
490
+ zone === 'before' && 'shadow-[inset_0_2px_0_0_var(--color-accent)]',
491
+ zone === 'after' && 'shadow-[inset_0_-2px_0_0_var(--color-accent)]',
492
+ zone === 'join' && 'bg-accent/10 ring-1 ring-accent',
493
+ zone === 'refused' && 'ring-1 ring-danger/60',
494
+ )}
495
+ >
378
496
  {item}
497
+ {zone === 'refused' && dropCue?.message ? (
498
+ <div role="status" className="px-3 pb-1 text-label text-danger">
499
+ {dropCue.message}
500
+ </div>
501
+ ) : null}
502
+ </div>
503
+ )
504
+ }
505
+
506
+ const TRAY_MAX = 4
507
+
508
+ // A folded team draws its members on the lead's first line (avatar and status each) and no row of its own; the tray
509
+ // unfolds it. Expanded, a tree line runs from the lead's avatar with the knob atop it, so neither state needs a twistie.
510
+ function TeamBlock({
511
+ lead,
512
+ collapsed,
513
+ avatars,
514
+ onToggle,
515
+ renderLead,
516
+ renderMember,
517
+ }: {
518
+ lead: SessionRow
519
+ collapsed: boolean
520
+ avatars?: AgentAvatars
521
+ onToggle: () => void
522
+ renderLead: (summary: ReturnType<typeof teamSummary>, tray?: ReactNode) => ReactNode
523
+ renderMember: (member: SessionRow) => ReactNode
524
+ }) {
525
+ const members = lead.members ?? []
526
+ const summary = teamSummary(lead)
527
+ if (collapsed) {
528
+ const surfaced = members.filter((member) => member.state === 'attention')
529
+ const counts = [
530
+ `${summary.members} member${summary.members === 1 ? '' : 's'}`,
531
+ summary.working ? `${summary.working} working` : undefined,
532
+ summary.attention ? `${summary.attention} need${summary.attention === 1 ? 's' : ''} you` : undefined,
533
+ ].filter(Boolean)
534
+ const hidden = members.length - TRAY_MAX
535
+ const tray = (
536
+ <button
537
+ type="button"
538
+ aria-expanded={false}
539
+ aria-label={`Expand ${lead.info.agent?.name ?? 'team'}: ${counts.join(', ')}`}
540
+ title={counts.join(' · ')}
541
+ onClick={(e) => {
542
+ e.stopPropagation()
543
+ onToggle()
544
+ }}
545
+ className="flex shrink-0 items-center gap-1 rounded-[4px] px-0.5 hover:bg-row-hover"
546
+ >
547
+ {members.slice(0, hidden > 0 ? TRAY_MAX - 1 : TRAY_MAX).map((member) => (
548
+ <AgentAvatar
549
+ key={sessionKey(member)}
550
+ row={member}
551
+ image={avatarOf(avatars, member.info.agent)}
552
+ size={16}
553
+ className="rounded-[3px]"
554
+ />
555
+ ))}
556
+ {hidden > 0 ? <span className="text-label text-fg-4 tabular-nums">+{hidden + 1}</span> : null}
557
+ </button>
558
+ )
559
+ return (
560
+ <div data-slot="team" data-collapsed className="flex flex-col">
561
+ {renderLead(summary, tray)}
562
+ {surfaced.length ? <div className="ml-3 flex flex-col">{surfaced.map(renderMember)}</div> : null}
563
+ </div>
564
+ )
565
+ }
566
+ return (
567
+ <div data-slot="team" className="flex flex-col">
568
+ {renderLead(summary)}
569
+ <div className="relative ml-3.5 flex flex-col pl-1">
570
+ <span aria-hidden className="absolute top-0 bottom-3 left-0 w-px bg-border" />
571
+ <button
572
+ type="button"
573
+ aria-expanded
574
+ aria-label={`Collapse ${lead.info.agent?.name ?? 'team'}`}
575
+ onClick={onToggle}
576
+ 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"
577
+ >
578
+ <ChevronDown className="size-2.5" />
579
+ </button>
580
+ {members.map(renderMember)}
581
+ </div>
379
582
  </div>
380
583
  )
381
584
  }