@workerdeck/ui 3.5.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.
package/build/scoped.css CHANGED
@@ -440,6 +440,9 @@
440
440
  .wd-root .-mr-1 {
441
441
  margin-right: calc(var(--spacing) * -1);
442
442
  }
443
+ .wd-root .-mr-1\.5 {
444
+ margin-right: calc(var(--spacing) * -1.5);
445
+ }
443
446
  .wd-root .mr-1 {
444
447
  margin-right: var(--spacing);
445
448
  }
@@ -697,6 +700,9 @@
697
700
  .wd-root .w-36 {
698
701
  width: calc(var(--spacing) * 36);
699
702
  }
703
+ .wd-root .w-44 {
704
+ width: calc(var(--spacing) * 44);
705
+ }
700
706
  .wd-root .w-48 {
701
707
  width: calc(var(--spacing) * 48);
702
708
  }
@@ -757,6 +763,9 @@
757
763
  .wd-root .max-w-full {
758
764
  max-width: 100%;
759
765
  }
766
+ .wd-root .max-w-max {
767
+ max-width: max-content;
768
+ }
760
769
  .wd-root .max-w-md {
761
770
  max-width: var(--container-md);
762
771
  }
@@ -817,6 +826,9 @@
817
826
  .wd-root .grow {
818
827
  flex-grow: 1;
819
828
  }
829
+ .wd-root .basis-0 {
830
+ flex-basis: 0px;
831
+ }
820
832
  .wd-root .border-collapse {
821
833
  border-collapse: collapse;
822
834
  }
@@ -891,6 +903,9 @@
891
903
  .wd-root .appearance-none {
892
904
  appearance: none;
893
905
  }
906
+ .wd-root .grid-cols-4 {
907
+ grid-template-columns: repeat(4, minmax(0, 1fr));
908
+ }
894
909
  .wd-root .flex-col {
895
910
  flex-direction: column;
896
911
  }
@@ -1022,6 +1037,9 @@
1022
1037
  .wd-root .rounded {
1023
1038
  border-radius: 0.25rem;
1024
1039
  }
1040
+ .wd-root .rounded-\[2px\] {
1041
+ border-radius: 2px;
1042
+ }
1025
1043
  .wd-root .rounded-\[3px\] {
1026
1044
  border-radius: 3px;
1027
1045
  }
@@ -1293,6 +1311,9 @@
1293
1311
  .wd-root .bg-red-100 {
1294
1312
  background-color: var(--color-red-100);
1295
1313
  }
1314
+ .wd-root .bg-row-attention {
1315
+ background-color: var(--row-attention);
1316
+ }
1296
1317
  .wd-root .bg-row-hover\/50 {
1297
1318
  background-color: var(--row-hover);
1298
1319
  @supports (color: color-mix(in lab, red, red)) {
@@ -1878,12 +1899,22 @@
1878
1899
  --tw-ring-color: color-mix(in oklab, var(--danger) 60%, transparent);
1879
1900
  }
1880
1901
  }
1902
+ .wd-root .ring-fg-3 {
1903
+ --tw-ring-color: var(--fg-3);
1904
+ }
1881
1905
  .wd-root .ring-ring\/30 {
1882
1906
  --tw-ring-color: var(--ring);
1883
1907
  @supports (color: color-mix(in lab, red, red)) {
1884
1908
  --tw-ring-color: color-mix(in oklab, var(--ring) 30%, transparent);
1885
1909
  }
1886
1910
  }
1911
+ .wd-root .ring-offset-1 {
1912
+ --tw-ring-offset-width: 1px;
1913
+ --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
1914
+ }
1915
+ .wd-root .ring-offset-surface {
1916
+ --tw-ring-offset-color: var(--surface);
1917
+ }
1887
1918
  .wd-root .outline {
1888
1919
  outline-style: var(--tw-outline-style);
1889
1920
  outline-width: 1px;
@@ -1977,6 +2008,9 @@
1977
2008
  .wd-root .\[image-rendering\:pixelated\] {
1978
2009
  image-rendering: pixelated;
1979
2010
  }
2011
+ .wd-root .ring-inset {
2012
+ --tw-ring-inset: inset;
2013
+ }
1980
2014
  @media (hover: hover) {
1981
2015
  .wd-root .group-hover\:block:is(:where(.group):hover *) {
1982
2016
  display: block;
@@ -2145,6 +2179,13 @@
2145
2179
  .wd-root .hover\:opacity-100:hover {
2146
2180
  opacity: 100%;
2147
2181
  }
2182
+ .wd-root .hover\:ring-1:hover {
2183
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
2184
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
2185
+ }
2186
+ .wd-root .hover\:ring-border:hover {
2187
+ --tw-ring-color: var(--border);
2188
+ }
2148
2189
  }
2149
2190
  .wd-root .focus-visible\:border-ring:focus-visible {
2150
2191
  border-color: var(--ring);
@@ -3212,6 +3253,7 @@
3212
3253
  --row-active: rgb(0 0 0 / 0.08);
3213
3254
  --row-selected: #cfe4f7;
3214
3255
  --row-selected-weak: #e4e6f1;
3256
+ --row-attention: #fbdcb8;
3215
3257
  --badge: #c4c4c4;
3216
3258
  --badge-fg: #333333;
3217
3259
  --border: #ebebeb;
@@ -3265,6 +3307,7 @@
3265
3307
  --row-active: rgb(255 255 255 / 0.1);
3266
3308
  --row-selected: #04395e;
3267
3309
  --row-selected-weak: #37373d;
3310
+ --row-attention: #5c3906;
3268
3311
  --badge: #616161;
3269
3312
  --badge-fg: #cccccc;
3270
3313
  --border: #1f1f1f;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "3.5.0",
3
+ "version": "3.6.1",
4
4
  "type": "module",
5
5
  "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  "streamdown": "^2.6.0",
40
40
  "tailwind-merge": "^3.7.0",
41
41
  "use-stick-to-bottom": "^1.1.6",
42
- "@workerdeck/protocol": "3.5.0",
43
- "@workerdeck/client": "3.5.0",
44
- "@workerdeck/react": "3.5.0"
42
+ "@workerdeck/client": "3.6.1",
43
+ "@workerdeck/react": "3.6.1",
44
+ "@workerdeck/protocol": "3.6.1"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -143,6 +143,8 @@ export function SessionBrowser({
143
143
  return (
144
144
  <CustomGroupHeader
145
145
  group={group}
146
+ look={config.customGroups?.find((custom) => custom.id === group.custom)}
147
+ onStyle={(look) => group.custom && drag.style(group.custom, look)}
146
148
  editing={group.custom !== undefined && editingGroup === group.custom}
147
149
  onEditingChange={(editing) => setEditingGroup(editing ? group.custom : undefined)}
148
150
  onRename={(name) => group.custom && drag.rename(group.custom, name)}
@@ -246,7 +248,7 @@ export function SessionBrowser({
246
248
  <Empty icon={<Layers />} title="Nothing here" description="No session to show." />
247
249
  )
248
250
  ) : (
249
- <div className="flex flex-col gap-4 px-1">
251
+ <div className="flex flex-col gap-2 px-1">
250
252
  {groups.map((group) => (
251
253
  <div
252
254
  key={group.key}
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import type { DragEvent, HTMLAttributes, ReactNode } from 'react'
3
- import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
3
+ import { GripVertical, Image as ImageIcon, Pencil, Plus, Trash2 } from 'lucide-react'
4
4
  import {
5
5
  addCustomGroup,
6
6
  moveCustomGroup,
@@ -9,11 +9,15 @@ import {
9
9
  removeCustomGroup,
10
10
  renameCustomGroup,
11
11
  sessionKey,
12
+ styleCustomGroup,
13
+ customGroupColor,
14
+ PROJECT_ACCENTS,
12
15
  } from '@workerdeck/protocol'
13
16
  import type { CustomGroup, SessionGroup, SessionRow } from '@workerdeck/protocol'
14
17
  import { dropZone, joinDrop, memberDrop, type DropZone, type TeamMove } from '../../lib/team-drop.ts'
15
18
  import { Button } from '../ui/Button.tsx'
16
19
  import { Input } from '../ui/Input.tsx'
20
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover.tsx'
17
21
  import { cn } from '../../lib/utils.ts'
18
22
 
19
23
  type DragItem = { kind: 'session'; key: string } | { kind: 'group'; id: string }
@@ -213,23 +217,29 @@ export function useGroupDrag(
213
217
  return id
214
218
  },
215
219
  rename: (id: string, name: string) => onChange(renameCustomGroup(groups, id, name)),
220
+ style: (id: string, look: { color?: string | null; icon?: string | null }) => onChange(styleCustomGroup(groups, id, look)),
216
221
  remove: (id: string) => onChange(removeCustomGroup(groups, id)),
217
222
  }
218
223
  }
219
224
 
220
225
  export function CustomGroupHeader({
221
226
  group,
227
+ look,
222
228
  editing,
223
229
  onEditingChange,
224
230
  onRename,
225
231
  onRemove,
232
+ onStyle,
226
233
  dragProps,
227
234
  }: {
228
235
  group: SessionGroup
236
+ // The stored group, for its colour and image; absent for the ungrouped bucket.
237
+ look?: CustomGroup
229
238
  editing: boolean
230
239
  onEditingChange: (editing: boolean) => void
231
240
  onRename: (name: string) => void
232
241
  onRemove: () => void
242
+ onStyle?: (look: { color?: string | null; icon?: string | null }) => void
233
243
  dragProps: HTMLAttributes<HTMLDivElement>
234
244
  }) {
235
245
  if (editing) {
@@ -242,9 +252,17 @@ export function CustomGroupHeader({
242
252
  {...dragProps}
243
253
  label={group.label ?? ''}
244
254
  count={group.rows.length}
255
+ caps={!custom}
245
256
  className={cn('group/heading', custom && 'cursor-grab')}
246
257
  onDoubleClick={custom ? () => onEditingChange(true) : undefined}
247
- leading={custom ? <GripVertical className="-ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" /> : undefined}
258
+ leading={
259
+ custom ? (
260
+ <>
261
+ <GripVertical className="-mr-1.5 -ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" />
262
+ {look ? <GroupBadge group={look} onStyle={onStyle} /> : null}
263
+ </>
264
+ ) : undefined
265
+ }
248
266
  actions={
249
267
  custom ? (
250
268
  <>
@@ -298,6 +316,7 @@ export function GroupHeading({
298
316
  count,
299
317
  leading,
300
318
  actions,
319
+ caps = true,
301
320
  className,
302
321
  ...rest
303
322
  }: {
@@ -305,11 +324,13 @@ export function GroupHeading({
305
324
  count: number
306
325
  leading?: ReactNode
307
326
  actions?: ReactNode
327
+ // Off for a name the operator typed, which keeps its own case.
328
+ caps?: boolean
308
329
  } & HTMLAttributes<HTMLDivElement>) {
309
330
  return (
310
331
  <div {...rest} className={cn('group/heading flex min-h-5 items-center gap-2 px-2 text-label font-medium text-fg-4', className)}>
311
332
  {leading}
312
- <span className="min-w-0 truncate uppercase tracking-wide">{label}</span>
333
+ <span className={cn('min-w-0 truncate', caps ? 'uppercase tracking-wide' : 'text-fg-3')}>{label}</span>
313
334
  <span className="text-fg-4/70">{count}</span>
314
335
  {actions ? (
315
336
  <span className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover/heading:opacity-100 focus-within:opacity-100">
@@ -356,3 +377,105 @@ export function NewGroupButton({ onClick }: { onClick: () => void }) {
356
377
  </Button>
357
378
  )
358
379
  }
380
+
381
+ const ICON_PX = 64
382
+
383
+ // A group's badge: its image, else its colour; pressing it picks either.
384
+ export function GroupBadge({
385
+ group,
386
+ onStyle,
387
+ }: {
388
+ group: CustomGroup
389
+ onStyle?: (look: { color?: string | null; icon?: string | null }) => void
390
+ }) {
391
+ const [error, setError] = useState<string>()
392
+ const file = useRef<HTMLInputElement>(null)
393
+ const mark = group.icon ? (
394
+ <img src={group.icon} alt="" className="size-3.5 rounded-[3px] object-cover" draggable={false} />
395
+ ) : (
396
+ <span className="size-2 rounded-[2px]" style={{ background: customGroupColor(group) }} />
397
+ )
398
+ if (!onStyle) {
399
+ return <span className="flex size-3.5 shrink-0 items-center justify-center">{mark}</span>
400
+ }
401
+ return (
402
+ <Popover>
403
+ <PopoverTrigger
404
+ render={
405
+ <button
406
+ type="button"
407
+ aria-label={`Colour or image for ${group.name}`}
408
+ title="Colour or image"
409
+ onClick={(e) => e.stopPropagation()}
410
+ onDoubleClick={(e) => e.stopPropagation()}
411
+ className="flex size-3.5 shrink-0 items-center justify-center rounded-[3px] hover:ring-1 hover:ring-border"
412
+ >
413
+ {mark}
414
+ </button>
415
+ }
416
+ />
417
+ <PopoverContent align="start" className="flex w-44 flex-col gap-2">
418
+ <div className="grid grid-cols-4 gap-1.5">
419
+ {PROJECT_ACCENTS.map((color) => (
420
+ <button
421
+ key={color}
422
+ type="button"
423
+ aria-label={`Colour ${color}`}
424
+ onClick={() => onStyle({ color, icon: null })}
425
+ className={cn(
426
+ 'h-6 rounded-[4px] ring-offset-1 ring-offset-surface',
427
+ !group.icon && customGroupColor(group) === color && 'ring-2 ring-fg-3',
428
+ )}
429
+ style={{ background: color }}
430
+ />
431
+ ))}
432
+ </div>
433
+ <input
434
+ ref={file}
435
+ type="file"
436
+ accept="image/*"
437
+ className="hidden"
438
+ onChange={(e) => {
439
+ const picked = e.target.files?.[0]
440
+ e.target.value = ''
441
+ if (!picked) {
442
+ return
443
+ }
444
+ setError(undefined)
445
+ void iconFromFile(picked)
446
+ .then((icon) => onStyle({ icon }))
447
+ .catch(() => setError('That image could not be read'))
448
+ }}
449
+ />
450
+ <Button variant="outline" size="xs" onClick={() => file.current?.click()}>
451
+ <ImageIcon />
452
+ Choose image…
453
+ </Button>
454
+ {group.icon ? (
455
+ <Button variant="ghost" size="xs" onClick={() => onStyle({ icon: null })}>
456
+ Remove image
457
+ </Button>
458
+ ) : null}
459
+ {error ? <span className="text-label text-danger">{error}</span> : null}
460
+ </PopoverContent>
461
+ </Popover>
462
+ )
463
+ }
464
+
465
+ // Scaled to cover a 64 px square and re-encoded, so a photo from disk stores as a few kB in the view config.
466
+ async function iconFromFile(file: File): Promise<string> {
467
+ const bitmap = await createImageBitmap(file)
468
+ const canvas = document.createElement('canvas')
469
+ canvas.width = ICON_PX
470
+ canvas.height = ICON_PX
471
+ const context = canvas.getContext('2d')
472
+ if (!context) {
473
+ throw new Error('no canvas')
474
+ }
475
+ const scale = Math.max(ICON_PX / bitmap.width, ICON_PX / bitmap.height)
476
+ const width = bitmap.width * scale
477
+ const height = bitmap.height * scale
478
+ context.drawImage(bitmap, (ICON_PX - width) / 2, (ICON_PX - height) / 2, width, height)
479
+ bitmap.close()
480
+ return canvas.toDataURL('image/png')
481
+ }
@@ -2,7 +2,7 @@ import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  import { Users } from 'lucide-react'
3
3
  import type { ReactNode } from 'react'
4
4
  import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
5
- import type { SessionRow, SessionState, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
5
+ import type { SessionInfo, SessionRow, SessionState, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
6
6
  import { AgentAvatar, LeadChip, avatarOf, type AgentAvatars } from './AgentAvatar.tsx'
7
7
  import { ContextRing } from './ContextRing.tsx'
8
8
  import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
@@ -108,6 +108,9 @@ export function SessionItem({
108
108
  ].filter((part): part is string => Boolean(part))
109
109
 
110
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'
111
114
  const parts: ReactNode[] = []
112
115
  if (model) {
113
116
  parts.push(
@@ -116,6 +119,13 @@ export function SessionItem({
116
119
  </span>,
117
120
  )
118
121
  }
122
+ if (status) {
123
+ parts.push(
124
+ <span key="status" title={status} className="text-fg-3">
125
+ {status}
126
+ </span>,
127
+ )
128
+ }
119
129
  if (project !== undefined) {
120
130
  parts.push(
121
131
  <span key="project" title={projectTitle}>
@@ -221,7 +231,13 @@ export function SessionItem({
221
231
  className={cn(
222
232
  'group flex w-full cursor-pointer flex-col p-1 text-left outline-none',
223
233
  'rounded-[4px] transition-colors focus-visible:ring-2 focus-visible:ring-ring',
224
- 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',
225
241
  className,
226
242
  )}
227
243
  >
@@ -229,7 +245,13 @@ export function SessionItem({
229
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')}>
230
246
  <AgentAvatar row={row} image={avatarOf(avatars, agent)} size={22} />
231
247
  {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" />}
248
+ {status ? (
249
+ <span title={status} className="min-w-0 flex-1 truncate text-body-sm text-fg-3">
250
+ {status}
251
+ </span>
252
+ ) : (
253
+ <span className="flex-1" />
254
+ )}
233
255
  {unread}
234
256
  <span className="shrink-0 text-body-sm text-fg-4">{time}</span>
235
257
  {actions}
@@ -241,6 +263,7 @@ export function SessionItem({
241
263
  <div className="flex h-5 items-center gap-1.5 overflow-hidden">
242
264
  {nameLabel}
243
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}
244
267
  <span className="flex-1" />
245
268
  {unread}
246
269
  {teamUnseen > 0 ? (
@@ -258,19 +281,13 @@ export function SessionItem({
258
281
  <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
259
282
  {agent ? (
260
283
  <>
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>
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}
274
291
  </>
275
292
  ) : (
276
293
  <>
@@ -398,3 +415,7 @@ function NameEditor({
398
415
  />
399
416
  )
400
417
  }
418
+
419
+ export function statusLine(label: SessionInfo['statusLabel']): string | undefined {
420
+ return label ? (label.emoji ? `${label.emoji} ${label.text}` : label.text) : undefined
421
+ }
@@ -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: