@workerdeck/react 0.16.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 CHANGED
@@ -137,6 +137,23 @@ input by construction: fine for the user's own data, never a source of server-au
137
137
 
138
138
  ## Rules you cannot infer from the types
139
139
 
140
+ - **A truncated tool result is hydrated into transcript state, not into a row.** The hook attaches
141
+ with `truncateResults`, so a huge result arrives as a head carrying
142
+ `result.truncated`/`totalChars`/`sourceSeq`; `loadFullResult(toolUseId)` fetches the rest and
143
+ folds it in through `hydrateToolResult`, which clears the markers. Keeping it in row-local state
144
+ instead would mean the copy button copies the head, the transcript cache drops it on a session
145
+ switch, and the next event re-truncates the row.
146
+
147
+ - **`result.images` is the same idea for pictures, and it is set only when there are any.** The
148
+ hook also attaches with `imageRefs`, so a `tool_result`'s base64 images arrive as addresses and
149
+ the reducer records `{ partIndex, mediaType, bytes, sourceSeq }` per picture — absent, never
150
+ empty, because an item that gained a field is an item every renderer re-measures (on iOS
151
+ `ToolCallItem` is `Equatable` and half the row-plan cache key). Each entry carries its **own**
152
+ `sourceSeq`: the result-level one is cleared by text hydration, and a reader who pressed "show
153
+ everything" must still be able to load the screenshot. Raw base64 parts are still dropped on
154
+ arrival, as they always were — folding them into state would pin megabytes inside the transcript
155
+ cache.
156
+
140
157
  - **Companions must ride the hook's own `handle`.** The server's tool bridge asks the *first
141
158
  attached client*, so a second `useClaudeSession` for the same session is a second attach that
142
159
  will never be asked anything. `useAttachments`, `useHostFileSearch` and `useToolCallHost` all
package/build/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AttachedFrame, ContextUsage, EngineCapabilities, FilePatch, HostDirEntry, HostFileMatch, MessageAttachment, ModelOption, PermissionMode, PermissionRequest, ProfileEngine, ProfileUsage, RateLimitInfo, SessionEvent, SessionInfo, SessionStatus, SkillInfo, SlashCommandInfo, ToolExecutionBackend, UsageWindowRow } from "@workerdeck/protocol";
1
+ import { AttachedFrame, ContextUsage, EngineCapabilities, FilePatch, HostDirEntry, HostFileMatch, MessageAttachment, ModelOption, PermissionMode, PermissionRequest, ProfileEngine, ProfileUsage, RateLimitInfo, SessionEvent, SessionInfo, SessionRow, SessionStatus, SkillInfo, SlashCommandInfo, ToolExecutionBackend, UsageWindowRow } from "@workerdeck/protocol";
2
2
  import { SessionHandle, WorkerDeckClient } from "@workerdeck/client";
3
3
  import { RunScriptResult, SandboxEngine, SandboxVfs } from "@workerdeck/sandbox";
4
4
 
@@ -12,6 +12,18 @@ type TranscriptItem = {
12
12
  id: string;
13
13
  text: string;
14
14
  attachments?: MessageAttachment[];
15
+ /**
16
+ * The `Task` call this prompt was addressed to, when it is a subagent's
17
+ * brief rather than something a person typed.
18
+ *
19
+ * Optional where the other kinds carry it as `string | null`, and the
20
+ * asymmetry is the point: on those it is a fact about every instance, so
21
+ * forgetting to stamp it should not typecheck. Here the overwhelming case
22
+ * is a human prompt, which has no parent at all — `undefined` says that,
23
+ * where `null` on 24 construction sites would only say "somebody
24
+ * remembered".
25
+ */
26
+ parentToolUseId?: string;
15
27
  } | {
16
28
  kind: 'assistant_text';
17
29
  id: string;
@@ -39,9 +51,45 @@ type TranscriptItem = {
39
51
  * deferred call has no result yet and is not the same as a running one.
40
52
  */
41
53
  status: 'running' | 'pending' | 'deferred' | 'settled' | 'failed';
54
+ /**
55
+ * `truncated`/`totalChars`/`sourceSeq` are set **only** when the replay
56
+ * delivered a head (protocol's {@link ToolResultBlock.truncated}), so
57
+ * every other result stays byte-identical to what it was before this
58
+ * feature existed. That matters beyond tidiness: on iOS `ToolCallItem` is
59
+ * `Equatable` and is half the row-plan cache key.
60
+ *
61
+ * `sourceSeq` is what makes the press possible at all — the item is what a
62
+ * renderer holds, and it must be able to name the event to fetch. It goes
63
+ * away again on hydration, along with the other two, so a hydrated result
64
+ * is indistinguishable from one that was never cut.
65
+ */
42
66
  result?: {
43
67
  text: string;
44
68
  isError: boolean;
69
+ truncated?: boolean;
70
+ totalChars?: number;
71
+ sourceSeq?: number;
72
+ /**
73
+ * The pictures this result carried, as addresses rather than bytes —
74
+ * set **only** when the replay delivered `image_ref` parts, so every
75
+ * other result stays byte-identical (the `Equatable` argument above,
76
+ * again).
77
+ *
78
+ * Each entry carries its **own** `sourceSeq`, which is not redundant
79
+ * with the one beside it: that one is cleared by text hydration, and a
80
+ * reader who pressed "show everything" must still be able to load the
81
+ * screenshot afterwards.
82
+ *
83
+ * Raw base64 `image` parts are still dropped on arrival, as they always
84
+ * were. Folding them in would pin megabytes inside `TranscriptState`,
85
+ * which the transcript LRU then retains across session switches.
86
+ */
87
+ images?: ReadonlyArray<{
88
+ partIndex: number;
89
+ mediaType: string;
90
+ bytes: number;
91
+ sourceSeq: number;
92
+ }>;
45
93
  };
46
94
  /**
47
95
  * What this call changed on disk, when it was a file edit — the engine's
@@ -172,6 +220,22 @@ declare function seedFromSessionInfo(state: TranscriptState, info: SessionInfo):
172
220
  * described two ways. This stays as the transcript-shaped door to it.
173
221
  */
174
222
  declare function rateLimitWindows(state: TranscriptState): UsageWindowRow[];
223
+ /**
224
+ * Put a fetched tool result back where its head was — the other half of
225
+ * `truncateResults`.
226
+ *
227
+ * Into **transcript state**, not row-local state, and the three reasons are the
228
+ * design: the copy button then copies the whole thing rather than the head, the
229
+ * transcript cache retains it across a session switch, and no later event can
230
+ * re-truncate it. The markers are cleared, so a hydrated result is
231
+ * indistinguishable from one that was never cut and every renderer needs a
232
+ * branch for exactly one state, not two.
233
+ *
234
+ * Keyed on `toolUseId`, which is the id the row already holds; `seq` is what the
235
+ * *fetch* needed, not what the fold needs. Unknown id returns `state` unchanged
236
+ * — a press answered after the session was cleared must not resurrect a row.
237
+ */
238
+ declare function hydrateToolResult(state: TranscriptState, toolUseId: string, text: string): TranscriptState;
175
239
  declare function applyEvent(state: TranscriptState, event: SessionEvent): TranscriptState;
176
240
  //#endregion
177
241
  //#region src/hooks/use-session.d.ts
@@ -313,6 +377,17 @@ type UseClaudeSessionResult = {
313
377
  setModel: (model?: string) => void;
314
378
  closeSession: () => void; /** Skip the reconnect backoff — what a tab returning to the foreground does. */
315
379
  reconnectNow: () => void;
380
+ /**
381
+ * Fetch the whole of a tool result the replay delivered as a head, and put it
382
+ * back on its row (`result.truncated` clears with it).
383
+ *
384
+ * Resolves `false` when there was nothing to do — an untruncated row, an
385
+ * unknown id, or a gateway that refused (a stale `sourceSeq` after a dormant
386
+ * rebuild 404s by design; re-attaching is what fixes that, not a retry). It
387
+ * never throws, because the caller is a press on a row and an exception there
388
+ * has nowhere sensible to go.
389
+ */
390
+ loadFullResult: (toolUseId: string) => Promise<boolean>;
316
391
  };
317
392
  /** Attach to a session and maintain live transcript state. Detaches on unmount. */
318
393
  declare function useClaudeSession(client: WorkerDeckClient, sessionId: string | undefined, options?: UseClaudeSessionOptions): UseClaudeSessionResult;
@@ -555,6 +630,10 @@ type UseHostFileTreeResult = {
555
630
  */
556
631
  declare function useHostFileTree(client: WorkerDeckClient, cwd: string | undefined): UseHostFileTreeResult;
557
632
  //#endregion
633
+ //#region src/hooks/use-project-icons.d.ts
634
+ type ClientForHost = (hostId: string) => WorkerDeckClient | undefined;
635
+ declare function useProjectIcons(rows: readonly SessionRow[], clientFor: ClientForHost): Record<string, string>;
636
+ //#endregion
558
637
  //#region src/hooks/use-profile-usage.d.ts
559
638
  type UseProfileUsageOptions = {
560
639
  /** How often to re-ask while enabled. Default 60s. */intervalMs?: number;
@@ -900,5 +979,5 @@ declare function summarizeSince(state: RecapInput, fromIndex: number): RecapSumm
900
979
  */
901
980
  declare function recapLine(summary: RecapSummary): string | undefined;
902
981
  //#endregion
903
- export { type AttachmentKind, type ConnectionState, type HostDirState, type HostTreeRow, type OpenFile, type OpenFilesAction, type OpenFilesState, type ProducedFileRef, type PromptToken, REPLAY_HOLD_MAX_MS, type RecapInput, type RecapSummary, type StagedAttachment, type ToolCallHostOptions, type ToolHostExecution, type ToolHostRunner, type TranscriptItem, type TranscriptState, type UseAttachmentsOptions, type UseAttachmentsResult, type UseClaudeSessionOptions, type UseClaudeSessionResult, type UseHostFileRootsResult, type UseHostFileSearchResult, type UseHostFileTreeResult, type UseOpenFilesResult, type UseProfileUsageOptions, type UseProfileUsageResult, type UseSessionInfoResult, type UseToolCallHostOptions, ancestorsWithin, applyEvent, attachmentKind, clearTranscriptCache, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialReplayTarget, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, staleAttach, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useProfileUsage, useSessionInfo, useToolCallHost };
982
+ export { type AttachmentKind, type ClientForHost, type ConnectionState, type HostDirState, type HostTreeRow, type OpenFile, type OpenFilesAction, type OpenFilesState, type ProducedFileRef, type PromptToken, REPLAY_HOLD_MAX_MS, type RecapInput, type RecapSummary, type StagedAttachment, type ToolCallHostOptions, type ToolHostExecution, type ToolHostRunner, type TranscriptItem, type TranscriptState, type UseAttachmentsOptions, type UseAttachmentsResult, type UseClaudeSessionOptions, type UseClaudeSessionResult, type UseHostFileRootsResult, type UseHostFileSearchResult, type UseHostFileTreeResult, type UseOpenFilesResult, type UseProfileUsageOptions, type UseProfileUsageResult, type UseSessionInfoResult, type UseToolCallHostOptions, 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 };
904
983
  //# sourceMappingURL=index.d.mts.map
package/build/index.mjs CHANGED
@@ -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",
@@ -97,6 +132,40 @@ function rateLimitWindows(state) {
97
132
  updatedAt: state.rateLimitsUpdatedAt
98
133
  }, void 0));
99
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;
168
+ }
100
169
  function applyEvent(state, event) {
101
170
  if (event.seq <= state.lastSeq) return state;
102
171
  const base = {
@@ -181,7 +250,13 @@ function applyEvent(state, event) {
181
250
  status: isError ? "failed" : "settled",
182
251
  result: {
183
252
  text: blockText(toolResult.content),
184
- isError
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) }
185
260
  },
186
261
  ...event.patch && { patch: event.patch }
187
262
  } : item);
@@ -198,7 +273,8 @@ function applyEvent(state, event) {
198
273
  kind: "user",
199
274
  id: event.uuid ?? `user-${event.seq}`,
200
275
  text: slashCommandText(text) ?? text,
201
- attachments: event.attachments
276
+ attachments: event.attachments,
277
+ ...event.parentToolUseId != null && { parentToolUseId: event.parentToolUseId }
202
278
  });
203
279
  }
204
280
  return {
@@ -207,8 +283,10 @@ function applyEvent(state, event) {
207
283
  };
208
284
  }
209
285
  case "assistant_message": {
210
- let streamedThinking = base.items.find((item) => item.kind === "thinking" && item.id === STREAMING_THINKING_ID)?.text ?? "";
211
- 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));
212
290
  contentToBlocks(event.message.content).forEach((block, index) => {
213
291
  const id = `${event.uuid}-${index}`;
214
292
  if (block.type === "text") items = upsert(items, {
@@ -249,10 +327,11 @@ function applyEvent(state, event) {
249
327
  const delta = event.event;
250
328
  if (delta.type !== "content_block_delta") return base;
251
329
  if (delta.delta?.type === "text_delta") {
330
+ const id = streamingTextId(event.parentToolUseId);
252
331
  const item = {
253
332
  kind: "assistant_text",
254
- id: STREAMING_ID,
255
- 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 ?? ""),
256
335
  streaming: true,
257
336
  parentToolUseId: event.parentToolUseId
258
337
  };
@@ -262,11 +341,12 @@ function applyEvent(state, event) {
262
341
  };
263
342
  }
264
343
  if (delta.delta?.type === "thinking_delta") {
265
- const text = (base.items.find((item) => item.kind === "thinking" && item.id === STREAMING_THINKING_ID)?.text ?? "") + (delta.delta.thinking ?? "");
344
+ const id = streamingThinkingId(event.parentToolUseId);
345
+ const text = (base.items.find((item) => item.kind === "thinking" && item.id === id)?.text ?? "") + (delta.delta.thinking ?? "");
266
346
  if (text.trim() === "") return base;
267
347
  const item = {
268
348
  kind: "thinking",
269
- id: STREAMING_THINKING_ID,
349
+ id,
270
350
  text,
271
351
  parentToolUseId: event.parentToolUseId
272
352
  };
@@ -280,14 +360,18 @@ function applyEvent(state, event) {
280
360
  case "turn_result": return {
281
361
  ...base,
282
362
  totalCostUsd: event.totalCostUsd,
283
- items: [...base.items.map((item) => item.kind === "assistant_text" && item.id === STREAMING_ID ? {
284
- ...item,
285
- id: `text-${event.seq}`,
286
- streaming: false
287
- } : item.kind === "thinking" && item.id === STREAMING_THINKING_ID ? {
288
- ...item,
289
- id: `thinking-${event.seq}`
290
- } : item), {
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
+ }), {
291
375
  kind: "turn_result",
292
376
  id: `turn-${event.seq}`,
293
377
  subtype: event.subtype,
@@ -437,11 +521,65 @@ function clearTranscriptCache() {
437
521
  entries.clear();
438
522
  }
439
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
440
577
  //#region src/hooks/use-session.ts
441
578
  /** Session events drive the reducer; the attach snapshot seeds fields (permission
442
579
  * mode, model) that a promptless session's event stream doesn't carry yet. */
443
580
  function reduce(state, action) {
444
581
  if (action.type === "transcript_seed") return action.state;
582
+ if (action.type === "transcript_hydrate_result") return hydrateToolResult(state, action.toolUseId, action.text);
445
583
  return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
446
584
  }
447
585
  /** Failed attempts in a row before "reconnecting…" stops being the honest word.
@@ -531,26 +669,34 @@ function useClaudeSession(client, sessionId, options) {
531
669
  optionsRef.current = options;
532
670
  const stateRef = useRef(state);
533
671
  stateRef.current = state;
534
- const seededForRef = useRef(`0:${sessionId === void 0 ? "" : transcriptCacheKey(client, sessionId)}`);
672
+ const seededForRef = useRef(attachSeedToken(0, sessionId === void 0 ? "" : transcriptCacheKey(client, sessionId)));
535
673
  const skipCacheRef = useRef(false);
536
674
  useEffect(() => {
537
675
  if (!sessionId) return;
538
676
  const cache = optionsRef.current?.cacheTranscript !== false;
539
677
  const key = transcriptCacheKey(client, sessionId);
540
- const seedToken = `${resyncSeq}:${key}`;
541
- const warm = cache && !skipCacheRef.current ? readTranscriptCache(key) : void 0;
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
+ });
542
687
  skipCacheRef.current = false;
543
- let held;
544
- if (seededForRef.current === seedToken) held = stateRef.current;
545
- else {
546
- held = warm ?? initialTranscriptState;
688
+ if (plan.seed) {
547
689
  dispatch({
548
690
  type: "transcript_seed",
549
- state: held
691
+ state: plan.held
550
692
  });
551
- seededForRef.current = seedToken;
693
+ seededForRef.current = plan.seedToken;
552
694
  }
553
- const handle = client.attach(sessionId, held.lastSeq > 0 ? { afterSeq: held.lastSeq } : {});
695
+ const handle = client.attach(sessionId, {
696
+ truncateResults: true,
697
+ imageRefs: true,
698
+ ...plan.afterSeq === void 0 ? {} : { afterSeq: plan.afterSeq }
699
+ });
554
700
  handleRef.current = handle;
555
701
  setHandleState(handle);
556
702
  const offEvent = handle.on("event", (event) => dispatch(event));
@@ -584,7 +730,11 @@ function useClaudeSession(client, sessionId, options) {
584
730
  setProtocolMismatch(void 0);
585
731
  setReplayTarget(void 0);
586
732
  const parting = stateRef.current;
587
- if (cache && !skipCacheRef.current && parting.lastSeq > 0 && parting.session) writeTranscriptCache(key, parting);
733
+ if (shouldWriteParting({
734
+ cacheEnabled: cache,
735
+ skipCache: skipCacheRef.current,
736
+ parting
737
+ })) writeTranscriptCache(key, parting);
588
738
  };
589
739
  }, [
590
740
  client,
@@ -603,6 +753,23 @@ function useClaudeSession(client, sessionId, options) {
603
753
  const connected = connection === "live";
604
754
  const replaying = replayTarget !== void 0 && state.lastSeq < replayTarget;
605
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]);
606
773
  return useMemo(() => ({
607
774
  state,
608
775
  connected,
@@ -619,7 +786,8 @@ function useClaudeSession(client, sessionId, options) {
619
786
  setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
620
787
  setModel: (model) => handleRef.current?.setModel(model),
621
788
  closeSession: () => handleRef.current?.closeSession(),
622
- reconnectNow
789
+ reconnectNow,
790
+ loadFullResult
623
791
  }), [
624
792
  state,
625
793
  connected,
@@ -628,7 +796,8 @@ function useClaudeSession(client, sessionId, options) {
628
796
  protocolMismatch,
629
797
  models,
630
798
  handleState,
631
- reconnectNow
799
+ reconnectNow,
800
+ loadFullResult
632
801
  ]);
633
802
  }
634
803
  /**
@@ -1196,6 +1365,66 @@ function useHostFileTree(client, cwd) {
1196
1365
  };
1197
1366
  }
1198
1367
  //#endregion
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
1199
1428
  //#region src/hooks/use-profile-usage.ts
1200
1429
  /**
1201
1430
  * The gateway's per-profile plan usage, over REST.
@@ -1885,6 +2114,6 @@ function plural(count, one, many = `${one}s`) {
1885
2114
  return `${count} ${count === 1 ? one : many}`;
1886
2115
  }
1887
2116
  //#endregion
1888
- export { REPLAY_HOLD_MAX_MS, ancestorsWithin, applyEvent, attachmentKind, clearTranscriptCache, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialReplayTarget, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, staleAttach, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useProfileUsage, 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 };
1889
2118
 
1890
2119
  //# sourceMappingURL=index.mjs.map