@workerdeck/react 0.16.0 → 0.18.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;
@@ -29,6 +41,20 @@ type TranscriptItem = {
29
41
  name: string;
30
42
  input: unknown;
31
43
  parentToolUseId: string | null;
44
+ /**
45
+ * When the model called it — the event's own `ts`, so it is replay-stable
46
+ * rather than a receive time (the mistake `rateLimitsUpdatedAt` makes on
47
+ * iOS). Optional because it is stamped at creation only: an item
48
+ * reconstructed by an older path has none, and absent must read as "no
49
+ * elapsed" rather than as the epoch.
50
+ *
51
+ * Added for the sub-agent takeover's header, which is the one surface that
52
+ * has to say how long an agent has been going: `SubagentInfo.startedAt`
53
+ * cannot answer it, being frozen at attach for anything spawned later.
54
+ * Immutable after creation, which is what makes it safe for iOS's
55
+ * `Equatable` row-plan cache key to mirror later.
56
+ */
57
+ ts?: number;
32
58
  /**
33
59
  * - `running` — the model called it; execution has not been reported
34
60
  * - `pending` — dispatched to an executor (bridged to this client, queued)
@@ -39,9 +65,45 @@ type TranscriptItem = {
39
65
  * deferred call has no result yet and is not the same as a running one.
40
66
  */
41
67
  status: 'running' | 'pending' | 'deferred' | 'settled' | 'failed';
68
+ /**
69
+ * `truncated`/`totalChars`/`sourceSeq` are set **only** when the replay
70
+ * delivered a head (protocol's {@link ToolResultBlock.truncated}), so
71
+ * every other result stays byte-identical to what it was before this
72
+ * feature existed. That matters beyond tidiness: on iOS `ToolCallItem` is
73
+ * `Equatable` and is half the row-plan cache key.
74
+ *
75
+ * `sourceSeq` is what makes the press possible at all — the item is what a
76
+ * renderer holds, and it must be able to name the event to fetch. It goes
77
+ * away again on hydration, along with the other two, so a hydrated result
78
+ * is indistinguishable from one that was never cut.
79
+ */
42
80
  result?: {
43
81
  text: string;
44
82
  isError: boolean;
83
+ truncated?: boolean;
84
+ totalChars?: number;
85
+ sourceSeq?: number;
86
+ /**
87
+ * The pictures this result carried, as addresses rather than bytes —
88
+ * set **only** when the replay delivered `image_ref` parts, so every
89
+ * other result stays byte-identical (the `Equatable` argument above,
90
+ * again).
91
+ *
92
+ * Each entry carries its **own** `sourceSeq`, which is not redundant
93
+ * with the one beside it: that one is cleared by text hydration, and a
94
+ * reader who pressed "show everything" must still be able to load the
95
+ * screenshot afterwards.
96
+ *
97
+ * Raw base64 `image` parts are still dropped on arrival, as they always
98
+ * were. Folding them in would pin megabytes inside `TranscriptState`,
99
+ * which the transcript LRU then retains across session switches.
100
+ */
101
+ images?: ReadonlyArray<{
102
+ partIndex: number;
103
+ mediaType: string;
104
+ bytes: number;
105
+ sourceSeq: number;
106
+ }>;
45
107
  };
46
108
  /**
47
109
  * What this call changed on disk, when it was a file edit — the engine's
@@ -172,6 +234,22 @@ declare function seedFromSessionInfo(state: TranscriptState, info: SessionInfo):
172
234
  * described two ways. This stays as the transcript-shaped door to it.
173
235
  */
174
236
  declare function rateLimitWindows(state: TranscriptState): UsageWindowRow[];
237
+ /**
238
+ * Put a fetched tool result back where its head was — the other half of
239
+ * `truncateResults`.
240
+ *
241
+ * Into **transcript state**, not row-local state, and the three reasons are the
242
+ * design: the copy button then copies the whole thing rather than the head, the
243
+ * transcript cache retains it across a session switch, and no later event can
244
+ * re-truncate it. The markers are cleared, so a hydrated result is
245
+ * indistinguishable from one that was never cut and every renderer needs a
246
+ * branch for exactly one state, not two.
247
+ *
248
+ * Keyed on `toolUseId`, which is the id the row already holds; `seq` is what the
249
+ * *fetch* needed, not what the fold needs. Unknown id returns `state` unchanged
250
+ * — a press answered after the session was cleared must not resurrect a row.
251
+ */
252
+ declare function hydrateToolResult(state: TranscriptState, toolUseId: string, text: string): TranscriptState;
175
253
  declare function applyEvent(state: TranscriptState, event: SessionEvent): TranscriptState;
176
254
  //#endregion
177
255
  //#region src/hooks/use-session.d.ts
@@ -313,6 +391,17 @@ type UseClaudeSessionResult = {
313
391
  setModel: (model?: string) => void;
314
392
  closeSession: () => void; /** Skip the reconnect backoff — what a tab returning to the foreground does. */
315
393
  reconnectNow: () => void;
394
+ /**
395
+ * Fetch the whole of a tool result the replay delivered as a head, and put it
396
+ * back on its row (`result.truncated` clears with it).
397
+ *
398
+ * Resolves `false` when there was nothing to do — an untruncated row, an
399
+ * unknown id, or a gateway that refused (a stale `sourceSeq` after a dormant
400
+ * rebuild 404s by design; re-attaching is what fixes that, not a retry). It
401
+ * never throws, because the caller is a press on a row and an exception there
402
+ * has nowhere sensible to go.
403
+ */
404
+ loadFullResult: (toolUseId: string) => Promise<boolean>;
316
405
  };
317
406
  /** Attach to a session and maintain live transcript state. Detaches on unmount. */
318
407
  declare function useClaudeSession(client: WorkerDeckClient, sessionId: string | undefined, options?: UseClaudeSessionOptions): UseClaudeSessionResult;
@@ -555,6 +644,10 @@ type UseHostFileTreeResult = {
555
644
  */
556
645
  declare function useHostFileTree(client: WorkerDeckClient, cwd: string | undefined): UseHostFileTreeResult;
557
646
  //#endregion
647
+ //#region src/hooks/use-project-icons.d.ts
648
+ type ClientForHost = (hostId: string) => WorkerDeckClient | undefined;
649
+ declare function useProjectIcons(rows: readonly SessionRow[], clientFor: ClientForHost): Record<string, string>;
650
+ //#endregion
558
651
  //#region src/hooks/use-profile-usage.d.ts
559
652
  type UseProfileUsageOptions = {
560
653
  /** How often to re-ask while enabled. Default 60s. */intervalMs?: number;
@@ -900,5 +993,5 @@ declare function summarizeSince(state: RecapInput, fromIndex: number): RecapSumm
900
993
  */
901
994
  declare function recapLine(summary: RecapSummary): string | undefined;
902
995
  //#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 };
996
+ 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
997
  //# 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, {
@@ -236,7 +314,8 @@ function applyEvent(state, event) {
236
314
  name: toolUse.name,
237
315
  input: toolUse.input,
238
316
  parentToolUseId: event.parentToolUseId,
239
- status: "running"
317
+ status: "running",
318
+ ts: event.ts
240
319
  });
241
320
  }
242
321
  });
@@ -249,10 +328,11 @@ function applyEvent(state, event) {
249
328
  const delta = event.event;
250
329
  if (delta.type !== "content_block_delta") return base;
251
330
  if (delta.delta?.type === "text_delta") {
331
+ const id = streamingTextId(event.parentToolUseId);
252
332
  const item = {
253
333
  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 ?? ""),
334
+ id,
335
+ text: (base.items.find((item) => item.kind === "assistant_text" && item.id === id)?.text ?? "") + (delta.delta.text ?? ""),
256
336
  streaming: true,
257
337
  parentToolUseId: event.parentToolUseId
258
338
  };
@@ -262,11 +342,12 @@ function applyEvent(state, event) {
262
342
  };
263
343
  }
264
344
  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 ?? "");
345
+ const id = streamingThinkingId(event.parentToolUseId);
346
+ const text = (base.items.find((item) => item.kind === "thinking" && item.id === id)?.text ?? "") + (delta.delta.thinking ?? "");
266
347
  if (text.trim() === "") return base;
267
348
  const item = {
268
349
  kind: "thinking",
269
- id: STREAMING_THINKING_ID,
350
+ id,
270
351
  text,
271
352
  parentToolUseId: event.parentToolUseId
272
353
  };
@@ -280,14 +361,18 @@ function applyEvent(state, event) {
280
361
  case "turn_result": return {
281
362
  ...base,
282
363
  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), {
364
+ items: [...base.items.map((item) => {
365
+ if (!isStreamingItem(item)) return item;
366
+ const agent = "parentToolUseId" in item && item.parentToolUseId ? `-${item.parentToolUseId}` : "";
367
+ return item.kind === "assistant_text" ? {
368
+ ...item,
369
+ id: `text-${event.seq}${agent}`,
370
+ streaming: false
371
+ } : {
372
+ ...item,
373
+ id: `thinking-${event.seq}${agent}`
374
+ };
375
+ }), {
291
376
  kind: "turn_result",
292
377
  id: `turn-${event.seq}`,
293
378
  subtype: event.subtype,
@@ -437,11 +522,65 @@ function clearTranscriptCache() {
437
522
  entries.clear();
438
523
  }
439
524
  //#endregion
525
+ //#region src/lib/attach-plan.ts
526
+ /**
527
+ * The attach effect's decisions, pure.
528
+ *
529
+ * `useClaudeSession` is never rendered in tests — this package deliberately
530
+ * carries no jsdom and no testing-library — so the logic that used to live
531
+ * inline in the attach effect (which state an attach holds, whether the
532
+ * reducer must be re-seeded, which `afterSeq` to request, whether the parting
533
+ * state may go back into the cache) is decided here, where plain vitest
534
+ * reaches it, and the effect keeps only glue: read its refs into inputs,
535
+ * apply the returned instructions, subscribe. The refs themselves stay in the
536
+ * hook — a decision function that owned React state would be the untestable
537
+ * thing again — so everything stateful arrives as a value and leaves as an
538
+ * instruction.
539
+ */
540
+ /**
541
+ * Which (resync, client identity, session) a reducer state was seeded for.
542
+ * One format, shared by the hook's mount initializer and {@link planAttach},
543
+ * so the two sites cannot drift: a token that dropped `resyncSeq` would leave
544
+ * the stale-log retry looking already-seeded, and the fresh replay would
545
+ * compose into the condemned state the resync just discarded.
546
+ */
547
+ function attachSeedToken(resyncSeq, key) {
548
+ return `${resyncSeq}:${key}`;
549
+ }
550
+ /**
551
+ * Decide what one run of the attach effect does before it opens the socket.
552
+ */
553
+ function planAttach(input) {
554
+ const seedToken = attachSeedToken(input.resyncSeq, input.key);
555
+ const warm = input.cacheEnabled && !input.skipCache ? input.warm : void 0;
556
+ const seed = input.seededFor !== seedToken;
557
+ const held = seed ? warm ?? initialTranscriptState : input.current;
558
+ return {
559
+ held,
560
+ seed,
561
+ seedToken,
562
+ ...held.lastSeq > 0 ? { afterSeq: held.lastSeq } : {}
563
+ };
564
+ }
565
+ /**
566
+ * Whether the effect's cleanup may keep the parting transcript warm for a
567
+ * switch-back. Refused when caching is off; after a stale-log detection —
568
+ * writing the condemned state back would re-poison the very retry that just
569
+ * discarded it; and when there is nothing real to keep — `lastSeq === 0` also
570
+ * protects an existing entry from being clobbered by a mount that never
571
+ * finished attaching, and a state with no `session` never saw its attached
572
+ * frame at all.
573
+ */
574
+ function shouldWriteParting(input) {
575
+ return input.cacheEnabled && !input.skipCache && input.parting.lastSeq > 0 && input.parting.session !== void 0;
576
+ }
577
+ //#endregion
440
578
  //#region src/hooks/use-session.ts
441
579
  /** Session events drive the reducer; the attach snapshot seeds fields (permission
442
580
  * mode, model) that a promptless session's event stream doesn't carry yet. */
443
581
  function reduce(state, action) {
444
582
  if (action.type === "transcript_seed") return action.state;
583
+ if (action.type === "transcript_hydrate_result") return hydrateToolResult(state, action.toolUseId, action.text);
445
584
  return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
446
585
  }
447
586
  /** Failed attempts in a row before "reconnecting…" stops being the honest word.
@@ -531,26 +670,34 @@ function useClaudeSession(client, sessionId, options) {
531
670
  optionsRef.current = options;
532
671
  const stateRef = useRef(state);
533
672
  stateRef.current = state;
534
- const seededForRef = useRef(`0:${sessionId === void 0 ? "" : transcriptCacheKey(client, sessionId)}`);
673
+ const seededForRef = useRef(attachSeedToken(0, sessionId === void 0 ? "" : transcriptCacheKey(client, sessionId)));
535
674
  const skipCacheRef = useRef(false);
536
675
  useEffect(() => {
537
676
  if (!sessionId) return;
538
677
  const cache = optionsRef.current?.cacheTranscript !== false;
539
678
  const key = transcriptCacheKey(client, sessionId);
540
- const seedToken = `${resyncSeq}:${key}`;
541
- const warm = cache && !skipCacheRef.current ? readTranscriptCache(key) : void 0;
679
+ const plan = planAttach({
680
+ resyncSeq,
681
+ key,
682
+ seededFor: seededForRef.current,
683
+ current: stateRef.current,
684
+ cacheEnabled: cache,
685
+ skipCache: skipCacheRef.current,
686
+ warm: readTranscriptCache(key)
687
+ });
542
688
  skipCacheRef.current = false;
543
- let held;
544
- if (seededForRef.current === seedToken) held = stateRef.current;
545
- else {
546
- held = warm ?? initialTranscriptState;
689
+ if (plan.seed) {
547
690
  dispatch({
548
691
  type: "transcript_seed",
549
- state: held
692
+ state: plan.held
550
693
  });
551
- seededForRef.current = seedToken;
694
+ seededForRef.current = plan.seedToken;
552
695
  }
553
- const handle = client.attach(sessionId, held.lastSeq > 0 ? { afterSeq: held.lastSeq } : {});
696
+ const handle = client.attach(sessionId, {
697
+ truncateResults: true,
698
+ imageRefs: true,
699
+ ...plan.afterSeq === void 0 ? {} : { afterSeq: plan.afterSeq }
700
+ });
554
701
  handleRef.current = handle;
555
702
  setHandleState(handle);
556
703
  const offEvent = handle.on("event", (event) => dispatch(event));
@@ -584,7 +731,11 @@ function useClaudeSession(client, sessionId, options) {
584
731
  setProtocolMismatch(void 0);
585
732
  setReplayTarget(void 0);
586
733
  const parting = stateRef.current;
587
- if (cache && !skipCacheRef.current && parting.lastSeq > 0 && parting.session) writeTranscriptCache(key, parting);
734
+ if (shouldWriteParting({
735
+ cacheEnabled: cache,
736
+ skipCache: skipCacheRef.current,
737
+ parting
738
+ })) writeTranscriptCache(key, parting);
588
739
  };
589
740
  }, [
590
741
  client,
@@ -603,6 +754,23 @@ function useClaudeSession(client, sessionId, options) {
603
754
  const connected = connection === "live";
604
755
  const replaying = replayTarget !== void 0 && state.lastSeq < replayTarget;
605
756
  const reconnectNow = useCallback(() => handleRef.current?.reconnectNow(), []);
757
+ const loadFullResult = useCallback(async (toolUseId) => {
758
+ if (!sessionId) return false;
759
+ const item = stateRef.current.items.find((candidate) => candidate.kind === "tool_call" && candidate.id === toolUseId);
760
+ const result = item?.kind === "tool_call" ? item.result : void 0;
761
+ if (!result?.truncated || result.sourceSeq === void 0) return false;
762
+ try {
763
+ const full = await client.toolResult(sessionId, result.sourceSeq, toolUseId);
764
+ dispatch({
765
+ type: "transcript_hydrate_result",
766
+ toolUseId,
767
+ text: typeof full.content === "string" ? full.content : (full.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n")
768
+ });
769
+ return true;
770
+ } catch {
771
+ return false;
772
+ }
773
+ }, [client, sessionId]);
606
774
  return useMemo(() => ({
607
775
  state,
608
776
  connected,
@@ -619,7 +787,8 @@ function useClaudeSession(client, sessionId, options) {
619
787
  setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
620
788
  setModel: (model) => handleRef.current?.setModel(model),
621
789
  closeSession: () => handleRef.current?.closeSession(),
622
- reconnectNow
790
+ reconnectNow,
791
+ loadFullResult
623
792
  }), [
624
793
  state,
625
794
  connected,
@@ -628,7 +797,8 @@ function useClaudeSession(client, sessionId, options) {
628
797
  protocolMismatch,
629
798
  models,
630
799
  handleState,
631
- reconnectNow
800
+ reconnectNow,
801
+ loadFullResult
632
802
  ]);
633
803
  }
634
804
  /**
@@ -1196,6 +1366,66 @@ function useHostFileTree(client, cwd) {
1196
1366
  };
1197
1367
  }
1198
1368
  //#endregion
1369
+ //#region src/hooks/use-project-icons.ts
1370
+ /**
1371
+ * Project icon bytes for a list of sessions, as object URLs keyed by the icon's
1372
+ * own content hash.
1373
+ *
1374
+ * **Keyed by hash, and cached for the life of the page.** That is what the
1375
+ * wire's `ProjectIcon.image.hash` is for: every session in one project serves
1376
+ * identical bytes, so twelve rows of one repo cost one request, and two
1377
+ * *different* projects that happen to declare the same file cost one between
1378
+ * them. A hash names its bytes, so an entry can never go stale — editing the
1379
+ * icon changes the hash, which arrives on the next poll as a key this cache has
1380
+ * not seen. The old entry is dead weight rather than a wrong answer, and the
1381
+ * population is bounded by how many distinct icons an operator has open.
1382
+ *
1383
+ * The cache is **module scope on purpose**, like `useSessions`' store: the
1384
+ * sidebar and any other surface rendering rows mount this at once, and a
1385
+ * per-hook cache would be N copies each fetching the same bytes.
1386
+ *
1387
+ * A failure is cached as a failure. The route's 404 is the uniform "no icon"
1388
+ * (no project, a glyph, or one the gateway refused), so retrying it every poll
1389
+ * would be a request per session per poll for a picture that is never coming.
1390
+ *
1391
+ * Object URLs are never revoked, which is the same decision stated twice: they
1392
+ * are the cache. Revoking one would break every row still pointing at it, and
1393
+ * the whole point of hashing is that nothing here is ever superseded.
1394
+ *
1395
+ * The VS Code extension has the same three-set structure in `project-icons.ts`
1396
+ * and cannot share this one — its webview has no external `connect-src` at all,
1397
+ * so its bytes arrive as data URLs pushed from the extension host. One design,
1398
+ * two implementations, for a reason that is in the transport rather than here.
1399
+ */
1400
+ const byHash = /* @__PURE__ */ new Map();
1401
+ const inFlight = /* @__PURE__ */ new Set();
1402
+ const failed = /* @__PURE__ */ new Set();
1403
+ function useProjectIcons(rows, clientFor) {
1404
+ const [resolved, setResolved] = useState(() => Object.fromEntries(byHash));
1405
+ useEffect(() => {
1406
+ let alive = true;
1407
+ for (const row of rows) {
1408
+ const icon = row.info.project?.icon;
1409
+ if (icon?.type !== "image") continue;
1410
+ const { hash } = icon;
1411
+ if (byHash.has(hash) || inFlight.has(hash) || failed.has(hash)) continue;
1412
+ const client = clientFor(row.hostId);
1413
+ if (!client) continue;
1414
+ inFlight.add(hash);
1415
+ client.projectIcon(row.info.id).then((blob) => {
1416
+ byHash.set(hash, URL.createObjectURL(blob));
1417
+ if (alive) setResolved(Object.fromEntries(byHash));
1418
+ }).catch(() => {
1419
+ failed.add(hash);
1420
+ }).finally(() => inFlight.delete(hash));
1421
+ }
1422
+ return () => {
1423
+ alive = false;
1424
+ };
1425
+ }, [rows, clientFor]);
1426
+ return resolved;
1427
+ }
1428
+ //#endregion
1199
1429
  //#region src/hooks/use-profile-usage.ts
1200
1430
  /**
1201
1431
  * The gateway's per-profile plan usage, over REST.
@@ -1885,6 +2115,6 @@ function plural(count, one, many = `${one}s`) {
1885
2115
  return `${count} ${count === 1 ? one : many}`;
1886
2116
  }
1887
2117
  //#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 };
2118
+ 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
2119
 
1890
2120
  //# sourceMappingURL=index.mjs.map