@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.
- package/build/{SessionPanel-CyhygZx_.d.mts → SessionPanel-Dy9lQrOV.d.mts} +44 -2
- package/build/{SessionPanel-_U8tjX29.mjs → SessionPanel-NQ8ksCfj.mjs} +280 -215
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- package/build/format.d.mts +65 -1
- package/build/format.mjs +118 -1
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +67 -7
- package/build/index.mjs +330 -5
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +28 -1
- package/build/workspace.mjs +14 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Loader.tsx +8 -46
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionPanel.tsx +28 -1
- package/src/components/agent/SessionWorkspace.tsx +35 -0
- package/src/components/agent/ToolCallCard.tsx +11 -2
- package/src/components/agent/Transcript.tsx +26 -8
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +62 -0
- package/src/format.ts +1 -0
- package/src/index.ts +4 -0
- package/src/lib/status.ts +124 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +0 -1
|
@@ -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
|
-
|
|
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
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
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
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
|
+
}
|