@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,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 { 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
@@ -125,6 +146,54 @@ export function SessionItem({
125
146
  },
126
147
  )
127
148
  const holdsOpenStep = steps.some((s) => s.key === activeStepKey)
149
+ const agent = info.agent
150
+ const label = agent ? agent.name : sessionLabel(info)
151
+ const editable = agent ? agent.name : (info.title ?? '')
152
+ const time = formatRelativeTime(info.lastActivityAt ?? info.createdAt)
153
+ const nameLabel =
154
+ isEditing && onRename ? (
155
+ <NameEditor
156
+ initial={editable}
157
+ placeholder={agent ? 'Agent name' : 'Session name'}
158
+ onCommit={(title) => {
159
+ setEditing(false)
160
+ if (title !== editable && (title || !agent)) {
161
+ onRename(title)
162
+ }
163
+ }}
164
+ onCancel={() => setEditing(false)}
165
+ />
166
+ ) : (
167
+ <span
168
+ onDoubleClick={
169
+ onRename && renameOn === 'doubleClick'
170
+ ? (e) => {
171
+ e.stopPropagation()
172
+ setEditing(true)
173
+ }
174
+ : undefined
175
+ }
176
+ className="min-w-0 shrink truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
177
+ >
178
+ {label}
179
+ </span>
180
+ )
181
+ const unread =
182
+ row.unseen > 0 ? (
183
+ <span
184
+ // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
185
+ title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
186
+ className={cn(
187
+ 'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
188
+ 'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
189
+ // Grey while the turn is still producing, accent once it has stopped: the badge's colour
190
+ // answers "is this waiting for me", and a working session is not yet.
191
+ row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
192
+ )}
193
+ >
194
+ {row.unseen}
195
+ </span>
196
+ ) : null
128
197
 
129
198
  return (
130
199
  <div
@@ -156,75 +225,107 @@ export function SessionItem({
156
225
  className,
157
226
  )}
158
227
  >
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)}
188
- </span>
189
- )}
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',
228
+ {agent && variant === 'member' ? (
229
+ <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')}>
230
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} size={22} />
231
+ {nameLabel}
232
+ {info.title ? <span className="min-w-0 flex-1 truncate text-body-sm text-fg-3">{info.title}</span> : <span className="flex-1" />}
233
+ {unread}
234
+ <span className="shrink-0 text-body-sm text-fg-4">{time}</span>
235
+ {actions}
236
+ </div>
237
+ ) : agent || tile ? (
238
+ <div className={cn('flex items-center gap-2 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
239
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} state={badgeState} />
240
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
241
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
242
+ {nameLabel}
243
+ {agent?.leads ? <LeadChip /> : null}
244
+ <span className="flex-1" />
245
+ {unread}
246
+ {teamUnseen > 0 ? (
247
+ <span
248
+ title={`${teamUnseen} new message${teamUnseen === 1 ? '' : 's'} in the team`}
249
+ 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"
250
+ >
251
+ <Users className="size-3" />
252
+ {teamUnseen}
253
+ </span>
254
+ ) : null}
255
+ {teamTray}
256
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
257
+ </div>
258
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
259
+ {agent ? (
260
+ <>
261
+ {info.title ? <span className="min-w-0 truncate text-fg-4">{info.title}</span> : null}
262
+ <span className="shrink-0 text-fg-4">
263
+ {model ? (
264
+ <>
265
+ {info.title ? '· ' : ''}
266
+ <span className={vendorTextClass(engine, info.model)}>{model}</span>
267
+ {' · '}
268
+ </>
269
+ ) : info.title ? (
270
+ '· '
271
+ ) : null}
272
+ {time}
273
+ </span>
274
+ </>
275
+ ) : (
276
+ <>
277
+ <span className="min-w-0 truncate text-fg-4">
278
+ {parts.map((part, i) => (
279
+ <Fragment key={i}>
280
+ {i > 0 ? ' · ' : ''}
281
+ {part}
282
+ </Fragment>
283
+ ))}
284
+ </span>
285
+ <span className="shrink-0 text-fg-4">
286
+ {parts.length > 0 ? '· ' : ''}
287
+ {time}
288
+ </span>
289
+ </>
200
290
  )}
201
- >
202
- {row.unseen}
203
- </span>
204
- ) : null}
205
- <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
291
+ <span className="min-w-0 flex-1" />
292
+ {actions}
293
+ </div>
294
+ </div>
206
295
  </div>
296
+ ) : (
297
+ <div className={cn('flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
298
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
299
+ <Gutter>
300
+ <SessionStatusIcon row={row} />
301
+ </Gutter>
302
+ {nameLabel}
303
+ <span className="flex-1" />
304
+ {unread}
305
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
306
+ </div>
207
307
 
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}
308
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
309
+ <Gutter>
310
+ <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
311
+ </Gutter>
312
+ <span className="min-w-0 truncate text-fg-4">
313
+ {parts.map((part, i) => (
314
+ <Fragment key={i}>
315
+ {i > 0 ? ' · ' : ''}
316
+ {part}
317
+ </Fragment>
318
+ ))}
319
+ </span>
320
+ <span className="shrink-0 text-fg-4">
321
+ {parts.length > 0 ? '· ' : ''}
322
+ {time}
323
+ </span>
324
+ <span className="min-w-0 flex-1" />
325
+ {actions}
326
+ </div>
226
327
  </div>
227
- </div>
328
+ )}
228
329
 
229
330
  {steps.length > 0 ? (
230
331
  <div className="flex flex-col">
@@ -241,7 +342,17 @@ function Gutter({ children }: { children: ReactNode }) {
241
342
  return <span className="flex size-4 shrink-0 items-center justify-center">{children}</span>
242
343
  }
243
344
 
244
- function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit: (title: string) => void; onCancel: () => void }) {
345
+ function NameEditor({
346
+ initial,
347
+ placeholder,
348
+ onCommit,
349
+ onCancel,
350
+ }: {
351
+ initial: string
352
+ placeholder: string
353
+ onCommit: (title: string) => void
354
+ onCancel: () => void
355
+ }) {
245
356
  const [value, setValue] = useState(initial)
246
357
  const ref = useRef<HTMLInputElement>(null)
247
358
  useEffect(() => {
@@ -261,8 +372,8 @@ function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit
261
372
  ref={ref}
262
373
  value={value}
263
374
  spellCheck={false}
264
- placeholder="Session name"
265
- aria-label="Session name"
375
+ placeholder={placeholder}
376
+ aria-label={placeholder}
266
377
  onClick={(e) => e.stopPropagation()}
267
378
  onDoubleClick={(e) => e.stopPropagation()}
268
379
  onChange={(e) => setValue(e.target.value)}
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
+ }
@@ -666,6 +666,23 @@
666
666
  opacity: 0;
667
667
  animation: wd-hold-appear 150ms ease-out 600ms forwards;
668
668
  }
669
+ @keyframes wd-avatar-strip {
670
+ to {
671
+ background-position-x: var(--wd-strip-end);
672
+ }
673
+ }
674
+ .wd-avatar-strip {
675
+ image-rendering: pixelated;
676
+ background-repeat: no-repeat;
677
+ animation-name: wd-avatar-strip;
678
+ animation-timing-function: steps(var(--wd-strip-steps));
679
+ animation-iteration-count: infinite;
680
+ }
681
+ @media (prefers-reduced-motion: reduce) {
682
+ .wd-avatar-strip {
683
+ animation: none;
684
+ }
685
+ }
669
686
 
670
687
  @keyframes prompt-area-chip-ripple {
671
688
  0% {