@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.
- package/build/index.d.mts +55 -4
- package/build/index.mjs +592 -157
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +88 -0
- package/package.json +4 -4
- package/src/components/agent/AgentAvatar.tsx +117 -0
- package/src/components/agent/SessionBrowser.tsx +243 -42
- package/src/components/agent/SessionGroups.tsx +130 -20
- package/src/components/agent/SessionItem.tsx +180 -69
- package/src/index.ts +11 -0
- package/src/lib/team-drop.ts +65 -0
- package/src/styles/theme.css +17 -0
|
@@ -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
|
-
|
|
160
|
-
<div className=
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
</
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
?
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
{
|
|
188
|
-
</
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
{
|
|
203
|
-
</
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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({
|
|
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=
|
|
265
|
-
aria-label=
|
|
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
|
+
}
|
package/src/styles/theme.css
CHANGED
|
@@ -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% {
|