@workerdeck/ui 0.15.0 → 0.17.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/README.md +60 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +493 -45
- package/build/index.mjs +343 -17
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +249 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +85 -112
- package/src/components/agent/Transcript.tsx +780 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +173 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +225 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +770 -0
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +486 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +86 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +784 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +224 -0
- package/src/index.ts +36 -1
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1081 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -1,56 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
|
|
3
|
-
import { RotateCcw } from 'lucide-react'
|
|
1
|
+
import type { ProfileEngine, UsageWindowRow } from '@workerdeck/protocol'
|
|
4
2
|
import { Badge } from '../ui/Badge.tsx'
|
|
5
3
|
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
formatAgoPrecise,
|
|
9
|
-
formatCost,
|
|
10
|
-
formatCountdown,
|
|
11
|
-
formatRateLimitWindowLong,
|
|
12
|
-
rateLimitWindowSeconds,
|
|
13
|
-
} from '../../lib/format.ts'
|
|
4
|
+
import { formatAgoPrecise, formatCost } from '../../lib/format.ts'
|
|
5
|
+
import { UsageMeters, useMinuteClock } from './UsageMeters.tsx'
|
|
14
6
|
|
|
15
7
|
export interface UsageDialogProps {
|
|
16
|
-
/**
|
|
17
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Windows in reading order — session, weekly, then per-model weeklies.
|
|
10
|
+
*
|
|
11
|
+
* `updatedAt`/`inferredReset` ride each window because the readings no longer
|
|
12
|
+
* share one clock: the panel merges the gateway's per-profile state over this
|
|
13
|
+
* session's, so a `five_hour` learned from a sibling session two minutes ago
|
|
14
|
+
* can sit beside a `seven_day` this session last heard about yesterday.
|
|
15
|
+
* `inferredReset` marks the ones the *gateway* zeroed because their own reset
|
|
16
|
+
* time passed with nothing newer — a floor, not a report.
|
|
17
|
+
*/
|
|
18
|
+
rateLimits: UsageWindowRow[]
|
|
18
19
|
/** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
|
|
19
20
|
subscriptionType?: string
|
|
20
21
|
engine: ProfileEngine
|
|
21
22
|
totalCostUsd: number
|
|
22
|
-
/**
|
|
23
|
-
* per turn at best
|
|
23
|
+
/** When the *freshest* of the windows was read, for the footer line. Readings
|
|
24
|
+
* arrive one per turn at best (and for an idle session, not at all), so a
|
|
25
|
+
* stale one is normal and worth saying out loud. */
|
|
24
26
|
updatedAt?: number
|
|
25
27
|
open: boolean
|
|
26
28
|
onOpenChange: (open: boolean) => void
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/** Ticking clock — the countdowns and the pace markers both move with it, and a
|
|
30
|
-
* minute is the finest resolution either of them prints. */
|
|
31
|
-
function useMinuteClock(open: boolean): number {
|
|
32
|
-
const [now, setNow] = useState(() => Date.now())
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!open) return
|
|
35
|
-
setNow(Date.now())
|
|
36
|
-
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
37
|
-
return () => clearInterval(timer)
|
|
38
|
-
}, [open])
|
|
39
|
-
return now
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
43
|
-
|
|
44
31
|
/**
|
|
45
|
-
* The
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* The pace marker is the point. A bar alone says "17% used", which is only
|
|
49
|
-
* alarming or reassuring once you know how far into the week you are — so every
|
|
50
|
-
* window draws a tick at the elapsed share of its duration. Left of the tick is
|
|
51
|
-
* under budget, right of it is ahead of it. The duration comes from the window
|
|
52
|
-
* key (5h, 7d) because the CLI reports a reset time and a percentage and never a
|
|
53
|
-
* duration; a window whose key doesn't say gets no marker rather than a guessed one.
|
|
32
|
+
* The session's view of the plan: every window as a meter (see
|
|
33
|
+
* {@link UsageMeters}), the plan tier, and what this session itself has cost.
|
|
54
34
|
*/
|
|
55
35
|
export function UsageDialog({
|
|
56
36
|
rateLimits,
|
|
@@ -86,11 +66,7 @@ export function UsageDialog({
|
|
|
86
66
|
: `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
|
|
87
67
|
</p>
|
|
88
68
|
) : (
|
|
89
|
-
<
|
|
90
|
-
{rateLimits.map(({ key, info }) => (
|
|
91
|
-
<UsageWindow key={key} windowKey={key} info={info} now={now} />
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
69
|
+
<UsageMeters windows={rateLimits} now={now} />
|
|
94
70
|
)}
|
|
95
71
|
<div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
|
|
96
72
|
<span className='text-label text-fg-3'>This session has cost</span>
|
|
@@ -104,65 +80,3 @@ export function UsageDialog({
|
|
|
104
80
|
</Dialog>
|
|
105
81
|
)
|
|
106
82
|
}
|
|
107
|
-
|
|
108
|
-
function UsageWindow({
|
|
109
|
-
windowKey,
|
|
110
|
-
info,
|
|
111
|
-
now,
|
|
112
|
-
}: {
|
|
113
|
-
windowKey: string
|
|
114
|
-
info: RateLimitInfo
|
|
115
|
-
now: number
|
|
116
|
-
}) {
|
|
117
|
-
const utilization = info.utilization ?? 0
|
|
118
|
-
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
119
|
-
// Share of the window already elapsed — where usage *would* be if it were
|
|
120
|
-
// spent evenly. Needs both a duration (from the key) and a reset time.
|
|
121
|
-
const duration = rateLimitWindowSeconds(windowKey)
|
|
122
|
-
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
123
|
-
const pace =
|
|
124
|
-
duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
|
|
125
|
-
? (duration - remaining) / duration
|
|
126
|
-
: undefined
|
|
127
|
-
return (
|
|
128
|
-
<div>
|
|
129
|
-
<div className='flex items-baseline justify-between gap-3'>
|
|
130
|
-
<span className='truncate text-body-sm text-fg-1'>
|
|
131
|
-
{formatRateLimitWindowLong(windowKey)}
|
|
132
|
-
</span>
|
|
133
|
-
<span
|
|
134
|
-
className={cn(
|
|
135
|
-
'shrink-0 font-mono text-body-sm font-medium',
|
|
136
|
-
info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
|
|
137
|
-
)}>
|
|
138
|
-
{utilization.toFixed(0)}% used
|
|
139
|
-
</span>
|
|
140
|
-
</div>
|
|
141
|
-
<div className='relative mt-2 h-2 rounded-full bg-border'>
|
|
142
|
-
<div
|
|
143
|
-
className={cn('h-full rounded-full', usageTint(utilization))}
|
|
144
|
-
// A floor, so a barely-touched window still shows a mark instead of
|
|
145
|
-
// reading as missing data.
|
|
146
|
-
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
147
|
-
/>
|
|
148
|
-
{pace !== undefined ? (
|
|
149
|
-
<span
|
|
150
|
-
aria-hidden
|
|
151
|
-
title='Spent evenly, usage would be here by now'
|
|
152
|
-
className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
|
|
153
|
-
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
154
|
-
/>
|
|
155
|
-
) : null}
|
|
156
|
-
</div>
|
|
157
|
-
<div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
|
|
158
|
-
{resetsAtMs !== undefined ? (
|
|
159
|
-
<span className='inline-flex items-center gap-1'>
|
|
160
|
-
<RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
161
|
-
</span>
|
|
162
|
-
) : null}
|
|
163
|
-
{info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
|
|
164
|
-
{info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
)
|
|
168
|
-
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import type { UsageWindowRow } from '@workerdeck/protocol'
|
|
3
|
+
import { RotateCcw } from 'lucide-react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import {
|
|
6
|
+
formatAgoPrecise,
|
|
7
|
+
formatCountdown,
|
|
8
|
+
formatRateLimitWindowLong,
|
|
9
|
+
rateLimitWindowSeconds,
|
|
10
|
+
} from '../../lib/format.ts'
|
|
11
|
+
|
|
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
|
+
export function useMinuteClock(active = true): number {
|
|
20
|
+
const [now, setNow] = useState(() => Date.now())
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!active) return
|
|
23
|
+
setNow(Date.now())
|
|
24
|
+
const timer = setInterval(() => setNow(Date.now()), 60_000)
|
|
25
|
+
return () => clearInterval(timer)
|
|
26
|
+
}, [active])
|
|
27
|
+
return now
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
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
|
+
}) {
|
|
61
|
+
const ownClock = useMinuteClock(now === undefined)
|
|
62
|
+
const clock = now ?? ownClock
|
|
63
|
+
return (
|
|
64
|
+
<div className={cn('flex flex-col gap-5', className)}>
|
|
65
|
+
{windows.map((window) => (
|
|
66
|
+
<UsageMeter key={window.key} window={window} now={clock} />
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
73
|
+
const { key: windowKey, info, updatedAt, inferredReset } = window
|
|
74
|
+
const utilization = info.utilization ?? 0
|
|
75
|
+
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
|
+
const duration = rateLimitWindowSeconds(windowKey)
|
|
79
|
+
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
80
|
+
const pace =
|
|
81
|
+
duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
|
|
82
|
+
? (duration - remaining) / duration
|
|
83
|
+
: undefined
|
|
84
|
+
return (
|
|
85
|
+
<div>
|
|
86
|
+
<div className='flex items-baseline justify-between gap-3'>
|
|
87
|
+
<span className='truncate text-body-sm text-fg-1'>
|
|
88
|
+
{formatRateLimitWindowLong(windowKey)}
|
|
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
|
+
)}>
|
|
95
|
+
{utilization.toFixed(0)}% used
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div className='relative mt-2 h-2 rounded-full bg-border'>
|
|
99
|
+
<div
|
|
100
|
+
className={cn('h-full rounded-full', usageTint(utilization))}
|
|
101
|
+
// A floor, so a barely-touched window still shows a mark instead of
|
|
102
|
+
// reading as missing data.
|
|
103
|
+
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
104
|
+
/>
|
|
105
|
+
{pace !== undefined ? (
|
|
106
|
+
<span
|
|
107
|
+
aria-hidden
|
|
108
|
+
title='Spent evenly, usage would be here by now'
|
|
109
|
+
className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
|
|
110
|
+
style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
|
|
111
|
+
/>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
<div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
|
|
115
|
+
{resetsAtMs !== undefined ? (
|
|
116
|
+
<span className='inline-flex items-center gap-1'>
|
|
117
|
+
<RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
|
|
118
|
+
</span>
|
|
119
|
+
) : null}
|
|
120
|
+
{info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
|
|
121
|
+
{info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
|
|
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. */}
|
|
125
|
+
{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
|
+
{updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -13,8 +13,9 @@ import { useEffect, useState } from 'react'
|
|
|
13
13
|
* BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
|
|
14
14
|
* *ambiguous width*, so they can render double-width in a terminal under an
|
|
15
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
|
|
17
|
-
*
|
|
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.
|
|
18
19
|
*/
|
|
19
20
|
export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
|
|
20
21
|
export const PULSE_MS = 150
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
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
|
+
export type ToolResultFetcher = (toolUseId: string) => Promise<boolean>
|
|
19
|
+
|
|
20
|
+
const FetchContext = createContext<ToolResultFetcher>(async () => false)
|
|
21
|
+
|
|
22
|
+
export function ToolResultFetchProvider({
|
|
23
|
+
value,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
value: ToolResultFetcher | undefined
|
|
27
|
+
children: ReactNode
|
|
28
|
+
}) {
|
|
29
|
+
return <FetchContext.Provider value={value ?? noop}>{children}</FetchContext.Provider>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const noop: ToolResultFetcher = async () => false
|
|
33
|
+
|
|
34
|
+
export function useToolResultFetcher(): ToolResultFetcher {
|
|
35
|
+
return useContext(FetchContext)
|
|
36
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How a row gets the picture the replay refused to send.
|
|
14
|
+
*
|
|
15
|
+
* The sibling of `tool-result-fetch.tsx` and the same shape of seam, because it
|
|
16
|
+
* answers the same shape of question: an opted-in socket delivers a base64
|
|
17
|
+
* `image` part as an `image_ref` — media type, decoded size, and its index in
|
|
18
|
+
* the stored block — and the bytes are fetched over REST by whoever is actually
|
|
19
|
+
* looking at the row. Across a measured corpus that payload was 91% of every
|
|
20
|
+
* tool result and nothing rendered a byte of it.
|
|
21
|
+
*
|
|
22
|
+
* A **context**, not a prop chain, for the variant's reason: the rows are drawn
|
|
23
|
+
* by `terminalBlocks` and by `ToolCallCard`, several layers under whoever holds
|
|
24
|
+
* the session. The default resolves `undefined`, which is exactly right for
|
|
25
|
+
* every surface that never asked (the playground, a fixture, a hand-composed
|
|
26
|
+
* row): `result.images` is only ever set by a replay a renderer opted into, so a
|
|
27
|
+
* row with no loader also has no reference to load. Only `SessionPanel` supplies
|
|
28
|
+
* a real one, because it owns the session's one attach and therefore the only
|
|
29
|
+
* `(seq, toolUseId)` addresses that mean anything.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** One image part, as the row addresses it: the reducer's entry plus the id of
|
|
33
|
+
* the call it came back from. `sourceSeq` is the entry's **own** — the
|
|
34
|
+
* result-level one is cleared by text hydration, and a reader who pressed "show
|
|
35
|
+
* everything" must still be able to load the screenshot afterwards. */
|
|
36
|
+
export type ToolResultImageRef = {
|
|
37
|
+
toolUseId: string
|
|
38
|
+
sourceSeq: number
|
|
39
|
+
partIndex: number
|
|
40
|
+
mediaType: string
|
|
41
|
+
bytes: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolves an object URL for the picture, or `undefined` when the gateway will
|
|
45
|
+
* not serve it — a stale address after a dormant wake, a gateway with no such
|
|
46
|
+
* route, a dropped connection. The row draws a box either way. */
|
|
47
|
+
export type ToolResultImageLoader = (ref: ToolResultImageRef) => Promise<string | undefined>
|
|
48
|
+
|
|
49
|
+
const noop: ToolResultImageLoader = async () => undefined
|
|
50
|
+
|
|
51
|
+
const ImageContext = createContext<ToolResultImageLoader>(noop)
|
|
52
|
+
|
|
53
|
+
export function ToolResultImageProvider({
|
|
54
|
+
value,
|
|
55
|
+
children,
|
|
56
|
+
}: {
|
|
57
|
+
value: ToolResultImageLoader | undefined
|
|
58
|
+
children: ReactNode
|
|
59
|
+
}) {
|
|
60
|
+
return <ImageContext.Provider value={value ?? noop}>{children}</ImageContext.Provider>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useToolResultImageLoader(): ToolResultImageLoader {
|
|
64
|
+
return useContext(ImageContext)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Long enough that a fast scrub through an image-heavy session fetches nothing
|
|
69
|
+
* it flew past, short enough to be invisible to a reader who stopped.
|
|
70
|
+
*
|
|
71
|
+
* There is no second visibility system here on purpose: the transcript is
|
|
72
|
+
* virtualized, so a *mounted* row is by definition within an overscan of the
|
|
73
|
+
* viewport — the virtualizer already is the IntersectionObserver, and a second
|
|
74
|
+
* answer to a question that has one is how the two disagree.
|
|
75
|
+
*/
|
|
76
|
+
const MOUNT_SETTLE_MS = 150
|
|
77
|
+
|
|
78
|
+
export type ToolResultImageState = { src?: string; failed: boolean }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One box's load, for either theme.
|
|
82
|
+
*
|
|
83
|
+
* Fires once the row has been mounted for {@link MOUNT_SETTLE_MS}, and then
|
|
84
|
+
* **runs to completion** — an aborted fetch re-pays the whole image on the
|
|
85
|
+
* return visit, and the gateway is HTTP/1.1, so the browser's per-origin
|
|
86
|
+
* connection cap is the concurrency throttle for free.
|
|
87
|
+
*
|
|
88
|
+
* The effect keys on the address's *primitives*, never on the ref object: the
|
|
89
|
+
* reducer replaces items on every streamed delta, so an object-identity dep
|
|
90
|
+
* would re-run this on every token of the turn after it.
|
|
91
|
+
*/
|
|
92
|
+
export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageState {
|
|
93
|
+
const load = useToolResultImageLoader()
|
|
94
|
+
const [state, setState] = useState<ToolResultImageState>({ failed: false })
|
|
95
|
+
const { toolUseId, sourceSeq, partIndex, mediaType, bytes } = ref
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let live = true
|
|
98
|
+
setState({ failed: false })
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
load({ toolUseId, sourceSeq, partIndex, mediaType, bytes })
|
|
101
|
+
.then((src) => {
|
|
102
|
+
if (live) setState({ src, failed: src === undefined })
|
|
103
|
+
})
|
|
104
|
+
.catch(() => {
|
|
105
|
+
if (live) setState({ failed: true })
|
|
106
|
+
})
|
|
107
|
+
}, MOUNT_SETTLE_MS)
|
|
108
|
+
return () => {
|
|
109
|
+
live = false
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
}
|
|
112
|
+
}, [load, toolUseId, sourceSeq, partIndex, mediaType, bytes])
|
|
113
|
+
return state
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** ~64 MB of decoded pictures held at once. At the corpus's 335 KB median that
|
|
117
|
+
* is ~190 images, which no viewport holds; the budget exists so a session
|
|
118
|
+
* scrolled end to end does not pin every screenshot it passed. */
|
|
119
|
+
const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
|
|
120
|
+
|
|
121
|
+
type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `useHostImage`'s shape, generalized to the replay route — and **bounded**,
|
|
125
|
+
* which `useHostImage` is not.
|
|
126
|
+
*
|
|
127
|
+
* The promise-per-key cache is what makes this callable from a transcript row at
|
|
128
|
+
* all: rows re-render on every streamed delta, and an uncached resolver would
|
|
129
|
+
* re-fetch each time. The LRU is the part that is new. Object URLs pin their
|
|
130
|
+
* blob until revoked, so a fully-scrolled hundred-image session would otherwise
|
|
131
|
+
* hold ~50 MB until the panel unmounted — and evicting means revoking, or the
|
|
132
|
+
* eviction frees a `Map` entry and nothing else.
|
|
133
|
+
*
|
|
134
|
+
* Re-fetching on a return scroll is fine, and is the whole design: the bytes are
|
|
135
|
+
* one authenticated request away, which is precisely what makes it cheap not to
|
|
136
|
+
* have shipped them in the attach.
|
|
137
|
+
*/
|
|
138
|
+
export function useToolResultImages(
|
|
139
|
+
client: WorkerDeckClient,
|
|
140
|
+
sessionId: string | undefined,
|
|
141
|
+
): ToolResultImageLoader {
|
|
142
|
+
const cache = useRef(new Map<string, Entry>())
|
|
143
|
+
useEffect(
|
|
144
|
+
() => () => {
|
|
145
|
+
for (const entry of cache.current.values()) if (entry.url) URL.revokeObjectURL(entry.url)
|
|
146
|
+
cache.current.clear()
|
|
147
|
+
},
|
|
148
|
+
[],
|
|
149
|
+
)
|
|
150
|
+
return useCallback(
|
|
151
|
+
(ref: ToolResultImageRef) => {
|
|
152
|
+
if (!sessionId) return Promise.resolve(undefined)
|
|
153
|
+
// The whole address, because every part of it can change under a row that
|
|
154
|
+
// is still on screen: a dormant wake restarts the seqs, and a cached
|
|
155
|
+
// address that outlived its log must miss rather than serve another
|
|
156
|
+
// call's pixels.
|
|
157
|
+
const key = `${sessionId}:${ref.sourceSeq}:${ref.toolUseId}:${ref.partIndex}`
|
|
158
|
+
const hit = cache.current.get(key)
|
|
159
|
+
if (hit) {
|
|
160
|
+
// Re-inserting is the "recently used" half of the LRU: `Map` iterates in
|
|
161
|
+
// insertion order, so eviction reads oldest-first for free.
|
|
162
|
+
cache.current.delete(key)
|
|
163
|
+
cache.current.set(key, hit)
|
|
164
|
+
return hit.pending
|
|
165
|
+
}
|
|
166
|
+
// Fetched rather than pointed at: a bare `<img src>` at the gateway
|
|
167
|
+
// carries a credential in exactly one of four clients (the dashboard's
|
|
168
|
+
// same-origin host), and a broken icon in the other three.
|
|
169
|
+
const pending = client
|
|
170
|
+
.toolResultImage(sessionId, ref.sourceSeq, ref.toolUseId, ref.partIndex)
|
|
171
|
+
.then((blob) => {
|
|
172
|
+
if (blob.size === 0) return undefined
|
|
173
|
+
const url = URL.createObjectURL(blob)
|
|
174
|
+
const entry = cache.current.get(key)
|
|
175
|
+
if (entry) {
|
|
176
|
+
entry.url = url
|
|
177
|
+
entry.bytes = blob.size
|
|
178
|
+
evict(cache.current, key)
|
|
179
|
+
} else {
|
|
180
|
+
// Evicted (or unmounted) while in flight — nothing will ever draw
|
|
181
|
+
// this, and an unrevoked URL is the leak the budget exists to stop.
|
|
182
|
+
URL.revokeObjectURL(url)
|
|
183
|
+
}
|
|
184
|
+
return url
|
|
185
|
+
})
|
|
186
|
+
.catch(() => undefined)
|
|
187
|
+
// The declared size is what the budget counts until the bytes land: a
|
|
188
|
+
// hundred fetches in flight must not all read as free.
|
|
189
|
+
cache.current.set(key, { pending, bytes: ref.bytes })
|
|
190
|
+
return pending
|
|
191
|
+
},
|
|
192
|
+
[client, sessionId],
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Drop oldest-first until the held bytes fit the budget, revoking as it goes.
|
|
197
|
+
* `keep` is the entry just resolved — evicting the picture a row is about to
|
|
198
|
+
* draw would be a fetch spent on nothing. */
|
|
199
|
+
function evict(cache: Map<string, Entry>, keep: string): void {
|
|
200
|
+
let held = 0
|
|
201
|
+
for (const entry of cache.values()) held += entry.bytes
|
|
202
|
+
for (const [key, entry] of cache) {
|
|
203
|
+
if (held <= CACHE_BUDGET_BYTES) return
|
|
204
|
+
if (key === keep) continue
|
|
205
|
+
if (entry.url) URL.revokeObjectURL(entry.url)
|
|
206
|
+
cache.delete(key)
|
|
207
|
+
held -= entry.bytes
|
|
208
|
+
}
|
|
209
|
+
}
|