@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.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -1,56 +1,36 @@
1
- import { useEffect, useState } from 'react'
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 { cn } from '../../lib/utils.ts'
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
- /** Windows in reading order — session, weekly, then per-model weeklies. */
17
- rateLimits: Array<{ key: string; info: RateLimitInfo }>
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
- /** Local receipt time of the last window update. `rate_limit` events are one
23
- * per turn at best, so a stale reading is normal and worth saying out loud. */
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 plan's rate-limit windows, spelled out: how much of each is used, how that
46
- * compares to the pace that would spend the window exactly, and when it resets.
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
- <div className='flex flex-col gap-5'>
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 `LineGlyph` is. Anything
17
- * writing to a real terminal must use the ASCII set instead.
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
+ }