@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 +17 -0
- package/build/index.d.mts +81 -2
- package/build/index.mjs +259 -30
- package/build/index.mjs.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
211
|
-
|
|
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
|
|
255
|
-
text: (base.items.find((item) => item.kind === "assistant_text" && item.id ===
|
|
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
|
|
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
|
|
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) =>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
|
541
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|