@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.
@@ -4,6 +4,7 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
4
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
5
5
  import { IMAGE_BOX_LINES, hostImagePathOf } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
7
+ import { ELAPSED_WIDEST, liveTailLines, runStartedAt, runTailLines, toolBusy } from './live-tool.ts'
7
8
  import { shellBodyLines, shellFooterText, shellHeaderText } from './shell-row.ts'
8
9
  import { todoLine, todoPreview } from './todos.ts'
9
10
  import { isPeerSend, peerName, planRun, runSummary, taskSummary } from './tool-run.ts'
@@ -565,7 +566,11 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
565
566
  const todos = todoPreview(item.name, item.input)
566
567
  const preview = todos ? todos.summary : toolInputPreview(item.input)
567
568
  const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
568
- let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
569
+ const elapsed = toolBusy(item) && item.ts !== undefined ? ELAPSED_WIDEST : ''
570
+ let acc = rowH(`${item.name}(${preview})${backend}${elapsed}`, m, { gutterCells: 2, extraPx })
571
+ for (const line of liveTailLines(item)) {
572
+ acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
573
+ }
569
574
 
570
575
  const boxes = (item.result?.images?.length ?? 0) + (hostImagePathOf(item) === undefined ? 0 : 1)
571
576
  if (boxes > 0) {
@@ -680,7 +685,12 @@ export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeigh
680
685
  return itemHeight(plan.item, m)
681
686
  }
682
687
  const busy = plan.items.some((item) => item.status === 'running' || item.status === 'pending')
683
- return rowH(runSummary(plan.items, busy), m)
688
+ const elapsed = runStartedAt(plan.items) === undefined ? '' : ELAPSED_WIDEST
689
+ let acc = rowH(runSummary(plan.items, busy) + elapsed, m)
690
+ for (const line of runTailLines(plan.items)) {
691
+ acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3 }))
692
+ }
693
+ return acc
684
694
  }
685
695
  return itemHeight(block.item, m)
686
696
  }
@@ -5,7 +5,8 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
5
5
  import { isMutatingTool } from '../../lib/tool-icon.ts'
6
6
  import { usePulse } from '../agent/pulse.tsx'
7
7
  import { PromptTokenText } from '../agent/PromptTokenText.tsx'
8
- import { ActionPlacementProvider, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
8
+ import { ActionPlacementProvider, BackgroundAction, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
9
+ import { canBackground, elapsedLabel, liveTailLines, runStartedAt, runTailLines } from './live-tool.ts'
9
10
  import { TerminalDiff } from './diff.tsx'
10
11
  import { TerminalMarkdown } from './markdown.tsx'
11
12
  import { usePeerNames } from './peer-names.tsx'
@@ -191,6 +192,9 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
191
192
  const busy = status === 'running' || status === 'pending'
192
193
  const isError = status === 'failed' || item.result?.isError === true
193
194
  const pulse = usePulse(busy)
195
+ const now = useTicker(busy && item.ts !== undefined)
196
+ const elapsed = busy ? elapsedLabel(item.ts, now) : undefined
197
+ const tail = liveTailLines(item)
194
198
 
195
199
  const text = item.result?.text ?? ''
196
200
  const lines = text.trimEnd().split('\n')
@@ -214,6 +218,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
214
218
  <WithActions
215
219
  actions={
216
220
  <>
221
+ {canBackground(item) ? <BackgroundAction toolUseId={item.id} /> : null}
217
222
  <BookmarkAction id={item.id} />
218
223
  {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
219
224
  </>
@@ -225,10 +230,18 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
225
230
  {title ?? item.name}
226
231
  </Ink>
227
232
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
233
+ {elapsed ? (
234
+ <Ink tone="faint">
235
+ {' '}
236
+ ·{'\u00a0'}
237
+ {elapsed}
238
+ </Ink>
239
+ ) : null}
228
240
  {title && open ? <Ink tone="faint"> · {item.name}</Ink> : null}
229
241
  {item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
230
242
  </Row>
231
243
  </Pressable>
244
+ <LiveTail lines={tail} />
232
245
  {hostPath !== undefined ? <TerminalHostImage path={hostPath} /> : null}
233
246
  {item.result?.images?.map((image) => (
234
247
  <TerminalImage key={image.partIndex} toolUseId={item.id} name={resultImageName(item.name, image)} image={image} />
@@ -373,14 +386,25 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
373
386
  })
374
387
  const failed = runFailed(items)
375
388
  const pulse = usePulse(busy)
389
+ const startedAt = runStartedAt(items)
390
+ const now = useTicker(startedAt !== undefined)
391
+ const elapsed = elapsedLabel(startedAt, now)
376
392
 
377
393
  return (
378
394
  <div ref={reveal} className={open ? 'term-open' : undefined}>
379
395
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
380
396
  <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
381
397
  {busy ? <span className="term-shimmer">{runSummary(items, busy)}</span> : runSummary(items, busy)}
398
+ {elapsed ? (
399
+ <Ink tone="faint">
400
+ {' '}
401
+ ·{'\u00a0'}
402
+ {elapsed}
403
+ </Ink>
404
+ ) : null}
382
405
  </Row>
383
406
  </Pressable>
407
+ {open ? null : <LiveTail lines={runTailLines(items)} />}
384
408
  {open ? (
385
409
  <div>
386
410
  {items.map((item, index) => (
@@ -511,6 +535,14 @@ export function FileRow({ item, href }: { item: Extract<TranscriptItem, { kind:
511
535
  )
512
536
  }
513
537
 
538
+ function LiveTail({ lines }: { lines: string[] }) {
539
+ return lines.map((line, index) => (
540
+ <Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone="faint">
541
+ {line || ' '}
542
+ </Row>
543
+ ))
544
+ }
545
+
514
546
  export function useTicker(on: boolean): number {
515
547
  const [now, setNow] = useState(() => Date.now())
516
548
  useEffect(() => {
@@ -0,0 +1,71 @@
1
+ import { formatDuration } from '../../lib/format.ts'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import type { ToolCallItem } from './blocks.ts'
4
+
5
+ export const LIVE_TAIL_LINES = 5
6
+ export const ELAPSED_AFTER_MS = 5000
7
+ export const SEND_NOW_AFTER_MS = 5000
8
+ // The widest label the header grows by while busy, so a height estimate never undercounts a wrap.
9
+ export const ELAPSED_WIDEST = ' ·\u00a059m\u00a059s'
10
+
11
+ const BACKGROUNDABLE = new Set(['Bash', 'Task', 'Agent'])
12
+
13
+ export function toolBusy(item: ToolCallItem): boolean {
14
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
15
+ return status === 'running' || status === 'pending'
16
+ }
17
+
18
+ export function liveTailLines(item: ToolCallItem): string[] {
19
+ if (!item.liveTail || !toolBusy(item)) {
20
+ return []
21
+ }
22
+ return item.liveTail.split('\n').slice(-LIVE_TAIL_LINES)
23
+ }
24
+
25
+ export function elapsedLabel(startedAt: number | undefined, now: number): string | undefined {
26
+ if (startedAt === undefined || now - startedAt < ELAPSED_AFTER_MS) {
27
+ return undefined
28
+ }
29
+ return formatDuration(Math.floor((now - startedAt) / 1000) * 1000).replace(' ', '\u00a0')
30
+ }
31
+
32
+ export function runStartedAt(items: readonly ToolCallItem[]): number | undefined {
33
+ let earliest: number | undefined
34
+ for (const item of items) {
35
+ if (toolBusy(item) && item.ts !== undefined && (earliest === undefined || item.ts < earliest)) {
36
+ earliest = item.ts
37
+ }
38
+ }
39
+ return earliest
40
+ }
41
+
42
+ export function canBackground(item: ToolCallItem): boolean {
43
+ return BACKGROUNDABLE.has(item.name) && item.parentToolUseId == null && toolBusy(item)
44
+ }
45
+
46
+ export function runTailLines(items: readonly ToolCallItem[]): string[] {
47
+ for (let index = items.length - 1; index >= 0; index--) {
48
+ const lines = liveTailLines(items[index]!)
49
+ if (lines.length > 0) {
50
+ return lines
51
+ }
52
+ }
53
+ return []
54
+ }
55
+
56
+ // Walks back only through the current turn: a running call is never older than the last prompt.
57
+ // The earliest start among them, or 0 when a call carries no `ts`.
58
+ export function backgroundableSince(items: readonly TranscriptItem[]): number | undefined {
59
+ let since: number | undefined
60
+ for (let index = items.length - 1; index >= 0; index--) {
61
+ const item = items[index]!
62
+ if (item.kind === 'user' && item.parentToolUseId == null) {
63
+ break
64
+ }
65
+ if (item.kind === 'tool_call' && canBackground(item)) {
66
+ const ts = item.ts ?? 0
67
+ since = since === undefined ? ts : Math.min(since, ts)
68
+ }
69
+ }
70
+ return since
71
+ }
package/src/index.ts CHANGED
@@ -123,6 +123,7 @@ export {
123
123
  type ComposerRow,
124
124
  } from './components/agent/composer-commands.ts'
125
125
  export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
126
+ export { EffortSelect, effortChoices, type EffortSelectProps } from './components/agent/EffortSelect.tsx'
126
127
  export {
127
128
  PERMISSION_MODES,
128
129
  PermissionModeSelect,
@@ -163,6 +164,7 @@ export {
163
164
  type GroupTarget,
164
165
  type SessionBrowserProps,
165
166
  } from './components/agent/SessionBrowser.tsx'
167
+ export { teamDropRefusal, type TeamMove } from './lib/team-drop.ts'
166
168
  export {
167
169
  STEP_DISPLAY_OPTIONS,
168
170
  SessionFilters,
@@ -172,6 +174,16 @@ export {
172
174
  type SessionFiltersProps,
173
175
  } from './components/agent/SessionFilters.tsx'
174
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'
175
187
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
176
188
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
177
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
+ }
@@ -483,9 +483,13 @@
483
483
  :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:not(.absolute) {
484
484
  position: relative;
485
485
  }
486
- :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:is(:hover, :focus-within) {
486
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:focus-within {
487
487
  z-index: 1;
488
488
  }
489
+ /* Above a focused row too: the row just clicked open keeps focus and would cover the bar beneath the hovered one. */
490
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:hover {
491
+ z-index: 2;
492
+ }
489
493
  @media (hover: none) {
490
494
  :is([data-terminal], [data-cards]) .term-actions {
491
495
  opacity: 1;
@@ -785,9 +789,12 @@ button.term-link {
785
789
  * it is the same `--bg`, and the row it covers is its own content.
786
790
  */
787
791
  [data-terminal] [data-slot='transcript-rows'] > [data-sticky-lane] {
788
- z-index: 3;
789
792
  pointer-events: none;
790
793
  }
794
+ /* Raised only while pinned: a raised idle lane covered the hover bar of the row above its prompt. */
795
+ [data-terminal] [data-slot='transcript-rows'] > [data-sticky-lane]:has(> [data-sticky-headlane] > [data-stuck]) {
796
+ z-index: 3;
797
+ }
791
798
 
792
799
  /* The prompt itself is *inside* the lane, not beneath it, so the lane's `none`
793
800
  reached it too and made a user message the one block in the transcript the
@@ -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% {
@@ -743,9 +760,11 @@
743
760
  * row's first line, so it has no in-flow y to match, and the 1st prompt (no
744
761
  * gap) and every later one (pt-4/pt-2) share these rules unchanged. */
745
762
  [data-slot='transcript-rows'] > [data-sticky-lane='cards'] {
746
- z-index: 3;
747
763
  pointer-events: none;
748
764
  }
765
+ [data-slot='transcript-rows'] > [data-sticky-lane='cards']:has(> [data-sticky-headlane] > [data-stuck]) {
766
+ z-index: 3;
767
+ }
749
768
 
750
769
  /* The head's own strip: the full lane, out of flow - see terminal.css for why
751
770
  sticky confinement wants the border box, not a cancelled margin box. */