@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 +17 -0
- package/build/index.d.mts +95 -2
- package/build/index.mjs +261 -31
- 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;
|
|
@@ -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
|
-
|
|
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, {
|
|
@@ -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
|
|
255
|
-
text: (base.items.find((item) => item.kind === "assistant_text" && item.id ===
|
|
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
|
|
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
|
|
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) =>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
|
541
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|