@workerdeck/ui 3.3.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,4 +1,4 @@
1
- import { r as SessionPanelProps } from "./SessionPanel-BNNc8grK.mjs";
1
+ import { r as SessionPanelProps } from "./SessionPanel-DIi4sEgh.mjs";
2
2
  import { CSSProperties } from "react";
3
3
  import { ShellInfo } from "@workerdeck/protocol";
4
4
  import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
@@ -1,4 +1,4 @@
1
- import { $t as Spinner, En as Input, N as ShellTerminal, Zt as Splitter, i as SessionPanel, in as TooltipProvider, jn as cn, kn as Button, n as usePathLinks, nn as Tip, q as EntryIcon, t as resolveAgainstCwd } from "./use-path-links-Cby4i_VL.mjs";
1
+ import { $t as Splitter, N as ShellTerminal, Nn as cn, On as Input, Y as EntryIcon, i as SessionPanel, in as Tip, jn as Button, n as usePathLinks, on as TooltipProvider, t as resolveAgainstCwd, tn as Spinner } from "./use-path-links-WCsysQPy.mjs";
2
2
  import { m as formatBytes } from "./status-kmsBhuFG.mjs";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
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.3.0",
43
- "@workerdeck/react": "3.3.0",
44
- "@workerdeck/client": "3.3.0"
42
+ "@workerdeck/protocol": "3.5.0",
43
+ "@workerdeck/client": "3.5.0",
44
+ "@workerdeck/react": "3.5.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -0,0 +1,117 @@
1
+ import type { CSSProperties } from 'react'
2
+ import type { AgentRef, SessionRow, SessionState } from '@workerdeck/protocol'
3
+ import { EngineIcon, vendorMarkClass } from './EngineIcon.tsx'
4
+ import { SessionStatusIcon } from './SessionStatusIcon.tsx'
5
+ import { cn } from '../../lib/utils.ts'
6
+
7
+ export type AgentAvatarImage = { still: string; busy?: { src: string; durations: number[] } }
8
+
9
+ // Keyed by `AgentRef.avatar`, the gateway path: the host fetches with its own credentials and hands over data URLs.
10
+ export type AgentAvatars = Record<string, AgentAvatarImage>
11
+
12
+ export function avatarOf(avatars: AgentAvatars | undefined, agent: AgentRef | undefined): AgentAvatarImage | undefined {
13
+ return agent?.avatar === undefined ? undefined : avatars?.[agent.avatar]
14
+ }
15
+
16
+ export interface AgentAvatarProps {
17
+ row: SessionRow
18
+ image?: AgentAvatarImage
19
+ size?: number
20
+ // The state the corner badge draws; a folded lead passes its team's most urgent one.
21
+ state?: SessionState
22
+ badge?: boolean
23
+ className?: string
24
+ }
25
+
26
+ export function AgentAvatar({ row, image, size = 32, state, badge = true, className }: AgentAvatarProps) {
27
+ const { info } = row
28
+ const shown = state ?? row.state
29
+ const busy = shown === 'working' && image?.busy && image.busy.durations.length > 1 ? image.busy : undefined
30
+ const asleep = info.engineAsleep === true
31
+ const engine = info.engine ?? 'claude'
32
+ const badgeSize = size >= 28 ? 14 : 11
33
+ return (
34
+ <span
35
+ data-slot="agent-avatar"
36
+ className={cn('relative inline-flex shrink-0', className)}
37
+ style={{ width: size, height: size }}
38
+ title={info.agent?.name}
39
+ >
40
+ {busy ? (
41
+ <span
42
+ aria-hidden
43
+ className="wd-avatar-strip size-full rounded-[4px]"
44
+ style={
45
+ {
46
+ backgroundImage: `url(${busy.src})`,
47
+ backgroundSize: `${busy.durations.length * size}px ${size}px`,
48
+ '--wd-strip-end': `-${busy.durations.length * size}px`,
49
+ '--wd-strip-steps': busy.durations.length,
50
+ animationDuration: `${busy.durations.reduce((sum, ms) => sum + ms, 0)}ms`,
51
+ } as CSSProperties
52
+ }
53
+ />
54
+ ) : image ? (
55
+ <img
56
+ src={image.still}
57
+ alt=""
58
+ draggable={false}
59
+ className={cn('size-full rounded-[4px] [image-rendering:pixelated]', asleep && 'opacity-60 grayscale')}
60
+ />
61
+ ) : (
62
+ <span className="flex size-full items-center justify-center rounded-[4px] border border-dashed border-border">
63
+ <EngineIcon engine={engine} model={info.model} className={cn('size-1/2', vendorMarkClass(engine, info.model))} />
64
+ </span>
65
+ )}
66
+ {badge && (shown !== 'idle' || asleep) ? (
67
+ <span
68
+ className="absolute -right-1 -bottom-1 flex items-center justify-center rounded-full bg-bg"
69
+ style={{ width: badgeSize, height: badgeSize }}
70
+ >
71
+ <SessionStatusIcon row={{ ...row, state: shown }} className="size-full p-px" />
72
+ </span>
73
+ ) : null}
74
+ </span>
75
+ )
76
+ }
77
+
78
+ export function LeadChip({ className }: { className?: string }) {
79
+ return (
80
+ <span
81
+ className={cn(
82
+ 'shrink-0 rounded-[3px] border border-accent/50 px-1 text-[0.625rem] leading-3.5 font-medium tracking-wide text-accent',
83
+ className,
84
+ )}
85
+ >
86
+ LEAD
87
+ </span>
88
+ )
89
+ }
90
+
91
+ export interface AgentHeadingProps {
92
+ row: SessionRow
93
+ image?: AgentAvatarImage
94
+ // Overrides `AgentRef.conversation`, which a gateway sends from the second conversation on.
95
+ conversation?: number
96
+ className?: string
97
+ }
98
+
99
+ // A session panel's title for an agent: avatar, name, its place in a team. Plain sessions keep the host's own title.
100
+ export function AgentHeading({ row, image, conversation, className }: AgentHeadingProps) {
101
+ const agent = row.info.agent
102
+ if (!agent) {
103
+ return null
104
+ }
105
+ const n = conversation ?? agent.conversation ?? 1
106
+ const detail = [agent.lead !== undefined && agent.team ? `${agent.team} team` : undefined, `conversation ${n}`]
107
+ .filter(Boolean)
108
+ .join(' · ')
109
+ return (
110
+ <span data-slot="agent-heading" className={cn('flex min-w-0 items-center gap-2', className)}>
111
+ <AgentAvatar row={row} image={image} size={24} />
112
+ <span className="truncate text-body-sm font-medium text-fg-1">{agent.name}</span>
113
+ {agent.leads ? <LeadChip /> : null}
114
+ {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
115
+ </span>
116
+ )
117
+ }
@@ -39,6 +39,8 @@ export type ComposerHandle = {
39
39
 
40
40
  export interface ComposerProps {
41
41
  onSend: (text: string, attachmentIds: string[]) => void
42
+ // Present while a foreground command the engine can background is running: ⌥↵ sends and moves it to the background.
43
+ onSendNow?: (text: string, attachmentIds: string[]) => void
42
44
  onInterrupt: () => void
43
45
  busy: boolean
44
46
  disabled?: boolean
@@ -70,6 +72,7 @@ export interface ComposerProps {
70
72
 
71
73
  export function Composer({
72
74
  onSend,
75
+ onSendNow,
73
76
  onInterrupt,
74
77
  busy,
75
78
  disabled,
@@ -184,7 +187,7 @@ export function Composer({
184
187
  rankPeerSessions(query, peers).map((peer) => ({
185
188
  value: peer.slug,
186
189
  label: peer.label,
187
- description: [peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
190
+ description: [peer.gateway, peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
188
191
  icon: <Users className="size-3.5 text-fg-3" />,
189
192
  })),
190
193
  onSelect: (suggestion) => suggestion.value,
@@ -214,7 +217,7 @@ export function Composer({
214
217
  const staged = attachments?.items ?? []
215
218
  const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure
216
219
 
217
- const submit = () => {
220
+ const submit = (now = false) => {
218
221
  if (!canSend) {
219
222
  return
220
223
  }
@@ -230,7 +233,8 @@ export function Composer({
230
233
  focus()
231
234
  return
232
235
  }
233
- onSend(plainText.trim(), attachments?.readyIds ?? [])
236
+ const deliver = now && onSendNow ? onSendNow : onSend
237
+ deliver(plainText.trim(), attachments?.readyIds ?? [])
234
238
  attachments?.clear()
235
239
  draft?.clear()
236
240
  clear()
@@ -275,6 +279,11 @@ export function Composer({
275
279
  setHelpOpen(true)
276
280
  return
277
281
  }
282
+ if (e.key === 'Enter' && e.altKey && onSendNow && canSend) {
283
+ e.preventDefault()
284
+ submit(true)
285
+ return
286
+ }
278
287
  if (helpOpen) {
279
288
  setHelpOpen(false)
280
289
  }
@@ -351,9 +360,16 @@ export function Composer({
351
360
  </span>
352
361
  )
353
362
 
363
+ const sendNow =
364
+ onSendNow && canSend && !shellMode ? (
365
+ <button type="button" className="term-press term-link text-label" onClick={() => submit(true)}>
366
+ ⌥↵ send now - moves the running command to the background
367
+ </button>
368
+ ) : null
369
+
354
370
  const interrupting = busy && !canSend
355
371
  const submitButton = terminal ? (
356
- <GlyphButton label="Send" disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
372
+ <GlyphButton label="Send" disabled={!canSend} onClick={() => submit()} tone={canSend ? 'blue' : undefined}>
357
373
  ↵
358
374
  </GlyphButton>
359
375
  ) : interrupting ? (
@@ -361,7 +377,7 @@ export function Composer({
361
377
  <Square className="size-3" />
362
378
  </Button>
363
379
  ) : (
364
- <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={submit}>
380
+ <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={() => submit()}>
365
381
  <ArrowUp className="size-4" />
366
382
  </Button>
367
383
  )
@@ -402,7 +418,7 @@ export function Composer({
402
418
  triggers={triggers}
403
419
  markdown={false}
404
420
  normalizeBullets={false}
405
- onSubmit={submit}
421
+ onSubmit={() => submit()}
406
422
  disabled={disabled}
407
423
  placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
408
424
  minHeight={minHeight}
@@ -428,6 +444,12 @@ export function Composer({
428
444
  className={cn('term-composer', shellMode && 'term-composer-shell', disabled && 'opacity-60')}
429
445
  >
430
446
  <div className="term-composer-body">
447
+ {sendNow ? (
448
+ <div className="term-row">
449
+ <span aria-hidden className="term-gutter" />
450
+ {sendNow}
451
+ </div>
452
+ ) : null}
431
453
  {stagedStrip}
432
454
  <div className="term-row">
433
455
  {canAttach && !shellMode ? fileField : null}
@@ -464,6 +486,7 @@ export function Composer({
464
486
  return (
465
487
  <div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
466
488
  {helpRow}
489
+ {sendNow ? <div className="mx-auto mb-1 w-full max-w-[var(--wd-transcript-max-width)] text-text-muted">{sendNow}</div> : null}
467
490
  <div
468
491
  {...dropHandlers}
469
492
  className={cn(
@@ -0,0 +1,64 @@
1
+ import type { EngineCapabilities, ModelOption } from '@workerdeck/protocol'
2
+ import { Brain } from 'lucide-react'
3
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import { matchModel } from './ModelSelect.tsx'
6
+
7
+ const DEFAULT_VALUE = 'default'
8
+
9
+ export function effortChoices(
10
+ models: readonly ModelOption[],
11
+ model: string | undefined,
12
+ capabilities: Pick<EngineCapabilities, 'reasoningEfforts'>,
13
+ ): readonly string[] {
14
+ return matchModel(models, model)?.reasoningEfforts ?? capabilities.reasoningEfforts ?? []
15
+ }
16
+
17
+ export interface EffortSelectProps {
18
+ efforts: readonly string[]
19
+ effort?: string | null
20
+ onEffortChange: (effort?: string) => void
21
+ variant?: 'toolbar' | 'form'
22
+ disabled?: boolean
23
+ className?: string
24
+ }
25
+
26
+ export function EffortSelect({ efforts, effort, onEffortChange, variant = 'toolbar', disabled, className }: EffortSelectProps) {
27
+ const items = [{ value: DEFAULT_VALUE, label: 'Default' }, ...efforts.map((value) => ({ value, label: value }))]
28
+ return (
29
+ <Select
30
+ items={items}
31
+ value={effort ?? null}
32
+ onValueChange={(value) => {
33
+ if (typeof value !== 'string' || value === effort) {
34
+ return
35
+ }
36
+ onEffortChange(value === DEFAULT_VALUE ? undefined : value)
37
+ }}
38
+ disabled={disabled}
39
+ >
40
+ <SelectTrigger
41
+ aria-label="Reasoning effort"
42
+ className={cn(
43
+ variant === 'toolbar' && 'h-6 max-w-32 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
44
+ className,
45
+ )}
46
+ >
47
+ <span className={cn('flex items-center gap-1 truncate', variant === 'toolbar' && 'font-mono text-label')}>
48
+ <Brain className="size-3 shrink-0" />
49
+ <SelectValue placeholder="effort" />
50
+ </span>
51
+ </SelectTrigger>
52
+ <SelectContent className="min-w-40">
53
+ {items.map((item) => (
54
+ <SelectItem key={item.value} value={item.value}>
55
+ <SelectItemText>
56
+ <span className="font-medium">{item.label}</span>
57
+ </SelectItemText>
58
+ {item.value === DEFAULT_VALUE ? <span className="text-label text-fg-4">The model's configured default</span> : null}
59
+ </SelectItem>
60
+ ))}
61
+ </SelectContent>
62
+ </Select>
63
+ )
64
+ }
@@ -39,7 +39,7 @@ function optionMatches(option: ModelOption, model: string): boolean {
39
39
  return token !== '' && token === family(dropVariant(option.value))
40
40
  }
41
41
 
42
- function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
42
+ export function matchModel(models: readonly ModelOption[], model?: string): ModelOption | undefined {
43
43
  if (!model) {
44
44
  return undefined
45
45
  }