@workerdeck/ui 0.11.0 → 0.12.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.
@@ -16,8 +16,10 @@ import { SessionEmptyState } from './SessionEmptyState.tsx'
16
16
  import { ToolCallCard } from './ToolCallCard.tsx'
17
17
  import {
18
18
  LineGlyph,
19
+ ROW_GAP,
19
20
  TranscriptVariantProvider,
20
21
  useLines,
22
+ type TranscriptDensity,
21
23
  type TranscriptVariant,
22
24
  } from './transcript-variant.tsx'
23
25
 
@@ -339,6 +341,7 @@ function TranscriptRows({
339
341
  boundary,
340
342
  since,
341
343
  lines,
344
+ gap,
342
345
  fileUrl,
343
346
  attachmentUrl,
344
347
  hostImage,
@@ -348,6 +351,8 @@ function TranscriptRows({
348
351
  boundary: number | undefined
349
352
  since: number | undefined
350
353
  lines: boolean
354
+ /** The inter-row gap for this variant and density (`ROW_GAP`). */
355
+ gap: { className?: string; px: number }
351
356
  fileUrl?: (path: string) => string
352
357
  attachmentUrl?: (attachmentId: string) => string
353
358
  hostImage?: (path: string) => Promise<string | undefined>
@@ -387,7 +392,10 @@ function TranscriptRows({
387
392
  // a measurement replaces them the moment a row mounts. A lines row is one
388
393
  // text line more often than not; cards vary too much for any constant to
389
394
  // be right, so that one is merely the order of magnitude.
390
- estimateSize: () => (lines ? 32 : 100),
395
+ // Plus the gap, which is real height on the same measured element — an
396
+ // estimate that ignored it would make the scrollbar visibly too short on a
397
+ // long transcript before the rows mount.
398
+ estimateSize: () => (lines ? 32 : 100) + gap.px,
391
399
  overscan: 8,
392
400
  getItemKey: (index) => rows[index].key,
393
401
  // Explicit, and left at the default, because the obvious cleanup here is
@@ -484,13 +492,14 @@ function TranscriptRows({
484
492
  data-index={virtualRow.index}
485
493
  className={cn(
486
494
  'absolute inset-x-0 top-0',
487
- // The card layout's inter-row gap, folded into each row so the
488
- // measured height carries it: flex `gap` cannot reach absolutely
489
- // positioned rows, and a pixel constant for the virtualizer's
490
- // `gap` option would drift from the rem the layout is set in.
491
- // On this outer wrapper, not the row div, so a nested row's left
492
- // border still breaks across the gap as it did under flex.
493
- !lines && virtualRow.index > 0 && 'pt-4',
495
+ // The inter-row gap, folded into each row so the measured height
496
+ // carries it: flex `gap` cannot reach absolutely positioned rows,
497
+ // and a pixel constant for the virtualizer's `gap` option would
498
+ // drift from the rem the layout is set in. On this outer wrapper,
499
+ // not the row div, so a nested row's left border still breaks
500
+ // across the gap as it did under flex. Skipped for the first row —
501
+ // a gap above it would be padding, not spacing.
502
+ virtualRow.index > 0 && gap.className,
494
503
  )}
495
504
  style={{ transform: `translateY(${virtualRow.start}px)` }}>
496
505
  {'item' in row ? (
@@ -541,6 +550,12 @@ export interface TranscriptProps {
541
550
  * vertical space is scarce. See {@link TranscriptVariant}.
542
551
  */
543
552
  variant?: TranscriptVariant
553
+ /**
554
+ * How much air each row gets: `comfortable` (default — a blank line between
555
+ * messages, as the Claude Code CLI does) or `compact`. Independent of
556
+ * {@link TranscriptVariant}. See {@link TranscriptDensity}.
557
+ */
558
+ density?: TranscriptDensity
544
559
  /**
545
560
  * Catch-up: `from` is how many items had been seen last time, `since` when
546
561
  * that was. A recap row is drawn at that boundary and everything above it is
@@ -566,11 +581,13 @@ export function Transcript({
566
581
  canBrowseFiles,
567
582
  hostImage,
568
583
  variant = 'cards',
584
+ density = 'comfortable',
569
585
  catchUp,
570
586
  jumpToRecapRef,
571
587
  className,
572
588
  }: TranscriptProps) {
573
589
  const lines = variant === 'lines'
590
+ const gap = ROW_GAP[variant][density]
574
591
  const runStartedAt = useRunStart(state.status)
575
592
  const following = useSettled(state.items.length, state.status)
576
593
  // A boundary at (or past) the end means nothing is new — no row, no dimming.
@@ -605,6 +622,7 @@ export function Transcript({
605
622
  boundary={boundary}
606
623
  since={catchUp?.since}
607
624
  lines={lines}
625
+ gap={gap}
608
626
  fileUrl={fileUrl}
609
627
  attachmentUrl={attachmentUrl}
610
628
  hostImage={hostImage}
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * The brand mark's pulse, as characters — the working marker every surface in the
5
+ * transcript animates.
6
+ *
7
+ * These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
8
+ * state"): a dot, an outline, a semi and a full diamond, built in the SVG from
9
+ * two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
10
+ * clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
11
+ * medium a transcript row actually has.
12
+ *
13
+ * BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
14
+ * *ambiguous width*, so they can render double-width in a terminal under an
15
+ * East-Asian locale and shift every line with them. They are safe wherever the
16
+ * glyph is centred in a fixed-width box, which is what `LineGlyph` is. Anything
17
+ * writing to a real terminal must use the ASCII set instead.
18
+ */
19
+ export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
20
+ export const PULSE_MS = 150
21
+
22
+ /**
23
+ * The resting state. Stopping the animation lands on the complete mark rather
24
+ * than on a half-drawn frame — the same property that makes the SVG's
25
+ * `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
26
+ */
27
+ export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
28
+
29
+ /** The OS-level "stop moving things" setting. A spinner is decoration — the word
30
+ * beside it carries the meaning — so honouring this costs nothing. */
31
+ export function usePrefersReducedMotion(): boolean {
32
+ const [reduced, setReduced] = useState(false)
33
+ useEffect(() => {
34
+ const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
35
+ if (!query) return
36
+ setReduced(query.matches)
37
+ const onChange = () => setReduced(query.matches)
38
+ query.addEventListener('change', onChange)
39
+ return () => query.removeEventListener('change', onChange)
40
+ }, [])
41
+ return reduced
42
+ }
43
+
44
+ /**
45
+ * The current pulse frame, ticking while `animated`. Callers mount this only
46
+ * while something is actually in flight, so nothing here runs on an idle
47
+ * session; with reduced motion, or when not animating, it holds at rest.
48
+ */
49
+ export function usePulse(animated: boolean): string {
50
+ const reduced = usePrefersReducedMotion()
51
+ const running = animated && !reduced
52
+ const [frame, setFrame] = useState(0)
53
+ useEffect(() => {
54
+ if (!running) return
55
+ const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
56
+ return () => clearInterval(timer)
57
+ }, [running])
58
+ if (!running) return PULSE_REST
59
+ return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
60
+ }
@@ -39,6 +39,68 @@ export function useLines(): boolean {
39
39
  return useTranscriptVariant() === 'lines'
40
40
  }
41
41
 
42
+ /**
43
+ * How much room the transcript gives each row.
44
+ *
45
+ * - `comfortable` — a blank line between messages, which is what the Claude Code
46
+ * CLI does and what the `lines` variant is trying to read like. The default:
47
+ * a transcript is prose before it is a table.
48
+ * - `compact` — rows tight against each other, for a dock where every line of
49
+ * vertical space is contested.
50
+ *
51
+ * Separate from the variant, and deliberately: they answer different questions.
52
+ * The variant decides *how a row is drawn* (boxed or not) and follows from the
53
+ * surface; density decides *how much air is around it* and is a preference the
54
+ * reader holds. Coupling them would mean a dock could not be roomy and a
55
+ * dashboard could not be dense.
56
+ */
57
+ export type TranscriptDensity = 'comfortable' | 'compact'
58
+
59
+ const DensityContext = createContext<TranscriptDensity>('comfortable')
60
+
61
+ export function TranscriptDensityProvider({
62
+ value,
63
+ children,
64
+ }: {
65
+ value: TranscriptDensity
66
+ children: ReactNode
67
+ }) {
68
+ return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
69
+ }
70
+
71
+ export function useTranscriptDensity(): TranscriptDensity {
72
+ return useContext(DensityContext)
73
+ }
74
+
75
+ /**
76
+ * The gap between two rows, per variant and density — the whole of the density
77
+ * feature, since it is the only vertical spacing between rows that exists.
78
+ *
79
+ * `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
80
+ * part of each row's measured height and no pixel constant is load-bearing.
81
+ * `px` is fed to `estimateSize` alone, where being approximate is the contract:
82
+ * it sets the scrollbar's length before rows mount and is replaced by a real
83
+ * measurement the moment one does.
84
+ *
85
+ * `lines` + `compact` is the only combination with no gap at all: there the
86
+ * row's own `py-0.5` is the entire separation, which is what makes it compact.
87
+ */
88
+ export const ROW_GAP: Record<
89
+ TranscriptVariant,
90
+ Record<TranscriptDensity, { className?: string; px: number }>
91
+ > = {
92
+ cards: {
93
+ comfortable: { className: 'pt-4', px: 16 },
94
+ compact: { className: 'pt-2', px: 8 },
95
+ },
96
+ lines: {
97
+ // 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
98
+ // line the CLI leaves, arrived at from the line height rather than picked.
99
+ comfortable: { className: 'pt-4', px: 16 },
100
+ compact: { px: 0 },
101
+ },
102
+ }
103
+
42
104
  /**
43
105
  * The left gutter of a line item: one glyph, fixed width, so every row's text
44
106
  * starts on the same column no matter which kind of event it is. Decorative —
package/src/format.ts CHANGED
@@ -8,3 +8,4 @@
8
8
  * costs nothing and keeps one truth for what "45.2k" and "2h 10m" mean.
9
9
  */
10
10
  export * from './lib/format.ts'
11
+ export * from './lib/status.ts'
package/src/index.ts CHANGED
@@ -83,8 +83,11 @@ export {
83
83
  } from './components/agent/Conversation.tsx'
84
84
  export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
85
85
  export {
86
+ TranscriptDensityProvider,
86
87
  TranscriptVariantProvider,
88
+ useTranscriptDensity,
87
89
  useTranscriptVariant,
90
+ type TranscriptDensity,
88
91
  type TranscriptVariant,
89
92
  } from './components/agent/transcript-variant.tsx'
90
93
  export { Response, type ResponseProps } from './components/agent/Response.tsx'
@@ -133,6 +136,7 @@ export {
133
136
  type SessionListItemProps,
134
137
  type SessionListProps,
135
138
  } from './components/agent/SessionList.tsx'
139
+ export { SessionBrowser, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
136
140
  export {
137
141
  SessionEmptyState,
138
142
  type SessionEmptyStateProps,
@@ -0,0 +1,124 @@
1
+ import type { ContextUsage, ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'
2
+
3
+ /**
4
+ * How a session's live readings become a status line — the pure half, so every
5
+ * host spells "Needs approval", "80% is a warning" and "which window is the
6
+ * binding one" the same way.
7
+ *
8
+ * Structurally typed against `SessionVitals` rather than importing it: this file
9
+ * ships from the React-free `@workerdeck/ui/format` entry, and a host drawing
10
+ * the readings outside React (the VS Code extension host in the window status
11
+ * bar) must not pull a component graph in to do it. A real `SessionVitals`
12
+ * satisfies every shape here.
13
+ */
14
+ export type StatusSeverity = 'none' | 'warning' | 'error'
15
+
16
+ /** What a status slot shows, before any host's icon vocabulary gets involved.
17
+ * `icon` is a VS Code codicon name — the one host-shaped thing left, because the
18
+ * alternative is a second mapping table in the only consumer. */
19
+ export type StatusPresentation = {
20
+ icon: string
21
+ label: string
22
+ severity: StatusSeverity
23
+ }
24
+
25
+ export type StatusReadings = {
26
+ status: SessionStatus
27
+ /** `@workerdeck/react`'s `ConnectionState`, structurally — the link state wins
28
+ * the slot, so it has to be part of the reading. */
29
+ connection?: 'live' | 'reconnecting' | 'offline'
30
+ }
31
+
32
+ const STATUS_META: Record<SessionStatus, StatusPresentation> = {
33
+ starting: { icon: 'loading~spin', label: 'Starting', severity: 'none' },
34
+ running: { icon: 'loading~spin', label: 'Running', severity: 'none' },
35
+ awaiting_approval: { icon: 'warning', label: 'Needs approval', severity: 'warning' },
36
+ idle: { icon: 'check', label: 'Idle', severity: 'none' },
37
+ parked: { icon: 'debug-pause', label: 'Parked', severity: 'none' },
38
+ failed: { icon: 'error', label: 'Failed', severity: 'error' },
39
+ closed: { icon: 'circle-slash', label: 'Closed', severity: 'none' },
40
+ }
41
+
42
+ /**
43
+ * The status slot, connection first. A session status held over a dead socket is
44
+ * the last thing we heard, not the current state — so a lost link takes the slot
45
+ * rather than letting "Running" imply a turn is still streaming.
46
+ */
47
+ export function statusPresentation(vitals: StatusReadings | undefined): StatusPresentation {
48
+ if (!vitals) return { icon: 'hubot', label: 'Connecting…', severity: 'none' }
49
+ if (vitals.connection === 'offline') {
50
+ return { icon: 'debug-disconnect', label: 'Offline', severity: 'error' }
51
+ }
52
+ if (vitals.connection === 'reconnecting') {
53
+ return { icon: 'sync~spin', label: 'Reconnecting…', severity: 'warning' }
54
+ }
55
+ return STATUS_META[vitals.status] ?? { icon: 'hubot', label: vitals.status, severity: 'none' }
56
+ }
57
+
58
+ /** 0–100 → the colour a meter wears. One pair of thresholds for every surface. */
59
+ export function meterSeverity(pct: number | undefined): StatusSeverity {
60
+ if (pct === undefined) return 'none'
61
+ if (pct >= 95) return 'error'
62
+ if (pct >= 80) return 'warning'
63
+ return 'none'
64
+ }
65
+
66
+ /** The rate-limit window that gets the one visible slot: whichever is fullest,
67
+ * since the binding constraint is the one worth glancing at. */
68
+ export function tightestWindow(
69
+ rateLimits: Record<string, RateLimitInfo> | undefined,
70
+ ): { key: string; info: RateLimitInfo } | undefined {
71
+ const entries = Object.entries(rateLimits ?? {})
72
+ if (entries.length === 0) return undefined
73
+ let best: { key: string; info: RateLimitInfo } | undefined
74
+ for (const [key, info] of entries) {
75
+ // A rejected window outranks any utilization: it is the one actually blocking.
76
+ const rank = info.status === 'rejected' ? Number.POSITIVE_INFINITY : (info.utilization ?? -1)
77
+ const bestRank =
78
+ best === undefined
79
+ ? Number.NEGATIVE_INFINITY
80
+ : best.info.status === 'rejected'
81
+ ? Number.POSITIVE_INFINITY
82
+ : (best.info.utilization ?? -1)
83
+ if (rank > bestRank) best = { key, info }
84
+ }
85
+ return best
86
+ }
87
+
88
+ /** A rate-limit window's key, named for a human. */
89
+ export function windowLabel(key: string): string {
90
+ if (key === 'five_hour') return 'Session'
91
+ if (key === 'seven_day') return 'Weekly'
92
+ return key.replaceAll('_', ' ')
93
+ }
94
+
95
+ export type ModelReadings = { model?: string; models: readonly ModelOption[] }
96
+
97
+ /**
98
+ * The catalog row a session is actually running, or `undefined` for a model the
99
+ * list doesn't name. Matched leniently: a session reports the *resolved* id
100
+ * (`claude-sonnet-5`) where the row may be keyed on the alias (`sonnet`), and
101
+ * either can carry a `[1m]` context-window suffix.
102
+ */
103
+ export function currentModel(vitals: ModelReadings | undefined): ModelOption | undefined {
104
+ const id = vitals?.model
105
+ if (!id) return undefined
106
+ const bare = (value: string) => value.replace(/\[.*\]$/, '')
107
+ const wanted = bare(id)
108
+ return vitals.models.find(
109
+ (m) => bare(m.value) === wanted || (m.resolvedModel && bare(m.resolvedModel) === wanted),
110
+ )
111
+ }
112
+
113
+ /** A session's model, named the way the picker names it. Falls back to the raw
114
+ * id, and to "Default" while the session is on the CLI's own pick. */
115
+ export function modelLabel(vitals: ModelReadings | undefined): string {
116
+ if (!vitals?.model) return 'Default'
117
+ return currentModel(vitals)?.displayName ?? vitals.model
118
+ }
119
+
120
+ /** Context percentage as its meter severity — the reading and the colour come
121
+ * from one place so a panel and a status bar never disagree. */
122
+ export function contextSeverity(usage: ContextUsage | undefined): StatusSeverity {
123
+ return meterSeverity(usage?.percentage)
124
+ }