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