@workerdeck/ui 0.22.0 → 1.0.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-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +80 -24
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +9 -124
- package/build/workspace.mjs +13 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +4 -6
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +345 -923
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +108 -215
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +136 -19
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-13l25ubU.d.mts +0 -609
- package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -2,24 +2,15 @@ import { useEffect, useState } from 'react'
|
|
|
2
2
|
import type { UsageWindowRow } from '@workerdeck/protocol'
|
|
3
3
|
import { RotateCcw } from 'lucide-react'
|
|
4
4
|
import { cn } from '../../lib/utils.ts'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
formatCountdown,
|
|
8
|
-
formatRateLimitWindowLong,
|
|
9
|
-
rateLimitWindowSeconds,
|
|
10
|
-
} from '../../lib/format.ts'
|
|
5
|
+
import { formatAgoPrecise, formatCountdown, formatRateLimitWindowLong, rateLimitWindowSeconds } from '../../lib/format.ts'
|
|
6
|
+
import { meterTintClass } from '../../lib/status.ts'
|
|
11
7
|
|
|
12
|
-
/**
|
|
13
|
-
* Ticking clock — the countdowns and the pace markers both move with it, and a
|
|
14
|
-
* minute is the finest resolution either of them prints.
|
|
15
|
-
*
|
|
16
|
-
* `active` is what a dialog passes its `open`: a closed panel's clock is a timer
|
|
17
|
-
* re-rendering something nobody can see.
|
|
18
|
-
*/
|
|
19
8
|
export function useMinuteClock(active = true): number {
|
|
20
9
|
const [now, setNow] = useState(() => Date.now())
|
|
21
10
|
useEffect(() => {
|
|
22
|
-
if (!active)
|
|
11
|
+
if (!active) {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
23
14
|
setNow(Date.now())
|
|
24
15
|
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
25
16
|
return () => clearInterval(timer)
|
|
@@ -27,37 +18,7 @@ export function useMinuteClock(active = true): number {
|
|
|
27
18
|
return now
|
|
28
19
|
}
|
|
29
20
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The plan's rate-limit windows, spelled out: how much of each is used, how that
|
|
34
|
-
* compares to the pace that would spend the window exactly, and when it resets.
|
|
35
|
-
*
|
|
36
|
-
* The pace marker is the point. A bar alone says "17% used", which is only
|
|
37
|
-
* alarming or reassuring once you know how far into the week you are — so every
|
|
38
|
-
* window draws a tick at the elapsed share of its duration. Left of the tick is
|
|
39
|
-
* under budget, right of it is ahead of it. The duration comes from the window
|
|
40
|
-
* key (5h, 7d) because the engine reports a reset time and a percentage and
|
|
41
|
-
* never a duration; a window whose key doesn't say gets no marker rather than a
|
|
42
|
-
* guessed one.
|
|
43
|
-
*
|
|
44
|
-
* Its own component rather than the Usage dialog's private helper because two
|
|
45
|
-
* surfaces draw these now — the session panel's Usage panel, from the session's
|
|
46
|
-
* readings merged with the gateway's, and the dashboard's profile page, straight
|
|
47
|
-
* off `ProfileInfo.usage` with no session in sight. Same account, same bars.
|
|
48
|
-
*/
|
|
49
|
-
export function UsageMeters({
|
|
50
|
-
windows,
|
|
51
|
-
now,
|
|
52
|
-
className,
|
|
53
|
-
}: {
|
|
54
|
-
/** In reading order — protocol's `orderUsageWindows`. */
|
|
55
|
-
windows: UsageWindowRow[]
|
|
56
|
-
/** A shared clock, when the caller already ticks one. Omitted, this ticks its
|
|
57
|
-
* own for as long as it is mounted. */
|
|
58
|
-
now?: number
|
|
59
|
-
className?: string
|
|
60
|
-
}) {
|
|
21
|
+
export function UsageMeters({ windows, now, className }: { windows: UsageWindowRow[]; now?: number; className?: string }) {
|
|
61
22
|
const ownClock = useMinuteClock(now === undefined)
|
|
62
23
|
const clock = now ?? ownClock
|
|
63
24
|
return (
|
|
@@ -73,8 +34,6 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
73
34
|
const { key: windowKey, info, updatedAt, inferredReset } = window
|
|
74
35
|
const utilization = info.utilization ?? 0
|
|
75
36
|
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
76
|
-
// Share of the window already elapsed — where usage *would* be if it were
|
|
77
|
-
// spent evenly. Needs both a duration (from the key) and a reset time.
|
|
78
37
|
const duration = rateLimitWindowSeconds(windowKey)
|
|
79
38
|
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
80
39
|
const pace =
|
|
@@ -83,49 +42,35 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
83
42
|
: undefined
|
|
84
43
|
return (
|
|
85
44
|
<div>
|
|
86
|
-
<div className=
|
|
87
|
-
<span className=
|
|
88
|
-
|
|
89
|
-
</span>
|
|
90
|
-
<span
|
|
91
|
-
className={cn(
|
|
92
|
-
'shrink-0 font-mono text-body-sm font-medium',
|
|
93
|
-
info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
|
|
94
|
-
)}>
|
|
45
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
46
|
+
<span className="truncate text-body-sm text-fg-1">{formatRateLimitWindowLong(windowKey)}</span>
|
|
47
|
+
<span className={cn('shrink-0 font-mono text-body-sm font-medium', info.status === 'rejected' ? 'text-danger' : 'text-fg-1')}>
|
|
95
48
|
{utilization.toFixed(0)}% used
|
|
96
49
|
</span>
|
|
97
50
|
</div>
|
|
98
|
-
<div className=
|
|
51
|
+
<div className="relative mt-2 h-2 rounded-full bg-border">
|
|
99
52
|
<div
|
|
100
|
-
className={cn('h-full rounded-full',
|
|
101
|
-
// A floor, so a barely-touched window still shows a mark instead of
|
|
102
|
-
// reading as missing data.
|
|
53
|
+
className={cn('h-full rounded-full', meterTintClass(utilization))}
|
|
103
54
|
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
104
55
|
/>
|
|
105
56
|
{pace !== undefined ? (
|
|
106
57
|
<span
|
|
107
58
|
aria-hidden
|
|
108
|
-
title=
|
|
109
|
-
className=
|
|
59
|
+
title="Spent evenly, usage would be here by now"
|
|
60
|
+
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1"
|
|
110
61
|
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
111
62
|
/>
|
|
112
63
|
) : null}
|
|
113
64
|
</div>
|
|
114
|
-
<div className=
|
|
65
|
+
<div className="mt-1.5 flex items-center gap-3 text-label text-fg-4">
|
|
115
66
|
{resetsAtMs !== undefined ? (
|
|
116
|
-
<span className=
|
|
117
|
-
<RotateCcw className=
|
|
67
|
+
<span className="inline-flex items-center gap-1">
|
|
68
|
+
<RotateCcw className="size-3" /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
118
69
|
</span>
|
|
119
70
|
) : null}
|
|
120
|
-
{info.isUsingOverage ? <span className=
|
|
121
|
-
{info.status === 'rejected' ? <span className=
|
|
122
|
-
{/* Said plainly rather than drawn as a fact: the gateway zeroed this
|
|
123
|
-
because the reading's own reset time passed with nothing newer, so 0
|
|
124
|
-
is a floor — the account may have been spent elsewhere since. */}
|
|
71
|
+
{info.isUsingOverage ? <span className="text-warning">overage</span> : null}
|
|
72
|
+
{info.status === 'rejected' ? <span className="text-danger">limit reached</span> : null}
|
|
125
73
|
{inferredReset ? <span>window reset · nothing reported since</span> : null}
|
|
126
|
-
{/* Per window, because they no longer share one clock: a reading learned
|
|
127
|
-
from a sibling session minutes ago can sit beside one this session
|
|
128
|
-
last heard about yesterday. */}
|
|
129
74
|
{updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
|
|
130
75
|
</div>
|
|
131
76
|
</div>
|
|
@@ -1,39 +1,17 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
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 the terminal theme's
|
|
17
|
-
* gutter cell (`.term-gutter`, one `--term-cell` wide) is. Anything writing to a
|
|
18
|
-
* real terminal must use the ASCII set instead.
|
|
19
|
-
*/
|
|
20
3
|
export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
|
|
21
4
|
export const PULSE_MS = 150
|
|
22
5
|
|
|
23
|
-
/**
|
|
24
|
-
* The resting state. Stopping the animation lands on the complete mark rather
|
|
25
|
-
* than on a half-drawn frame — the same property that makes the SVG's
|
|
26
|
-
* `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
|
|
27
|
-
*/
|
|
28
6
|
export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
|
|
29
7
|
|
|
30
|
-
/** The OS-level "stop moving things" setting. A spinner is decoration — the word
|
|
31
|
-
* beside it carries the meaning — so honouring this costs nothing. */
|
|
32
8
|
export function usePrefersReducedMotion(): boolean {
|
|
33
9
|
const [reduced, setReduced] = useState(false)
|
|
34
10
|
useEffect(() => {
|
|
35
11
|
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
36
|
-
if (!query)
|
|
12
|
+
if (!query) {
|
|
13
|
+
return
|
|
14
|
+
}
|
|
37
15
|
setReduced(query.matches)
|
|
38
16
|
const onChange = () => setReduced(query.matches)
|
|
39
17
|
query.addEventListener('change', onChange)
|
|
@@ -42,20 +20,19 @@ export function usePrefersReducedMotion(): boolean {
|
|
|
42
20
|
return reduced
|
|
43
21
|
}
|
|
44
22
|
|
|
45
|
-
/**
|
|
46
|
-
* The current pulse frame, ticking while `animated`. Callers mount this only
|
|
47
|
-
* while something is actually in flight, so nothing here runs on an idle
|
|
48
|
-
* session; with reduced motion, or when not animating, it holds at rest.
|
|
49
|
-
*/
|
|
50
23
|
export function usePulse(animated: boolean): string {
|
|
51
24
|
const reduced = usePrefersReducedMotion()
|
|
52
25
|
const running = animated && !reduced
|
|
53
26
|
const [frame, setFrame] = useState(0)
|
|
54
27
|
useEffect(() => {
|
|
55
|
-
if (!running)
|
|
28
|
+
if (!running) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
56
31
|
const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
|
|
57
32
|
return () => clearInterval(timer)
|
|
58
33
|
}, [running])
|
|
59
|
-
if (!running)
|
|
34
|
+
if (!running) {
|
|
35
|
+
return PULSE_REST
|
|
36
|
+
}
|
|
60
37
|
return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
|
|
61
38
|
}
|
|
@@ -2,53 +2,15 @@ import type { TranscriptItem } from '@workerdeck/react'
|
|
|
2
2
|
import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
3
3
|
import { parentOf } from '../terminal/blocks.ts'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The scrubber's mark model, shared between the two rails.
|
|
7
|
-
*
|
|
8
|
-
* The terminal scrubber (`terminal/scrubber.tsx`) positions marks in **pixel
|
|
9
|
-
* space** — the virtualizer's row offsets, honest because the height calculator
|
|
10
|
-
* feeds `estimateSize`. The cards variant has no such claim (proportional text,
|
|
11
|
-
* variable row heights), so its rail (`agent/Scrubber.tsx`) positions marks in
|
|
12
|
-
* **index space**: `itemIndex / items.length` of the rail. Less precise, but it
|
|
13
|
-
* still answers the reader's questions — where did I type, where did it fail,
|
|
14
|
-
* where is the approval waiting.
|
|
15
|
-
*
|
|
16
|
-
* What is shared here is the *classification*: which items earn a mark, which
|
|
17
|
-
* lane a mark lives in, and who wins the colour when marks merge. The walk in
|
|
18
|
-
* {@link buildMarks} mirrors `buildClusters`'s first half (the segment
|
|
19
|
-
* machinery, sub-agent detection, bookmark/recap injection) minus everything
|
|
20
|
-
* that needs a row model. One deliberate divergence: the terminal marks a
|
|
21
|
-
* failed tool call only when it is its row's *outcome* (a fold's last member),
|
|
22
|
-
* because that is what its transcript reddens; cards folds nothing
|
|
23
|
-
* (`terminalBlocks` with `fold=false`), every failed top-level call reddens its
|
|
24
|
-
* own card, so every one marks — the same "the rail marks what the transcript
|
|
25
|
-
* reddens" rule, read against a surface with no folds.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
5
|
export type Lane = 'l' | 'r' | 'f'
|
|
29
|
-
export type MarkKind =
|
|
30
|
-
| 'user'
|
|
31
|
-
| 'subagent'
|
|
32
|
-
| 'turn'
|
|
33
|
-
| 'turnFailed'
|
|
34
|
-
| 'toolFailed'
|
|
35
|
-
| 'error'
|
|
36
|
-
| 'approval'
|
|
37
|
-
| 'recap'
|
|
38
|
-
| 'bookmark'
|
|
6
|
+
export type MarkKind = 'user' | 'subagent' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
39
7
|
|
|
40
8
|
export type Mark = {
|
|
41
9
|
kind: MarkKind
|
|
42
|
-
/** The jump anchor (for a turn mark: the paired response). */
|
|
43
10
|
itemIndex: number
|
|
44
|
-
/** The `turn_result` behind a right-lane mark — the peek shows its done-line. */
|
|
45
11
|
turnIndex?: number
|
|
46
12
|
}
|
|
47
13
|
|
|
48
|
-
/** Members keep their own y: a dense transcript chain-merges a lane into one
|
|
49
|
-
* tall bar, and the bar answers the pointer by its *nearest member* — a press
|
|
50
|
-
* or peek at the middle of the bar must not act on the mark that happened to
|
|
51
|
-
* found the cluster. */
|
|
52
14
|
export type Cluster = {
|
|
53
15
|
lane: Lane
|
|
54
16
|
kind: MarkKind
|
|
@@ -57,20 +19,16 @@ export type Cluster = {
|
|
|
57
19
|
marks: { mark: Mark; y: number }[]
|
|
58
20
|
}
|
|
59
21
|
|
|
60
|
-
/** The member closest to a rail-space y — what a press or peek on a merged
|
|
61
|
-
* cluster resolves to. */
|
|
62
22
|
export function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
63
23
|
let best: { mark: Mark; y: number } | undefined
|
|
64
|
-
for (const member of cluster.marks)
|
|
65
|
-
if (!best || Math.abs(member.y - y) < Math.abs(best.y - y))
|
|
24
|
+
for (const member of cluster.marks) {
|
|
25
|
+
if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) {
|
|
26
|
+
best = member
|
|
27
|
+
}
|
|
28
|
+
}
|
|
66
29
|
return best?.mark
|
|
67
30
|
}
|
|
68
31
|
|
|
69
|
-
/** The two lanes are channels, not classes: left is what went *in* (your
|
|
70
|
-
* prompts, the sub-agents you dispatched), right is what came *out* (each
|
|
71
|
-
* turn's answer, and everything that went wrong producing one). Full width is
|
|
72
|
-
* for what is not a channel at all: the waiting approval, a bookmark, the
|
|
73
|
-
* catch-up seam. See the terminal scrubber for the full argument. */
|
|
74
32
|
export const LANE: Record<MarkKind, Lane> = {
|
|
75
33
|
user: 'l',
|
|
76
34
|
subagent: 'l',
|
|
@@ -83,7 +41,6 @@ export const LANE: Record<MarkKind, Lane> = {
|
|
|
83
41
|
bookmark: 'f',
|
|
84
42
|
}
|
|
85
43
|
|
|
86
|
-
/** Who wins the colour when marks merge. */
|
|
87
44
|
export const LOUDNESS: Record<MarkKind, number> = {
|
|
88
45
|
approval: 7,
|
|
89
46
|
error: 6,
|
|
@@ -108,48 +65,40 @@ export const KIND_NAME: Record<MarkKind, string> = {
|
|
|
108
65
|
bookmark: 'bookmark',
|
|
109
66
|
}
|
|
110
67
|
|
|
111
|
-
/** The floor: an index-proportional mark has no extent to draw, so this is
|
|
112
|
-
* usually the height too — 2px keeps a tick findable. */
|
|
113
68
|
export const MIN_MARK = 2
|
|
114
69
|
|
|
115
|
-
export
|
|
116
|
-
`${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
70
|
+
export function doneLine(turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string {
|
|
71
|
+
return `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
72
|
+
}
|
|
117
73
|
|
|
118
74
|
export function excerpt(item: TranscriptItem): string {
|
|
119
75
|
switch (item.kind) {
|
|
120
76
|
case 'user':
|
|
121
77
|
case 'assistant_text':
|
|
122
78
|
case 'thinking':
|
|
123
|
-
case 'notice':
|
|
79
|
+
case 'notice': {
|
|
124
80
|
return item.text
|
|
125
|
-
|
|
81
|
+
}
|
|
82
|
+
case 'tool_call': {
|
|
126
83
|
return `${item.name}(${toolInputPreview(item.input)})`
|
|
127
|
-
|
|
84
|
+
}
|
|
85
|
+
case 'turn_result': {
|
|
128
86
|
return doneLine(item)
|
|
129
|
-
|
|
87
|
+
}
|
|
88
|
+
case 'file_delivered': {
|
|
130
89
|
return item.path
|
|
131
|
-
|
|
90
|
+
}
|
|
91
|
+
default: {
|
|
132
92
|
return ''
|
|
93
|
+
}
|
|
133
94
|
}
|
|
134
95
|
}
|
|
135
96
|
|
|
136
|
-
/** One right-lane mark per segment, emitted when the segment closes — by the
|
|
137
|
-
* next prompt, by its own turn end, or by running out of items (which is what
|
|
138
|
-
* a replayed history is made of, since `#backfillHistory` carries no turn
|
|
139
|
-
* rows). A `turn_result` *decorates* the answer rather than conjuring the
|
|
140
|
-
* mark, so a live answer with no turn end yet is still on the rail. */
|
|
141
97
|
type Segment = { response?: number; turn?: number; failed?: boolean }
|
|
142
98
|
|
|
143
99
|
export interface BuildMarksOptions {
|
|
144
|
-
/** The sub-agent takeover's parent id, when the rail belongs to a frame — it
|
|
145
|
-
* is what "top level" means here (`undefined` at the conversation's own
|
|
146
|
-
* level). Inside a frame every narration step is its own mark: the stream
|
|
147
|
-
* carries no prompts and no `turn_result` for the segment machinery to work
|
|
148
|
-
* with, and a fifty-step agent run is exactly where a rail earns its keep. */
|
|
149
100
|
frameParentId?: string
|
|
150
|
-
/** Bookmarked item indices. Paint only — the store is the client's. */
|
|
151
101
|
bookmarks?: readonly number[]
|
|
152
|
-
/** The catch-up boundary's item index, when the recap is spliced in. */
|
|
153
102
|
recapItemIndex?: number
|
|
154
103
|
}
|
|
155
104
|
|
|
@@ -158,14 +107,12 @@ export function buildMarks(
|
|
|
158
107
|
{ frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
|
|
159
108
|
): Mark[] {
|
|
160
109
|
const marks: Mark[] = []
|
|
161
|
-
// Which top-level calls a sub-agent ran inside — by `parentToolUseId`, never
|
|
162
|
-
// by the spawning call's *name*: `Task` is the SDK's convention (a background
|
|
163
|
-
// agent arrives as `Agent`), and an id other items demonstrably nest under IS
|
|
164
|
-
// a sub-agent whatever spawned it.
|
|
165
110
|
const subagentParents = new Set<string>()
|
|
166
111
|
for (const item of items) {
|
|
167
112
|
const parent = parentOf(item)
|
|
168
|
-
if (parent !== undefined)
|
|
113
|
+
if (parent !== undefined) {
|
|
114
|
+
subagentParents.add(parent)
|
|
115
|
+
}
|
|
169
116
|
}
|
|
170
117
|
let segment: Segment = {}
|
|
171
118
|
const closeSegment = () => {
|
|
@@ -180,16 +127,9 @@ export function buildMarks(
|
|
|
180
127
|
segment = {}
|
|
181
128
|
}
|
|
182
129
|
items.forEach((item, index) => {
|
|
183
|
-
// The dispatch itself. Deliberately not part of the chain below: a `Task`
|
|
184
|
-
// whose own result errored earns a red tick in the response lane *and*
|
|
185
|
-
// this mark in the input lane — one says a sub-agent ran here, the other
|
|
186
|
-
// says it came back broken.
|
|
187
130
|
if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
|
|
188
131
|
marks.push({ kind: 'subagent', itemIndex: index })
|
|
189
132
|
}
|
|
190
|
-
// Top-level prompts only, like the answer check below: a sub-agent's brief
|
|
191
|
-
// is a `user` item too, and it would both paint a "you" mark for something
|
|
192
|
-
// nobody typed and close the segment mid-turn.
|
|
193
133
|
if (item.kind === 'user' && parentOf(item) === frameParentId) {
|
|
194
134
|
closeSegment()
|
|
195
135
|
marks.push({ kind: 'user', itemIndex: index })
|
|
@@ -200,11 +140,6 @@ export function buildMarks(
|
|
|
200
140
|
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
201
141
|
marks.push({ kind: 'error', itemIndex: index })
|
|
202
142
|
} else if (
|
|
203
|
-
// Both spellings are needed: an out-of-loop execution failure sets
|
|
204
|
-
// `status` with no `is_error` block to read, and an engine can flag
|
|
205
|
-
// `is_error` on a call this reducer has not settled yet. Top level only
|
|
206
|
-
// — a sub-agent's failed child is represented by the sub-agent mark and
|
|
207
|
-
// the Task's own red tick, exactly as the terminal rail does.
|
|
208
143
|
item.kind === 'tool_call' &&
|
|
209
144
|
parentOf(item) === frameParentId &&
|
|
210
145
|
(item.status === 'failed' || item.result?.isError === true)
|
|
@@ -218,20 +153,18 @@ export function buildMarks(
|
|
|
218
153
|
segment.response = index
|
|
219
154
|
}
|
|
220
155
|
})
|
|
221
|
-
// A history that ends mid-segment still has an answer in it.
|
|
222
156
|
closeSegment()
|
|
223
|
-
for (const index of bookmarks)
|
|
224
|
-
if (index >= 0 && index < items.length)
|
|
225
|
-
|
|
157
|
+
for (const index of bookmarks) {
|
|
158
|
+
if (index >= 0 && index < items.length) {
|
|
159
|
+
marks.push({ kind: 'bookmark', itemIndex: index })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (recapItemIndex !== undefined) {
|
|
163
|
+
marks.push({ kind: 'recap', itemIndex: recapItemIndex })
|
|
164
|
+
}
|
|
226
165
|
return marks
|
|
227
166
|
}
|
|
228
167
|
|
|
229
|
-
/**
|
|
230
|
-
* Place marks proportionally — y = `itemIndex / itemCount` of the rail, every
|
|
231
|
-
* mark one item's share tall (floored at {@link MIN_MARK}) — then merge
|
|
232
|
-
* adjacent ones per lane, the loudest colour winning. The same merge rule as
|
|
233
|
-
* the terminal rail; only the position source differs.
|
|
234
|
-
*/
|
|
235
168
|
export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
|
|
236
169
|
const count = Math.max(1, itemCount)
|
|
237
170
|
const h = Math.max(MIN_MARK, Math.round(railH / count))
|
|
@@ -248,11 +181,11 @@ export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: n
|
|
|
248
181
|
list.sort((a, b) => a.y - b.y)
|
|
249
182
|
let current: Cluster | null = null
|
|
250
183
|
for (const { mark, y } of list) {
|
|
251
|
-
// Merge when the gap is under a pixel; the merged mark grows and takes
|
|
252
|
-
// the loudest member's colour.
|
|
253
184
|
if (current && y <= current.y + current.h + 1) {
|
|
254
185
|
current.h = Math.max(current.h, y + h - current.y)
|
|
255
|
-
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind])
|
|
186
|
+
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {
|
|
187
|
+
current.kind = mark.kind
|
|
188
|
+
}
|
|
256
189
|
current.marks.push({ mark, y })
|
|
257
190
|
} else {
|
|
258
191
|
current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
|
|
@@ -263,9 +196,6 @@ export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: n
|
|
|
263
196
|
return clusters
|
|
264
197
|
}
|
|
265
198
|
|
|
266
|
-
/** The approval is not an item — the prompt renders below the transcript — so
|
|
267
|
-
* its mark pins at the rail's foot, where the prompt is. Built by hand (no
|
|
268
|
-
* item to derive a position from), hence `marks: []`. */
|
|
269
199
|
export function approvalCluster(railH: number): Cluster {
|
|
270
200
|
return {
|
|
271
201
|
lane: LANE.approval,
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { SessionStatus } from '@workerdeck/protocol'
|
|
2
2
|
import type { BadgeProps } from '../ui/Badge.tsx'
|
|
3
3
|
|
|
4
|
-
export const STATUS_META: Record<
|
|
5
|
-
SessionStatus,
|
|
6
|
-
{ label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }
|
|
7
|
-
> = {
|
|
4
|
+
export const STATUS_META: Record<SessionStatus, { label: string; variant: NonNullable<BadgeProps['variant']>; busy: boolean }> = {
|
|
8
5
|
starting: { label: 'Starting', variant: 'info', busy: true },
|
|
9
6
|
running: { label: 'Running', variant: 'info', busy: true },
|
|
10
7
|
awaiting_approval: { label: 'Needs approval', variant: 'warning', busy: true },
|
|
11
8
|
idle: { label: 'Idle', variant: 'success', busy: false },
|
|
12
|
-
// Waiting on a deferred execution: the run is alive but nothing is burning.
|
|
13
9
|
parked: { label: 'Parked', variant: 'accent', busy: false },
|
|
14
10
|
failed: { label: 'Failed', variant: 'danger', busy: false },
|
|
15
11
|
closed: { label: 'Closed', variant: 'neutral', busy: false },
|
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* How a row gets back the part of a tool result the replay did not send.
|
|
5
|
-
*
|
|
6
|
-
* A **context**, not a prop chain, for the same reason the variant is one: the
|
|
7
|
-
* rows that need it are drawn by `terminalBlocks` and by the cards theme's
|
|
8
|
-
* `ToolCallCard`, several layers below whoever holds the session, and a row
|
|
9
|
-
* composed by hand should get the same behaviour without threading a callback
|
|
10
|
-
* through everything in between.
|
|
11
|
-
*
|
|
12
|
-
* The default is a no-op resolving `false`, which is exactly right for every
|
|
13
|
-
* surface that never asked for truncation (the playground, a fixture, an
|
|
14
|
-
* embedder rendering rows by hand): `result.truncated` is only ever set by a
|
|
15
|
-
* replay a renderer opted into, so a row that has no fetcher also has no head to
|
|
16
|
-
* complete. A press still opens the row; it simply has everything already.
|
|
17
|
-
*/
|
|
18
3
|
export type ToolResultFetcher = (toolUseId: string) => Promise<boolean>
|
|
19
4
|
|
|
20
|
-
const
|
|
5
|
+
const noop: ToolResultFetcher = async () => false
|
|
6
|
+
const FetchContext = createContext<ToolResultFetcher>(noop)
|
|
21
7
|
|
|
22
|
-
export function ToolResultFetchProvider({
|
|
23
|
-
value,
|
|
24
|
-
children,
|
|
25
|
-
}: {
|
|
26
|
-
value: ToolResultFetcher | undefined
|
|
27
|
-
children: ReactNode
|
|
28
|
-
}) {
|
|
8
|
+
export function ToolResultFetchProvider({ value, children }: { value: ToolResultFetcher | undefined; children: ReactNode }) {
|
|
29
9
|
return <FetchContext.Provider value={value ?? noop}>{children}</FetchContext.Provider>
|
|
30
10
|
}
|
|
31
11
|
|
|
32
|
-
const noop: ToolResultFetcher = async () => false
|
|
33
|
-
|
|
34
12
|
export function useToolResultFetcher(): ToolResultFetcher {
|
|
35
13
|
return useContext(FetchContext)
|
|
36
14
|
}
|