@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,7 +1,9 @@
1
1
  import { Fragment, useEffect, useRef, useState } from 'react'
2
+ import { Users } from 'lucide-react'
2
3
  import type { ReactNode } from 'react'
3
4
  import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
4
- import type { SessionRow, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
5
+ import type { SessionInfo, SessionRow, SessionState, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
6
+ import { AgentAvatar, LeadChip, avatarOf, type AgentAvatars } from './AgentAvatar.tsx'
5
7
  import { ContextRing } from './ContextRing.tsx'
6
8
  import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
7
9
  import { ProjectIcon } from './ProjectIcon.tsx'
@@ -36,6 +38,19 @@ export interface SessionItemProps {
36
38
  editing?: boolean
37
39
  onEditingChange?: (editing: boolean) => void
38
40
  actions?: ReactNode
41
+ // Drawn when `row.info.agent` is set: the card then leads with the avatar and names the agent.
42
+ avatars?: AgentAvatars
43
+ // `member` is the one-row card a team draws under its lead.
44
+ variant?: 'card' | 'member'
45
+ // The state the avatar badge draws, for a folded lead standing in for its team.
46
+ badgeState?: SessionState
47
+ // Unread summed over a folded team's members, drawn with the team glyph beside the lead's own.
48
+ teamUnseen?: number
49
+ // A folded lead's members, drawn on its first line before the context ring.
50
+ teamTray?: ReactNode
51
+ // Draws a session with no agent in the agent card's shape (an engine-mark tile for the avatar), so a list mixing
52
+ // agents and plain sessions keeps one column.
53
+ tile?: boolean
39
54
  className?: string
40
55
  }
41
56
 
@@ -64,6 +79,12 @@ export function SessionItem({
64
79
  editing,
65
80
  onEditingChange,
66
81
  actions,
82
+ avatars,
83
+ variant = 'card',
84
+ badgeState,
85
+ teamUnseen = 0,
86
+ teamTray,
87
+ tile = false,
67
88
  className,
68
89
  }: SessionItemProps) {
69
90
  const { info } = row
@@ -87,6 +108,9 @@ export function SessionItem({
87
108
  ].filter((part): part is string => Boolean(part))
88
109
 
89
110
  const model = friendlyModel(info.model)
111
+ const status = statusLine(info.statusLabel)
112
+ // Its own approval or question, or (on a lead) a team member's: the one card that asks for an action.
113
+ const needsYou = row.state === 'attention' || row.teamState === 'attention' || badgeState === 'attention'
90
114
  const parts: ReactNode[] = []
91
115
  if (model) {
92
116
  parts.push(
@@ -95,6 +119,13 @@ export function SessionItem({
95
119
  </span>,
96
120
  )
97
121
  }
122
+ if (status) {
123
+ parts.push(
124
+ <span key="status" title={status} className="text-fg-3">
125
+ {status}
126
+ </span>,
127
+ )
128
+ }
98
129
  if (project !== undefined) {
99
130
  parts.push(
100
131
  <span key="project" title={projectTitle}>
@@ -125,6 +156,54 @@ export function SessionItem({
125
156
  },
126
157
  )
127
158
  const holdsOpenStep = steps.some((s) => s.key === activeStepKey)
159
+ const agent = info.agent
160
+ const label = agent ? agent.name : sessionLabel(info)
161
+ const editable = agent ? agent.name : (info.title ?? '')
162
+ const time = formatRelativeTime(info.lastActivityAt ?? info.createdAt)
163
+ const nameLabel =
164
+ isEditing && onRename ? (
165
+ <NameEditor
166
+ initial={editable}
167
+ placeholder={agent ? 'Agent name' : 'Session name'}
168
+ onCommit={(title) => {
169
+ setEditing(false)
170
+ if (title !== editable && (title || !agent)) {
171
+ onRename(title)
172
+ }
173
+ }}
174
+ onCancel={() => setEditing(false)}
175
+ />
176
+ ) : (
177
+ <span
178
+ onDoubleClick={
179
+ onRename && renameOn === 'doubleClick'
180
+ ? (e) => {
181
+ e.stopPropagation()
182
+ setEditing(true)
183
+ }
184
+ : undefined
185
+ }
186
+ className="min-w-0 shrink truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
187
+ >
188
+ {label}
189
+ </span>
190
+ )
191
+ const unread =
192
+ row.unseen > 0 ? (
193
+ <span
194
+ // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
195
+ title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
196
+ className={cn(
197
+ 'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
198
+ 'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
199
+ // Grey while the turn is still producing, accent once it has stopped: the badge's colour
200
+ // answers "is this waiting for me", and a working session is not yet.
201
+ row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
202
+ )}
203
+ >
204
+ {row.unseen}
205
+ </span>
206
+ ) : null
128
207
 
129
208
  return (
130
209
  <div
@@ -152,79 +231,118 @@ export function SessionItem({
152
231
  className={cn(
153
232
  'group flex w-full cursor-pointer flex-col p-1 text-left outline-none',
154
233
  'rounded-[4px] transition-colors focus-visible:ring-2 focus-visible:ring-ring',
155
- holdsOpenStep ? 'bg-row-selected-weak' : active ? 'bg-row-selected' : 'hover:bg-row-hover',
234
+ needsYou
235
+ ? cn('bg-row-attention', active && 'ring-1 ring-accent ring-inset')
236
+ : holdsOpenStep
237
+ ? 'bg-row-selected-weak'
238
+ : active
239
+ ? 'bg-row-selected'
240
+ : 'hover:bg-row-hover',
156
241
  className,
157
242
  )}
158
243
  >
159
- <div className={cn('flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
160
- <div className="flex h-5 items-center gap-1.5 overflow-hidden">
161
- <Gutter>
162
- <SessionStatusIcon row={row} />
163
- </Gutter>
164
- {isEditing && onRename ? (
165
- <NameEditor
166
- initial={info.title ?? ''}
167
- onCommit={(title) => {
168
- setEditing(false)
169
- if (title !== (info.title ?? '')) {
170
- onRename(title)
171
- }
172
- }}
173
- onCancel={() => setEditing(false)}
174
- />
175
- ) : (
176
- <span
177
- onDoubleClick={
178
- onRename && renameOn === 'doubleClick'
179
- ? (e) => {
180
- e.stopPropagation()
181
- setEditing(true)
182
- }
183
- : undefined
184
- }
185
- className="min-w-0 flex-1 truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
186
- >
187
- {sessionLabel(info)}
244
+ {agent && variant === 'member' ? (
245
+ <div className={cn('flex h-6 items-center gap-1.5 overflow-hidden py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
246
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} size={22} />
247
+ {nameLabel}
248
+ {status ? (
249
+ <span title={status} className="min-w-0 flex-1 truncate text-body-sm text-fg-3">
250
+ {status}
188
251
  </span>
252
+ ) : (
253
+ <span className="flex-1" />
189
254
  )}
190
- {row.unseen > 0 ? (
191
- <span
192
- // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
193
- title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
194
- className={cn(
195
- 'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
196
- 'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
197
- // Grey while the turn is still producing, accent once it has stopped: the badge's colour
198
- // answers "is this waiting for me", and a working session is not yet.
199
- row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
255
+ {unread}
256
+ <span className="shrink-0 text-body-sm text-fg-4">{time}</span>
257
+ {actions}
258
+ </div>
259
+ ) : agent || tile ? (
260
+ <div className={cn('flex items-center gap-2 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
261
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} state={badgeState} />
262
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
263
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
264
+ {nameLabel}
265
+ {agent?.leads ? <LeadChip /> : null}
266
+ {agent ? <span className="w-max max-w-max min-w-0 grow basis-0 truncate text-label text-fg-4/70">{time}</span> : null}
267
+ <span className="flex-1" />
268
+ {unread}
269
+ {teamUnseen > 0 ? (
270
+ <span
271
+ title={`${teamUnseen} new message${teamUnseen === 1 ? '' : 's'} in the team`}
272
+ className="flex h-4 shrink-0 items-center gap-0.5 rounded-full bg-accent px-1.5 text-[0.75rem] leading-none text-accent-fg tabular-nums"
273
+ >
274
+ <Users className="size-3" />
275
+ {teamUnseen}
276
+ </span>
277
+ ) : null}
278
+ {teamTray}
279
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
280
+ </div>
281
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
282
+ {agent ? (
283
+ <>
284
+ {model ? <span className={cn('shrink-0', vendorTextClass(engine, info.model))}>{model}</span> : null}
285
+ {status ? (
286
+ <span title={status} className="min-w-0 truncate text-fg-3">
287
+ {model ? '· ' : ''}
288
+ {status}
289
+ </span>
290
+ ) : null}
291
+ </>
292
+ ) : (
293
+ <>
294
+ <span className="min-w-0 truncate text-fg-4">
295
+ {parts.map((part, i) => (
296
+ <Fragment key={i}>
297
+ {i > 0 ? ' · ' : ''}
298
+ {part}
299
+ </Fragment>
300
+ ))}
301
+ </span>
302
+ <span className="shrink-0 text-fg-4">
303
+ {parts.length > 0 ? '· ' : ''}
304
+ {time}
305
+ </span>
306
+ </>
200
307
  )}
201
- >
202
- {row.unseen}
203
- </span>
204
- ) : null}
205
- <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
308
+ <span className="min-w-0 flex-1" />
309
+ {actions}
310
+ </div>
311
+ </div>
206
312
  </div>
313
+ ) : (
314
+ <div className={cn('flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
315
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
316
+ <Gutter>
317
+ <SessionStatusIcon row={row} />
318
+ </Gutter>
319
+ {nameLabel}
320
+ <span className="flex-1" />
321
+ {unread}
322
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
323
+ </div>
207
324
 
208
- <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
209
- <Gutter>
210
- <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
211
- </Gutter>
212
- <span className="min-w-0 truncate text-fg-4">
213
- {parts.map((part, i) => (
214
- <Fragment key={i}>
215
- {i > 0 ? ' · ' : ''}
216
- {part}
217
- </Fragment>
218
- ))}
219
- </span>
220
- <span className="shrink-0 text-fg-4">
221
- {parts.length > 0 ? '· ' : ''}
222
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
223
- </span>
224
- <span className="min-w-0 flex-1" />
225
- {actions}
325
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
326
+ <Gutter>
327
+ <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
328
+ </Gutter>
329
+ <span className="min-w-0 truncate text-fg-4">
330
+ {parts.map((part, i) => (
331
+ <Fragment key={i}>
332
+ {i > 0 ? ' · ' : ''}
333
+ {part}
334
+ </Fragment>
335
+ ))}
336
+ </span>
337
+ <span className="shrink-0 text-fg-4">
338
+ {parts.length > 0 ? '· ' : ''}
339
+ {time}
340
+ </span>
341
+ <span className="min-w-0 flex-1" />
342
+ {actions}
343
+ </div>
226
344
  </div>
227
- </div>
345
+ )}
228
346
 
229
347
  {steps.length > 0 ? (
230
348
  <div className="flex flex-col">
@@ -241,7 +359,17 @@ function Gutter({ children }: { children: ReactNode }) {
241
359
  return <span className="flex size-4 shrink-0 items-center justify-center">{children}</span>
242
360
  }
243
361
 
244
- function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit: (title: string) => void; onCancel: () => void }) {
362
+ function NameEditor({
363
+ initial,
364
+ placeholder,
365
+ onCommit,
366
+ onCancel,
367
+ }: {
368
+ initial: string
369
+ placeholder: string
370
+ onCommit: (title: string) => void
371
+ onCancel: () => void
372
+ }) {
245
373
  const [value, setValue] = useState(initial)
246
374
  const ref = useRef<HTMLInputElement>(null)
247
375
  useEffect(() => {
@@ -261,8 +389,8 @@ function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit
261
389
  ref={ref}
262
390
  value={value}
263
391
  spellCheck={false}
264
- placeholder="Session name"
265
- aria-label="Session name"
392
+ placeholder={placeholder}
393
+ aria-label={placeholder}
266
394
  onClick={(e) => e.stopPropagation()}
267
395
  onDoubleClick={(e) => e.stopPropagation()}
268
396
  onChange={(e) => setValue(e.target.value)}
@@ -287,3 +415,7 @@ function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit
287
415
  />
288
416
  )
289
417
  }
418
+
419
+ export function statusLine(label: SessionInfo['statusLabel']): string | undefined {
420
+ return label ? (label.emoji ? `${label.emoji} ${label.text}` : label.text) : undefined
421
+ }
package/src/index.ts CHANGED
@@ -164,6 +164,7 @@ export {
164
164
  type GroupTarget,
165
165
  type SessionBrowserProps,
166
166
  } from './components/agent/SessionBrowser.tsx'
167
+ export { teamDropRefusal, type TeamMove } from './lib/team-drop.ts'
167
168
  export {
168
169
  STEP_DISPLAY_OPTIONS,
169
170
  SessionFilters,
@@ -173,6 +174,16 @@ export {
173
174
  type SessionFiltersProps,
174
175
  } from './components/agent/SessionFilters.tsx'
175
176
  export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
177
+ export {
178
+ AgentAvatar,
179
+ AgentHeading,
180
+ LeadChip,
181
+ avatarOf,
182
+ type AgentAvatarImage,
183
+ type AgentAvatarProps,
184
+ type AgentAvatars,
185
+ type AgentHeadingProps,
186
+ } from './components/agent/AgentAvatar.tsx'
176
187
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
177
188
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
178
189
  export {
@@ -0,0 +1,65 @@
1
+ import type { SessionRow } from '@workerdeck/protocol'
2
+
3
+ export type DropZone = 'before' | 'join' | 'after'
4
+
5
+ // What a drop asks of the host: `lead: null` leaves the team. Joining renumbers the team densely from 0, so
6
+ // `siblings` lists the other members whose `order` changes; teams are small and most of them never moved.
7
+ export type TeamMove = { row: SessionRow; lead: SessionRow | null; order?: number; siblings?: { row: SessionRow; order: number }[] }
8
+
9
+ export function dropZone(offsetY: number, height: number): DropZone {
10
+ if (height <= 0) {
11
+ return 'join'
12
+ }
13
+ const at = offsetY / height
14
+ return at < 0.25 ? 'before' : at > 0.75 ? 'after' : 'join'
15
+ }
16
+
17
+ // Mirrors the gateway's `leadRefusal`, so a drag can say no before it asks; the 409 stays the authority.
18
+ export function teamDropRefusal(dragged: SessionRow, lead: SessionRow): string | undefined {
19
+ const mover = dragged.info.agent
20
+ const target = lead.info.agent
21
+ if (dragged.hostId !== lead.hostId) {
22
+ return 'a team stays on one gateway'
23
+ }
24
+ if (!target) {
25
+ return 'only an agent can lead a team'
26
+ }
27
+ if (mover?.id === target.id || dragged.info.id === lead.info.id) {
28
+ return 'an agent cannot lead its own team'
29
+ }
30
+ if (target.lead !== undefined) {
31
+ return `${target.name} is a member of a team; teams are one level deep`
32
+ }
33
+ if (mover?.leads) {
34
+ return `${mover.name} leads a team; teams are one level deep`
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ // The move a drop over a member asks for: join (or reorder within) that member's team, at the member's place.
40
+ export function memberDrop(dragged: SessionRow, lead: SessionRow, member: SessionRow, zone: DropZone): TeamMove | string {
41
+ const refusal = teamDropRefusal(dragged, lead)
42
+ if (refusal) {
43
+ return refusal
44
+ }
45
+ const others = (lead.members ?? []).filter((row) => row.info.id !== dragged.info.id || row.hostId !== dragged.hostId)
46
+ const at = others.findIndex((row) => row.info.id === member.info.id && row.hostId === member.hostId)
47
+ const index = at < 0 ? others.length : zone === 'before' ? at : at + 1
48
+ return placeMember(dragged, lead, others, index)
49
+ }
50
+
51
+ export function joinDrop(dragged: SessionRow, lead: SessionRow): TeamMove | string {
52
+ const refusal = teamDropRefusal(dragged, lead)
53
+ if (refusal) {
54
+ return refusal
55
+ }
56
+ const others = (lead.members ?? []).filter((row) => row.info.id !== dragged.info.id || row.hostId !== dragged.hostId)
57
+ return placeMember(dragged, lead, others, others.length)
58
+ }
59
+
60
+ function placeMember(dragged: SessionRow, lead: SessionRow, others: readonly SessionRow[], index: number): TeamMove {
61
+ const siblings = others
62
+ .map((row, i) => ({ row, order: i < index ? i : i + 1 }))
63
+ .filter(({ row, order }) => row.info.agent?.order !== order)
64
+ return { row: dragged, lead, order: index, ...(siblings.length ? { siblings } : {}) }
65
+ }
@@ -65,6 +65,9 @@
65
65
  --row-active: rgb(0 0 0 / 0.08);
66
66
  --row-selected: #cfe4f7;
67
67
  --row-selected-weak: #e4e6f1;
68
+ /* A card that is waiting on the person: an approval or a question, its own or a team member's. As loud as
69
+ the selection, in the warning hue, because it is the one card that asks for an action. */
70
+ --row-attention: #fbdcb8;
68
71
 
69
72
  /* A count badge that is NOT an alert. The unread badge wears `--accent`
70
73
  because unread is a call to look; a plain count (how many sub-agents, how
@@ -187,6 +190,7 @@
187
190
  --row-active: rgb(255 255 255 / 0.1);
188
191
  --row-selected: #04395e;
189
192
  --row-selected-weak: #37373d;
193
+ --row-attention: #5c3906;
190
194
 
191
195
  /* See the light block. */
192
196
  --badge: #616161;
@@ -352,6 +356,7 @@
352
356
  --color-row-active: var(--row-active);
353
357
  --color-row-selected: var(--row-selected);
354
358
  --color-row-selected-weak: var(--row-selected-weak);
359
+ --color-row-attention: var(--row-attention);
355
360
  --color-badge: var(--badge);
356
361
  --color-badge-fg: var(--badge-fg);
357
362
  /* Named code-bg (not code) so it can't shadow the --text-code font-size token:
@@ -666,6 +671,23 @@
666
671
  opacity: 0;
667
672
  animation: wd-hold-appear 150ms ease-out 600ms forwards;
668
673
  }
674
+ @keyframes wd-avatar-strip {
675
+ to {
676
+ background-position-x: var(--wd-strip-end);
677
+ }
678
+ }
679
+ .wd-avatar-strip {
680
+ image-rendering: pixelated;
681
+ background-repeat: no-repeat;
682
+ animation-name: wd-avatar-strip;
683
+ animation-timing-function: steps(var(--wd-strip-steps));
684
+ animation-iteration-count: infinite;
685
+ }
686
+ @media (prefers-reduced-motion: reduce) {
687
+ .wd-avatar-strip {
688
+ animation: none;
689
+ }
690
+ }
669
691
 
670
692
  @keyframes prompt-area-chip-ripple {
671
693
  0% {