@workerdeck/react 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/build/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
2
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION } from "@workerdeck/protocol";
2
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, mergeUsage, orderUsageWindows } from "@workerdeck/protocol";
3
3
  import { WorkerDeckError } from "@workerdeck/client";
4
- //#region src/transcript.ts
4
+ //#region src/lib/transcript.ts
5
5
  const initialTranscriptState = {
6
6
  status: "starting",
7
7
  capabilities: ENGINE_CAPABILITIES.claude,
@@ -10,13 +10,48 @@ const initialTranscriptState = {
10
10
  totalCostUsd: 0,
11
11
  lastSeq: 0
12
12
  };
13
+ /**
14
+ * The in-flight streamed text and thought — a singleton **per agent**, not per
15
+ * session.
16
+ *
17
+ * It was one id for the whole stream, which was right while one thread streamed
18
+ * at a time. It is not: with subagent text forwarded, a `Task` and the thread
19
+ * that spawned it stream *concurrently*, and three parallel Tasks stream three
20
+ * ways at once. Under one id every one of those deltas accumulates into the same
21
+ * item — a row welding several agents' half-sentences together — and the first
22
+ * `assistant_message` to land wipes all of them, including the ones still being
23
+ * written.
24
+ *
25
+ * So the id carries the agent: `streaming` for the main thread (unchanged, so
26
+ * nothing that keys off it moves) and `streaming:<parentToolUseId>` inside a
27
+ * subagent.
28
+ */
13
29
  const STREAMING_ID = "streaming";
14
30
  const STREAMING_THINKING_ID = "streaming-thinking";
31
+ const streamingTextId = (parentToolUseId) => parentToolUseId == null ? STREAMING_ID : `${STREAMING_ID}:${parentToolUseId}`;
32
+ const streamingThinkingId = (parentToolUseId) => parentToolUseId == null ? STREAMING_THINKING_ID : `${STREAMING_THINKING_ID}:${parentToolUseId}`;
33
+ /** Is this item an in-flight stream — anyone's? The turn's end finalizes every
34
+ * one of them, since a subagent's last text is as unrecoverable as the main
35
+ * thread's when a turn is interrupted. */
36
+ const isStreamingItem = (item) => item.kind === "assistant_text" && item.id.startsWith(STREAMING_ID) || item.kind === "thinking" && item.id.startsWith(STREAMING_THINKING_ID);
15
37
  function blockText(content) {
16
38
  if (content === void 0) return "";
17
39
  if (typeof content === "string") return content;
18
40
  return content.map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
19
41
  }
42
+ /** The `image_ref` addresses in a result's content, or undefined when it holds
43
+ * none — which is the common case, and is why this returns undefined rather than
44
+ * an empty array: an absent field keeps the item byte-identical. */
45
+ function imageRefsOf(content, seq) {
46
+ if (!Array.isArray(content)) return void 0;
47
+ const refs = content.flatMap((part) => part.type === "image_ref" ? [{
48
+ partIndex: Number(part.part_index),
49
+ mediaType: String(part.media_type ?? "application/octet-stream"),
50
+ bytes: Number(part.bytes ?? 0),
51
+ sourceSeq: seq
52
+ }] : []);
53
+ return refs.length > 0 ? refs : void 0;
54
+ }
20
55
  function contentToBlocks(content) {
21
56
  return typeof content === "string" ? [{
22
57
  type: "text",
@@ -34,6 +69,27 @@ function outputText(output) {
34
69
  }
35
70
  /** CLI-side command output arrives as user text wrapped in local-command tags. */
36
71
  const LOCAL_COMMAND_OUTPUT = /^<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>$/;
72
+ /**
73
+ * A slash command the person ran, as the CLI writes it into the transcript:
74
+ * `<command-message>…</command-message><command-name>/wrapup</command-name>
75
+ * <command-args>…</command-args>`, in whichever order.
76
+ *
77
+ * Rendered as the command line rather than hidden. It *is* a person's turn — it
78
+ * is the reason everything after it happened — but the raw wrapper is markup
79
+ * nobody typed, and it showed up verbatim in every resumed transcript. Not
80
+ * suppressed in the runner for that same reason: hiding it would erase the
81
+ * turn's cause and, since `transcriptActivity` counts a non-synthetic user
82
+ * message as one row, silently disagree with the unread count.
83
+ */
84
+ const COMMAND_NAME = /<command-name>([\s\S]*?)<\/command-name>/;
85
+ const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
86
+ /** The typed command line, or undefined when this is ordinary prose. */
87
+ function slashCommandText(text) {
88
+ const name = COMMAND_NAME.exec(text)?.[1]?.trim();
89
+ if (!name) return void 0;
90
+ const args = COMMAND_ARGS.exec(text)?.[1]?.trim();
91
+ return args ? `${name} ${args}` : name;
92
+ }
37
93
  function upsert(items, item) {
38
94
  const index = items.findIndex((existing) => existing.id === item.id && existing.kind === item.kind);
39
95
  if (index === -1) return [...items, item];
@@ -65,19 +121,50 @@ function seedFromSessionInfo(state, info) {
65
121
  * The session's rate-limit windows in reading order: the session window, the
66
122
  * weekly window, then whichever per-model weekly windows it reports.
67
123
  *
68
- * Discovered rather than hardcoded the SDK's set of windows is an open union
69
- * and has grown before but ordered, so the first two always mean the same
70
- * thing. A window with no `utilization` is *unknown*, not zero, and is dropped
71
- * entirely rather than drawn as an empty bar that reads as "plenty left".
124
+ * The ordering and the drop-the-unknown rule are protocol's `orderUsageWindows`
125
+ * — the dashboard renders the same windows straight off `ProfileInfo.usage`,
126
+ * with no transcript anywhere near it, and two orderings would be one account
127
+ * described two ways. This stays as the transcript-shaped door to it.
72
128
  */
73
129
  function rateLimitWindows(state) {
74
- const all = Object.entries(state.rateLimits ?? {}).filter(([, info]) => info.utilization !== void 0).map(([key, info]) => ({
75
- key,
76
- info
77
- }));
78
- const named = ["five_hour", "seven_day"].flatMap((key) => all.filter((w) => w.key === key));
79
- const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
80
- return [...named, ...perModel];
130
+ return orderUsageWindows(mergeUsage({
131
+ rateLimits: state.rateLimits,
132
+ updatedAt: state.rateLimitsUpdatedAt
133
+ }, void 0));
134
+ }
135
+ /**
136
+ * Put a fetched tool result back where its head was — the other half of
137
+ * `truncateResults`.
138
+ *
139
+ * Into **transcript state**, not row-local state, and the three reasons are the
140
+ * design: the copy button then copies the whole thing rather than the head, the
141
+ * transcript cache retains it across a session switch, and no later event can
142
+ * re-truncate it. The markers are cleared, so a hydrated result is
143
+ * indistinguishable from one that was never cut and every renderer needs a
144
+ * branch for exactly one state, not two.
145
+ *
146
+ * Keyed on `toolUseId`, which is the id the row already holds; `seq` is what the
147
+ * *fetch* needed, not what the fold needs. Unknown id returns `state` unchanged
148
+ * — a press answered after the session was cleared must not resurrect a row.
149
+ */
150
+ function hydrateToolResult(state, toolUseId, text) {
151
+ let changed = false;
152
+ const items = state.items.map((item) => {
153
+ if (item.kind !== "tool_call" || item.id !== toolUseId || !item.result?.truncated) return item;
154
+ changed = true;
155
+ return {
156
+ ...item,
157
+ result: {
158
+ text,
159
+ isError: item.result.isError,
160
+ ...item.result.images && { images: item.result.images }
161
+ }
162
+ };
163
+ });
164
+ return changed ? {
165
+ ...state,
166
+ items
167
+ } : state;
81
168
  }
82
169
  function applyEvent(state, event) {
83
170
  if (event.seq <= state.lastSeq) return state;
@@ -147,6 +234,12 @@ function applyEvent(state, event) {
147
234
  ...base,
148
235
  subscriptionType: event.subscriptionType
149
236
  };
237
+ case "conversation_reset": return {
238
+ ...base,
239
+ items: [],
240
+ contextUsage: void 0,
241
+ sdkSessionId: event.sdkSessionId ?? base.sdkSessionId
242
+ };
150
243
  case "user_message": {
151
244
  let items = base.items;
152
245
  for (const block of contentToBlocks(event.message.content)) if (block.type === "tool_result") {
@@ -157,8 +250,15 @@ function applyEvent(state, event) {
157
250
  status: isError ? "failed" : "settled",
158
251
  result: {
159
252
  text: blockText(toolResult.content),
160
- isError
161
- }
253
+ isError,
254
+ ...toolResult.truncated && {
255
+ truncated: true,
256
+ totalChars: toolResult.total_chars,
257
+ sourceSeq: event.seq
258
+ },
259
+ ...imageRefsOf(toolResult.content, event.seq) && { images: imageRefsOf(toolResult.content, event.seq) }
260
+ },
261
+ ...event.patch && { patch: event.patch }
162
262
  } : item);
163
263
  } else if (block.type === "text" && !event.synthetic) {
164
264
  const text = block.text;
@@ -172,8 +272,9 @@ function applyEvent(state, event) {
172
272
  else items = upsert(items, {
173
273
  kind: "user",
174
274
  id: event.uuid ?? `user-${event.seq}`,
175
- text,
176
- attachments: event.attachments
275
+ text: slashCommandText(text) ?? text,
276
+ attachments: event.attachments,
277
+ ...event.parentToolUseId != null && { parentToolUseId: event.parentToolUseId }
177
278
  });
178
279
  }
179
280
  return {
@@ -182,8 +283,10 @@ function applyEvent(state, event) {
182
283
  };
183
284
  }
184
285
  case "assistant_message": {
185
- let streamedThinking = base.items.find((item) => item.kind === "thinking" && item.id === STREAMING_THINKING_ID)?.text ?? "";
186
- let items = base.items.filter((item) => !(item.kind === "assistant_text" && item.id === STREAMING_ID) && !(item.kind === "thinking" && item.id === STREAMING_THINKING_ID));
286
+ const streamingText = streamingTextId(event.parentToolUseId);
287
+ const streamingThought = streamingThinkingId(event.parentToolUseId);
288
+ let streamedThinking = base.items.find((item) => item.kind === "thinking" && item.id === streamingThought)?.text ?? "";
289
+ let items = base.items.filter((item) => !(item.kind === "assistant_text" && item.id === streamingText) && !(item.kind === "thinking" && item.id === streamingThought));
187
290
  contentToBlocks(event.message.content).forEach((block, index) => {
188
291
  const id = `${event.uuid}-${index}`;
189
292
  if (block.type === "text") items = upsert(items, {
@@ -224,10 +327,11 @@ function applyEvent(state, event) {
224
327
  const delta = event.event;
225
328
  if (delta.type !== "content_block_delta") return base;
226
329
  if (delta.delta?.type === "text_delta") {
330
+ const id = streamingTextId(event.parentToolUseId);
227
331
  const item = {
228
332
  kind: "assistant_text",
229
- id: STREAMING_ID,
230
- text: (base.items.find((item) => item.kind === "assistant_text" && item.id === STREAMING_ID)?.text ?? "") + (delta.delta.text ?? ""),
333
+ id,
334
+ text: (base.items.find((item) => item.kind === "assistant_text" && item.id === id)?.text ?? "") + (delta.delta.text ?? ""),
231
335
  streaming: true,
232
336
  parentToolUseId: event.parentToolUseId
233
337
  };
@@ -237,10 +341,13 @@ function applyEvent(state, event) {
237
341
  };
238
342
  }
239
343
  if (delta.delta?.type === "thinking_delta") {
344
+ const id = streamingThinkingId(event.parentToolUseId);
345
+ const text = (base.items.find((item) => item.kind === "thinking" && item.id === id)?.text ?? "") + (delta.delta.thinking ?? "");
346
+ if (text.trim() === "") return base;
240
347
  const item = {
241
348
  kind: "thinking",
242
- id: STREAMING_THINKING_ID,
243
- text: (base.items.find((item) => item.kind === "thinking" && item.id === STREAMING_THINKING_ID)?.text ?? "") + (delta.delta.thinking ?? ""),
349
+ id,
350
+ text,
244
351
  parentToolUseId: event.parentToolUseId
245
352
  };
246
353
  return {
@@ -253,7 +360,18 @@ function applyEvent(state, event) {
253
360
  case "turn_result": return {
254
361
  ...base,
255
362
  totalCostUsd: event.totalCostUsd,
256
- items: [...base.items, {
363
+ items: [...base.items.map((item) => {
364
+ if (!isStreamingItem(item)) return item;
365
+ const agent = "parentToolUseId" in item && item.parentToolUseId ? `-${item.parentToolUseId}` : "";
366
+ return item.kind === "assistant_text" ? {
367
+ ...item,
368
+ id: `text-${event.seq}${agent}`,
369
+ streaming: false
370
+ } : {
371
+ ...item,
372
+ id: `thinking-${event.seq}${agent}`
373
+ };
374
+ }), {
257
375
  kind: "turn_result",
258
376
  id: `turn-${event.seq}`,
259
377
  subtype: event.subtype,
@@ -338,38 +456,266 @@ function applyEvent(state, event) {
338
456
  }
339
457
  }
340
458
  //#endregion
341
- //#region src/use-session.ts
459
+ //#region src/lib/transcript-cache.ts
460
+ /**
461
+ * Module-scope cache of detached transcript states, so switching back to a
462
+ * recently viewed session paints its transcript in the mount frame and
463
+ * re-attaches with `afterSeq: lastSeq` — the wire replays only what happened
464
+ * while the panel was away, instead of the whole event log.
465
+ *
466
+ * Module-scope for the same reason `useSessions` and the watermarks are: the
467
+ * consumers that need it (the VS Code panel, the dashboard's session route)
468
+ * remount `SessionPanel` per session, so any per-hook copy would die with the
469
+ * unmount that is the entire point of surviving.
470
+ *
471
+ * Entries are the same `TranscriptState` objects the reducer held — retention,
472
+ * not duplication — and the bound is what keeps retention from becoming a
473
+ * leak. Eviction is least-recently-STORED: every detach stores, so store
474
+ * recency is viewing recency, and reads don't need to reorder.
475
+ *
476
+ * Keys come from {@link transcriptCacheKey} and carry the client's
477
+ * `identityKey` (gateway + auth headers), never the session id alone: a
478
+ * session id is unique only within one gateway, and an entry must never be
479
+ * readable through a client speaking as a different principal.
480
+ */
481
+ /**
482
+ * How many detached transcripts stay warm.
483
+ *
484
+ * Five covers the working set the feature exists for — an operator alternating
485
+ * between the handful of sessions that are simultaneously working or awaiting
486
+ * them — while keeping the pathological case (five `perf`-fixture-sized
487
+ * transcripts of ~4k items each) in the tens of megabytes, no more than a few
488
+ * times what the one mounted panel already holds. Too small degrades to
489
+ * today's behaviour (a replay on switch-back); too large is memory held
490
+ * forever in a webview — the asymmetry favours small.
491
+ */
492
+ const MAX_ENTRIES = 5;
493
+ const entries = /* @__PURE__ */ new Map();
494
+ /** Cache key for one session as seen through one (gateway, principal). The
495
+ * NUL separator is unambiguous: the identity key is `JSON.stringify` output,
496
+ * which escapes control characters, so no two (identity, session) pairs can
497
+ * spell the same key. */
498
+ function transcriptCacheKey(client, sessionId) {
499
+ return `${client.identityKey}\u0000${sessionId}`;
500
+ }
501
+ function readTranscriptCache(key) {
502
+ return entries.get(key);
503
+ }
504
+ function writeTranscriptCache(key, state) {
505
+ entries.delete(key);
506
+ entries.set(key, state);
507
+ if (entries.size > MAX_ENTRIES) {
508
+ const oldest = entries.keys().next().value;
509
+ if (oldest !== void 0) entries.delete(oldest);
510
+ }
511
+ }
512
+ function deleteTranscriptCache(key) {
513
+ entries.delete(key);
514
+ }
515
+ /**
516
+ * Drop every cached transcript. For an embedder changing principals in place
517
+ * (a logout that keeps the page alive) — entries are unreachable through the
518
+ * new principal's client either way, but scrubbing them is free and final.
519
+ */
520
+ function clearTranscriptCache() {
521
+ entries.clear();
522
+ }
523
+ //#endregion
524
+ //#region src/lib/attach-plan.ts
525
+ /**
526
+ * The attach effect's decisions, pure.
527
+ *
528
+ * `useClaudeSession` is never rendered in tests — this package deliberately
529
+ * carries no jsdom and no testing-library — so the logic that used to live
530
+ * inline in the attach effect (which state an attach holds, whether the
531
+ * reducer must be re-seeded, which `afterSeq` to request, whether the parting
532
+ * state may go back into the cache) is decided here, where plain vitest
533
+ * reaches it, and the effect keeps only glue: read its refs into inputs,
534
+ * apply the returned instructions, subscribe. The refs themselves stay in the
535
+ * hook — a decision function that owned React state would be the untestable
536
+ * thing again — so everything stateful arrives as a value and leaves as an
537
+ * instruction.
538
+ */
539
+ /**
540
+ * Which (resync, client identity, session) a reducer state was seeded for.
541
+ * One format, shared by the hook's mount initializer and {@link planAttach},
542
+ * so the two sites cannot drift: a token that dropped `resyncSeq` would leave
543
+ * the stale-log retry looking already-seeded, and the fresh replay would
544
+ * compose into the condemned state the resync just discarded.
545
+ */
546
+ function attachSeedToken(resyncSeq, key) {
547
+ return `${resyncSeq}:${key}`;
548
+ }
549
+ /**
550
+ * Decide what one run of the attach effect does before it opens the socket.
551
+ */
552
+ function planAttach(input) {
553
+ const seedToken = attachSeedToken(input.resyncSeq, input.key);
554
+ const warm = input.cacheEnabled && !input.skipCache ? input.warm : void 0;
555
+ const seed = input.seededFor !== seedToken;
556
+ const held = seed ? warm ?? initialTranscriptState : input.current;
557
+ return {
558
+ held,
559
+ seed,
560
+ seedToken,
561
+ ...held.lastSeq > 0 ? { afterSeq: held.lastSeq } : {}
562
+ };
563
+ }
564
+ /**
565
+ * Whether the effect's cleanup may keep the parting transcript warm for a
566
+ * switch-back. Refused when caching is off; after a stale-log detection —
567
+ * writing the condemned state back would re-poison the very retry that just
568
+ * discarded it; and when there is nothing real to keep — `lastSeq === 0` also
569
+ * protects an existing entry from being clobbered by a mount that never
570
+ * finished attaching, and a state with no `session` never saw its attached
571
+ * frame at all.
572
+ */
573
+ function shouldWriteParting(input) {
574
+ return input.cacheEnabled && !input.skipCache && input.parting.lastSeq > 0 && input.parting.session !== void 0;
575
+ }
576
+ //#endregion
577
+ //#region src/hooks/use-session.ts
342
578
  /** Session events drive the reducer; the attach snapshot seeds fields (permission
343
579
  * mode, model) that a promptless session's event stream doesn't carry yet. */
344
580
  function reduce(state, action) {
581
+ if (action.type === "transcript_seed") return action.state;
582
+ if (action.type === "transcript_hydrate_result") return hydrateToolResult(state, action.toolUseId, action.text);
345
583
  return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
346
584
  }
347
585
  /** Failed attempts in a row before "reconnecting…" stops being the honest word.
348
586
  * Three is ~3.5s of backoff — past a blip. Matches the iOS client. */
349
587
  const OFFLINE_AFTER_ATTEMPTS = 3;
588
+ /**
589
+ * The seq the initial attach replay ends on, or undefined when there is nothing
590
+ * to hold for.
591
+ *
592
+ * This is an exact signal, not a heuristic: the `attached` frame is sent before
593
+ * any replayed `event` frame and carries the runner's seq at attach time
594
+ * (`session.lastSeq`), so the moment the frame arrives the client knows
595
+ * precisely which seq the replay ends on. Every runner keeps its full event log
596
+ * and always delivers the highest-seq event on a fresh replay (the
597
+ * `conversation_reset` skip is strictly-below-the-reset, and the reset's seq is
598
+ * itself ≤ lastSeq), so `TranscriptState.lastSeq >= target` means the replay
599
+ * has landed. No quiet window or other arrival heuristic belongs here.
600
+ *
601
+ * Only a FRESH attach yields a target (`replayingFrom === 0`): a reconnect
602
+ * replays into a transcript the reader is already looking at, and blanking it
603
+ * mid-turn would be a worse bug than the flicker the hold exists to fix. A
604
+ * brand-new session (`lastSeq === 0`) has nothing to replay and never holds.
605
+ */
606
+ function initialReplayTarget(frame) {
607
+ return frame.replayingFrom === 0 && frame.session.lastSeq > 0 ? frame.session.lastSeq : void 0;
608
+ }
609
+ /**
610
+ * Whether an attach frame describes a DIFFERENT event log than the transcript
611
+ * `held` was built from — in which case attaching with `afterSeq: held.lastSeq`
612
+ * has already gone wrong: every event in the new log has seq ≤ afterSeq, so
613
+ * nothing will ever arrive and the stale rows would stand forever, with no
614
+ * error. The only recovery is to forget the state and re-attach from seq 0.
615
+ *
616
+ * A log resets on routine paths, not corner cases: a dormant session
617
+ * (claude/codex surviving a gateway restart) is rebuilt with a brand-new
618
+ * runner whose log starts at 0 and refills from the engine's own store. Two
619
+ * checks, each of which the other misses:
620
+ *
621
+ * - `session.lastSeq < held.lastSeq` — the server's log is shorter than what
622
+ * we hold. Within one log seq only grows, so this is proof of a reset. It
623
+ * catches a rebuilt runner that has not yet re-run far — but not one whose
624
+ * backfill already advanced past us.
625
+ * - `session.createdAt !== held.session.createdAt` — a different runner
626
+ * incarnation. The claude and codex runners stamp `Date.now()` at
627
+ * construction, so a dormant rebuild always changes it; the provider runner
628
+ * restores `createdAt` from its snapshot precisely when it also restores
629
+ * the event log and seq counter (ai-sdk-runner's `#restore`), so equality
630
+ * truthfully means "same log" for every engine.
631
+ *
632
+ * A full replay (`replayingFrom === 0`) is never stale — it carries the whole
633
+ * log, so the caller heals by resetting state and applying it — and holding
634
+ * nothing (`held.lastSeq === 0`) has nothing to be stale about. That first
635
+ * clause is also what makes the recovery loop-proof: the re-attach from 0 can
636
+ * never re-trigger this predicate.
637
+ *
638
+ * Not cache-specific: a live handle reconnecting after a gateway restart
639
+ * re-attaches with its own advanced `afterSeq` against the rebuilt log and
640
+ * hits the identical silence, so the hook applies this to every attach frame.
641
+ */
642
+ function staleAttach(frame, held) {
643
+ if (frame.replayingFrom === 0 || held.lastSeq === 0) return false;
644
+ if (frame.session.lastSeq < held.lastSeq) return true;
645
+ return held.session !== void 0 && frame.session.createdAt !== held.session.createdAt;
646
+ }
647
+ /**
648
+ * Backstop for the replay hold: if the target seq has not landed after this
649
+ * long, reveal what has arrived. On a healthy attach the target is always
650
+ * reached (see {@link initialReplayTarget}); the backstop exists because a
651
+ * blank panel forever would be a much worse failure than a visible stream, so
652
+ * the hold is bounded no matter what a future filter or a lossy path does. It
653
+ * runs from the attach — a per-event re-arm would be a quiet-window heuristic
654
+ * in a new costume.
655
+ */
656
+ const REPLAY_HOLD_MAX_MS = 1500;
350
657
  /** Attach to a session and maintain live transcript state. Detaches on unmount. */
351
658
  function useClaudeSession(client, sessionId, options) {
352
- const [state, dispatch] = useReducer(reduce, initialTranscriptState);
659
+ const [state, dispatch] = useReducer(reduce, void 0, () => (options?.cacheTranscript !== false && sessionId !== void 0 ? readTranscriptCache(transcriptCacheKey(client, sessionId)) : void 0) ?? initialTranscriptState);
353
660
  const [connection, setConnection] = useState("reconnecting");
354
661
  const [protocolMismatch, setProtocolMismatch] = useState();
662
+ /** Where the current attach's replay ends, while one is being held for. */
663
+ const [replayTarget, setReplayTarget] = useState();
664
+ /** Bumped to force a fresh attach from seq 0 after a stale-log detection. */
665
+ const [resyncSeq, setResyncSeq] = useState(0);
355
666
  const [handleState, setHandleState] = useState();
356
667
  const handleRef = useRef(null);
357
- const onProtocolErrorRef = useRef(options?.onProtocolError);
358
- onProtocolErrorRef.current = options?.onProtocolError;
668
+ const optionsRef = useRef(options);
669
+ optionsRef.current = options;
670
+ const stateRef = useRef(state);
671
+ stateRef.current = state;
672
+ const seededForRef = useRef(attachSeedToken(0, sessionId === void 0 ? "" : transcriptCacheKey(client, sessionId)));
673
+ const skipCacheRef = useRef(false);
359
674
  useEffect(() => {
360
675
  if (!sessionId) return;
361
- const handle = client.attach(sessionId);
676
+ const cache = optionsRef.current?.cacheTranscript !== false;
677
+ const key = transcriptCacheKey(client, sessionId);
678
+ const plan = planAttach({
679
+ resyncSeq,
680
+ key,
681
+ seededFor: seededForRef.current,
682
+ current: stateRef.current,
683
+ cacheEnabled: cache,
684
+ skipCache: skipCacheRef.current,
685
+ warm: readTranscriptCache(key)
686
+ });
687
+ skipCacheRef.current = false;
688
+ if (plan.seed) {
689
+ dispatch({
690
+ type: "transcript_seed",
691
+ state: plan.held
692
+ });
693
+ seededForRef.current = plan.seedToken;
694
+ }
695
+ const handle = client.attach(sessionId, {
696
+ truncateResults: true,
697
+ imageRefs: true,
698
+ ...plan.afterSeq === void 0 ? {} : { afterSeq: plan.afterSeq }
699
+ });
362
700
  handleRef.current = handle;
363
701
  setHandleState(handle);
364
702
  const offEvent = handle.on("event", (event) => dispatch(event));
365
703
  const offAttached = handle.on("attached", (frame) => {
704
+ if (staleAttach(frame, stateRef.current)) {
705
+ offEvent();
706
+ deleteTranscriptCache(key);
707
+ skipCacheRef.current = true;
708
+ setResyncSeq((n) => n + 1);
709
+ return;
710
+ }
366
711
  dispatch(frame);
712
+ setReplayTarget(initialReplayTarget(frame));
367
713
  setProtocolMismatch(frame.protocolVersion === PROTOCOL_VERSION ? void 0 : frame.protocolVersion);
368
714
  });
369
715
  const offConn = handle.on("connectionChange", (open) => setConnection(open ? "live" : "reconnecting"));
370
716
  const offRetry = handle.on("reconnectAttempt", (attempts) => setConnection(attempts >= OFFLINE_AFTER_ATTEMPTS ? "offline" : "reconnecting"));
371
717
  const offProtocolError = handle.on("protocolError", (message) => {
372
- onProtocolErrorRef.current?.(message);
718
+ optionsRef.current?.onProtocolError?.(message);
373
719
  });
374
720
  return () => {
375
721
  offEvent();
@@ -382,15 +728,53 @@ function useClaudeSession(client, sessionId, options) {
382
728
  setHandleState(void 0);
383
729
  setConnection("reconnecting");
384
730
  setProtocolMismatch(void 0);
731
+ setReplayTarget(void 0);
732
+ const parting = stateRef.current;
733
+ if (shouldWriteParting({
734
+ cacheEnabled: cache,
735
+ skipCache: skipCacheRef.current,
736
+ parting
737
+ })) writeTranscriptCache(key, parting);
385
738
  };
386
- }, [client, sessionId]);
739
+ }, [
740
+ client,
741
+ sessionId,
742
+ resyncSeq
743
+ ]);
744
+ useEffect(() => {
745
+ if (replayTarget === void 0) return;
746
+ const timer = setTimeout(() => setReplayTarget(void 0), REPLAY_HOLD_MAX_MS);
747
+ return () => clearTimeout(timer);
748
+ }, [replayTarget]);
749
+ useEffect(() => {
750
+ if (replayTarget !== void 0 && state.lastSeq >= replayTarget) setReplayTarget(void 0);
751
+ }, [replayTarget, state.lastSeq]);
387
752
  const models = useProfileModelFallback(client, sessionId, state);
388
753
  const connected = connection === "live";
754
+ const replaying = replayTarget !== void 0 && state.lastSeq < replayTarget;
389
755
  const reconnectNow = useCallback(() => handleRef.current?.reconnectNow(), []);
756
+ const loadFullResult = useCallback(async (toolUseId) => {
757
+ if (!sessionId) return false;
758
+ const item = stateRef.current.items.find((candidate) => candidate.kind === "tool_call" && candidate.id === toolUseId);
759
+ const result = item?.kind === "tool_call" ? item.result : void 0;
760
+ if (!result?.truncated || result.sourceSeq === void 0) return false;
761
+ try {
762
+ const full = await client.toolResult(sessionId, result.sourceSeq, toolUseId);
763
+ dispatch({
764
+ type: "transcript_hydrate_result",
765
+ toolUseId,
766
+ text: typeof full.content === "string" ? full.content : (full.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n")
767
+ });
768
+ return true;
769
+ } catch {
770
+ return false;
771
+ }
772
+ }, [client, sessionId]);
390
773
  return useMemo(() => ({
391
774
  state,
392
775
  connected,
393
776
  connection,
777
+ replaying,
394
778
  protocolMismatch,
395
779
  models,
396
780
  effectiveModel: state.model ?? state.defaultModel,
@@ -402,15 +786,18 @@ function useClaudeSession(client, sessionId, options) {
402
786
  setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
403
787
  setModel: (model) => handleRef.current?.setModel(model),
404
788
  closeSession: () => handleRef.current?.closeSession(),
405
- reconnectNow
789
+ reconnectNow,
790
+ loadFullResult
406
791
  }), [
407
792
  state,
408
793
  connected,
409
794
  connection,
795
+ replaying,
410
796
  protocolMismatch,
411
797
  models,
412
798
  handleState,
413
- reconnectNow
799
+ reconnectNow,
800
+ loadFullResult
414
801
  ]);
415
802
  }
416
803
  /**
@@ -445,7 +832,7 @@ function useProfileModelFallback(client, sessionId, state) {
445
832
  return hasReported ? reported : catalog;
446
833
  }
447
834
  //#endregion
448
- //#region src/use-attachments.ts
835
+ //#region src/hooks/use-attachments.ts
449
836
  /**
450
837
  * How a media type reaches a model, in the capability record's vocabulary.
451
838
  * `undefined` means this build can't classify it — the upload still goes,
@@ -681,7 +1068,7 @@ async function prepare(file) {
681
1068
  }
682
1069
  }
683
1070
  //#endregion
684
- //#region src/prompt-tokens.ts
1071
+ //#region src/lib/prompt-tokens.ts
685
1072
  /** Characters a command name may contain after the slash. Deliberately excludes
686
1073
  * `/`, so an absolute path pasted into a message (`/Users/me/…`) is not mistaken
687
1074
  * for a command; `:` is in because namespaced skills (`dev:wrapup`) are spelled
@@ -731,7 +1118,7 @@ function scanPromptTokens(text) {
731
1118
  return tokens;
732
1119
  }
733
1120
  //#endregion
734
- //#region src/host-tree.ts
1121
+ //#region src/lib/host-tree.ts
735
1122
  /**
736
1123
  * Flatten the loaded directories into the rows the tree shows.
737
1124
  *
@@ -796,7 +1183,7 @@ function ancestorsWithin(root, path) {
796
1183
  return out;
797
1184
  }
798
1185
  //#endregion
799
- //#region src/use-host-files.ts
1186
+ //#region src/hooks/use-host-files.ts
800
1187
  /**
801
1188
  * Fuzzy file search rooted at a session's working directory — what an `@file`
802
1189
  * picker needs.
@@ -978,7 +1365,133 @@ function useHostFileTree(client, cwd) {
978
1365
  };
979
1366
  }
980
1367
  //#endregion
981
- //#region src/use-session-info.ts
1368
+ //#region src/hooks/use-project-icons.ts
1369
+ /**
1370
+ * Project icon bytes for a list of sessions, as object URLs keyed by the icon's
1371
+ * own content hash.
1372
+ *
1373
+ * **Keyed by hash, and cached for the life of the page.** That is what the
1374
+ * wire's `ProjectIcon.image.hash` is for: every session in one project serves
1375
+ * identical bytes, so twelve rows of one repo cost one request, and two
1376
+ * *different* projects that happen to declare the same file cost one between
1377
+ * them. A hash names its bytes, so an entry can never go stale — editing the
1378
+ * icon changes the hash, which arrives on the next poll as a key this cache has
1379
+ * not seen. The old entry is dead weight rather than a wrong answer, and the
1380
+ * population is bounded by how many distinct icons an operator has open.
1381
+ *
1382
+ * The cache is **module scope on purpose**, like `useSessions`' store: the
1383
+ * sidebar and any other surface rendering rows mount this at once, and a
1384
+ * per-hook cache would be N copies each fetching the same bytes.
1385
+ *
1386
+ * A failure is cached as a failure. The route's 404 is the uniform "no icon"
1387
+ * (no project, a glyph, or one the gateway refused), so retrying it every poll
1388
+ * would be a request per session per poll for a picture that is never coming.
1389
+ *
1390
+ * Object URLs are never revoked, which is the same decision stated twice: they
1391
+ * are the cache. Revoking one would break every row still pointing at it, and
1392
+ * the whole point of hashing is that nothing here is ever superseded.
1393
+ *
1394
+ * The VS Code extension has the same three-set structure in `project-icons.ts`
1395
+ * and cannot share this one — its webview has no external `connect-src` at all,
1396
+ * so its bytes arrive as data URLs pushed from the extension host. One design,
1397
+ * two implementations, for a reason that is in the transport rather than here.
1398
+ */
1399
+ const byHash = /* @__PURE__ */ new Map();
1400
+ const inFlight = /* @__PURE__ */ new Set();
1401
+ const failed = /* @__PURE__ */ new Set();
1402
+ function useProjectIcons(rows, clientFor) {
1403
+ const [resolved, setResolved] = useState(() => Object.fromEntries(byHash));
1404
+ useEffect(() => {
1405
+ let alive = true;
1406
+ for (const row of rows) {
1407
+ const icon = row.info.project?.icon;
1408
+ if (icon?.type !== "image") continue;
1409
+ const { hash } = icon;
1410
+ if (byHash.has(hash) || inFlight.has(hash) || failed.has(hash)) continue;
1411
+ const client = clientFor(row.hostId);
1412
+ if (!client) continue;
1413
+ inFlight.add(hash);
1414
+ client.projectIcon(row.info.id).then((blob) => {
1415
+ byHash.set(hash, URL.createObjectURL(blob));
1416
+ if (alive) setResolved(Object.fromEntries(byHash));
1417
+ }).catch(() => {
1418
+ failed.add(hash);
1419
+ }).finally(() => inFlight.delete(hash));
1420
+ }
1421
+ return () => {
1422
+ alive = false;
1423
+ };
1424
+ }, [rows, clientFor]);
1425
+ return resolved;
1426
+ }
1427
+ //#endregion
1428
+ //#region src/hooks/use-profile-usage.ts
1429
+ /**
1430
+ * The gateway's per-profile plan usage, over REST.
1431
+ *
1432
+ * The session's own event stream carries a `rate_limit` reading only when the
1433
+ * engine volunteers one — for claude that is at a turn's edges and nowhere else,
1434
+ * so a session idle since yesterday replays yesterday's number, and a session
1435
+ * opened today knows nothing of what a sibling on the same account spent an hour
1436
+ * ago. `GET /profiles` answers the account-wide question, which is why this is a
1437
+ * poll and not a subscription: nothing pushes it.
1438
+ *
1439
+ * Polling and not attaching, deliberately — a second WebSocket per surface is
1440
+ * exactly what the bridge's "asks the first attached client" rule forbids, and
1441
+ * this is one small GET a minute.
1442
+ *
1443
+ * Self-disabling on a 404, like {@link useHostFileSearch}: a gateway without the
1444
+ * route will never grow one mid-session, so stop asking rather than log a miss
1445
+ * every minute.
1446
+ */
1447
+ function useProfileUsage(client, profile, options = {}) {
1448
+ const { intervalMs = 6e4, enabled = true } = options;
1449
+ const [usage, setUsage] = useState();
1450
+ const [unsupported, setUnsupported] = useState(false);
1451
+ const [nonce, setNonce] = useState(0);
1452
+ const refresh = useCallback(() => setNonce((n) => n + 1), []);
1453
+ useEffect(() => setUsage(void 0), [client, profile]);
1454
+ const alive = useRef(true);
1455
+ useEffect(() => {
1456
+ alive.current = true;
1457
+ return () => {
1458
+ alive.current = false;
1459
+ };
1460
+ }, []);
1461
+ useEffect(() => {
1462
+ if (!profile || !enabled || unsupported) return;
1463
+ let cancelled = false;
1464
+ const load = () => {
1465
+ if (globalThis.document?.hidden) return;
1466
+ client.listProfiles().then((res) => {
1467
+ if (cancelled || !alive.current) return;
1468
+ setUsage(res.profiles.find((p) => p.name === profile)?.usage);
1469
+ }).catch((e) => {
1470
+ if (cancelled || !alive.current) return;
1471
+ if (e instanceof WorkerDeckError && e.status === 404) setUnsupported(true);
1472
+ });
1473
+ };
1474
+ load();
1475
+ const timer = setInterval(load, intervalMs);
1476
+ return () => {
1477
+ cancelled = true;
1478
+ clearInterval(timer);
1479
+ };
1480
+ }, [
1481
+ client,
1482
+ profile,
1483
+ enabled,
1484
+ unsupported,
1485
+ intervalMs,
1486
+ nonce
1487
+ ]);
1488
+ return {
1489
+ usage,
1490
+ refresh
1491
+ };
1492
+ }
1493
+ //#endregion
1494
+ //#region src/hooks/use-session-info.ts
982
1495
  /**
983
1496
  * The registry's record of one session, over REST.
984
1497
  *
@@ -1026,7 +1539,7 @@ function useSessionInfo(client, sessionId) {
1026
1539
  };
1027
1540
  }
1028
1541
  //#endregion
1029
- //#region src/open-files.ts
1542
+ //#region src/lib/open-files.ts
1030
1543
  /** Whether a tab has edits that are not on disk. Derived, so typing something
1031
1544
  * and undoing it back leaves the tab clean — which is what an editor should do
1032
1545
  * and what a boolean flag set on first keystroke would get wrong. */
@@ -1174,7 +1687,7 @@ function baseName(path) {
1174
1687
  return trimmed.slice(trimmed.lastIndexOf("/") + 1) || trimmed || path;
1175
1688
  }
1176
1689
  //#endregion
1177
- //#region src/use-open-files.ts
1690
+ //#region src/hooks/use-open-files.ts
1178
1691
  /**
1179
1692
  * The open-file tabs of a workspace: which files are open, which one is focused,
1180
1693
  * the bytes behind each, and the edits on top of them.
@@ -1352,7 +1865,7 @@ function useOpenFiles(client) {
1352
1865
  };
1353
1866
  }
1354
1867
  //#endregion
1355
- //#region src/tool-host.ts
1868
+ //#region src/lib/tool-host.ts
1356
1869
  /**
1357
1870
  * Answers server-bridged tool calls by executing them in this browser tab.
1358
1871
  * Framework-free — {@link useToolCallHost} is a thin React wrapper.
@@ -1480,7 +1993,7 @@ async function defaultLoadEngine() {
1480
1993
  return sandbox.loadEngine(variant);
1481
1994
  }
1482
1995
  //#endregion
1483
- //#region src/use-tool-host.ts
1996
+ //#region src/hooks/use-tool-host.ts
1484
1997
  /**
1485
1998
  * React wrapper around {@link createToolCallHost}: subscribes while mounted and
1486
1999
  * exposes recent executions for rendering. All the logic lives in the
@@ -1522,7 +2035,7 @@ function useToolCallHost(handle, options = {}) {
1522
2035
  return { executions };
1523
2036
  }
1524
2037
  //#endregion
1525
- //#region src/recap.ts
2038
+ //#region src/lib/recap.ts
1526
2039
  /**
1527
2040
  * Summarize the items from `fromIndex` onward — the boundary being the number
1528
2041
  * of items that existed when the session was last looked at.
@@ -1601,6 +2114,6 @@ function plural(count, one, many = `${one}s`) {
1601
2114
  return `${count} ${count === 1 ? one : many}`;
1602
2115
  }
1603
2116
  //#endregion
1604
- export { ancestorsWithin, applyEvent, attachmentKind, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, useToolCallHost };
2117
+ export { REPLAY_HOLD_MAX_MS, ancestorsWithin, applyEvent, attachmentKind, clearTranscriptCache, createToolCallHost, currentText, flattenHostTree, hydrateToolResult, initialOpenFilesState, initialReplayTarget, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, staleAttach, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useProfileUsage, useProjectIcons, useSessionInfo, useToolCallHost };
1605
2118
 
1606
2119
  //# sourceMappingURL=index.mjs.map