@workerdeck/react 0.23.0 → 1.1.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/build/index.d.mts +91 -713
- package/build/index.mjs +211 -533
- package/build/index.mjs.map +1 -1
- package/package.json +14 -14
package/build/index.mjs
CHANGED
|
@@ -10,40 +10,27 @@ 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
|
-
*/
|
|
29
13
|
const STREAMING_ID = "streaming";
|
|
30
14
|
const STREAMING_THINKING_ID = "streaming-thinking";
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
15
|
+
const LOCAL_COMMAND_OUTPUT = /^<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>$/;
|
|
16
|
+
const COMMAND_NAME = /<command-name>([\s\S]*?)<\/command-name>/;
|
|
17
|
+
const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
|
|
18
|
+
function streamingTextId(parentToolUseId) {
|
|
19
|
+
return parentToolUseId == null ? STREAMING_ID : `${STREAMING_ID}:${parentToolUseId}`;
|
|
20
|
+
}
|
|
21
|
+
function streamingThinkingId(parentToolUseId) {
|
|
22
|
+
return parentToolUseId == null ? STREAMING_THINKING_ID : `${STREAMING_THINKING_ID}:${parentToolUseId}`;
|
|
23
|
+
}
|
|
24
|
+
function isStreamingItem(item) {
|
|
25
|
+
return item.kind === "assistant_text" && item.id.startsWith(STREAMING_ID) || item.kind === "thinking" && item.id.startsWith(STREAMING_THINKING_ID);
|
|
26
|
+
}
|
|
37
27
|
function blockText(content) {
|
|
38
28
|
if (content === void 0) return "";
|
|
39
29
|
if (typeof content === "string") return content;
|
|
40
30
|
return content.map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
|
|
41
31
|
}
|
|
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
32
|
function imageRefsOf(content, seq) {
|
|
46
|
-
if (!Array.isArray(content)) return
|
|
33
|
+
if (!Array.isArray(content)) return;
|
|
47
34
|
const refs = content.flatMap((part) => part.type === "image_ref" ? [{
|
|
48
35
|
partIndex: Number(part.part_index),
|
|
49
36
|
mediaType: String(part.media_type ?? "application/octet-stream"),
|
|
@@ -58,7 +45,6 @@ function contentToBlocks(content) {
|
|
|
58
45
|
text: content
|
|
59
46
|
}] : content;
|
|
60
47
|
}
|
|
61
|
-
/** Render an execution's by-value output for the transcript. */
|
|
62
48
|
function outputText(output) {
|
|
63
49
|
if (output.type === "text") return output.value;
|
|
64
50
|
try {
|
|
@@ -67,26 +53,9 @@ function outputText(output) {
|
|
|
67
53
|
return String(output.value);
|
|
68
54
|
}
|
|
69
55
|
}
|
|
70
|
-
/** CLI-side command output arrives as user text wrapped in local-command tags. */
|
|
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
56
|
function slashCommandText(text) {
|
|
88
57
|
const name = COMMAND_NAME.exec(text)?.[1]?.trim();
|
|
89
|
-
if (!name) return
|
|
58
|
+
if (!name) return;
|
|
90
59
|
const args = COMMAND_ARGS.exec(text)?.[1]?.trim();
|
|
91
60
|
return args ? `${name} ${args}` : name;
|
|
92
61
|
}
|
|
@@ -97,12 +66,6 @@ function upsert(items, item) {
|
|
|
97
66
|
next[index] = item;
|
|
98
67
|
return next;
|
|
99
68
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Seed transcript state from the attach snapshot (the `attached` frame's SessionInfo).
|
|
102
|
-
* A promptless session emits no `system_init` until its first message, so fields like
|
|
103
|
-
* `permissionMode` and `model` would otherwise stay empty — fill only what events
|
|
104
|
-
* haven't set yet; the event stream stays authoritative.
|
|
105
|
-
*/
|
|
106
69
|
function seedFromSessionInfo(state, info) {
|
|
107
70
|
const engine = info.engine ?? state.engine;
|
|
108
71
|
return {
|
|
@@ -117,36 +80,12 @@ function seedFromSessionInfo(state, info) {
|
|
|
117
80
|
session: info
|
|
118
81
|
};
|
|
119
82
|
}
|
|
120
|
-
/**
|
|
121
|
-
* The session's rate-limit windows in reading order: the session window, the
|
|
122
|
-
* weekly window, then whichever per-model weekly windows it reports.
|
|
123
|
-
*
|
|
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.
|
|
128
|
-
*/
|
|
129
83
|
function rateLimitWindows(state) {
|
|
130
84
|
return orderUsageWindows(mergeUsage({
|
|
131
85
|
rateLimits: state.rateLimits,
|
|
132
86
|
updatedAt: state.rateLimitsUpdatedAt
|
|
133
87
|
}, void 0));
|
|
134
88
|
}
|
|
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
89
|
function hydrateToolResult(state, toolUseId, text) {
|
|
151
90
|
let changed = false;
|
|
152
91
|
const items = state.items.map((item) => {
|
|
@@ -458,98 +397,33 @@ function applyEvent(state, event) {
|
|
|
458
397
|
}
|
|
459
398
|
//#endregion
|
|
460
399
|
//#region src/lib/transcript-cache.ts
|
|
461
|
-
/**
|
|
462
|
-
* Module-scope cache of detached transcript states, so switching back to a
|
|
463
|
-
* recently viewed session paints its transcript in the mount frame and
|
|
464
|
-
* re-attaches with `afterSeq: lastSeq` — the wire replays only what happened
|
|
465
|
-
* while the panel was away, instead of the whole event log.
|
|
466
|
-
*
|
|
467
|
-
* Module-scope for the same reason `useSessions` and the watermarks are: the
|
|
468
|
-
* consumers that need it (the VS Code panel, the dashboard's session route)
|
|
469
|
-
* remount `SessionPanel` per session, so any per-hook copy would die with the
|
|
470
|
-
* unmount that is the entire point of surviving.
|
|
471
|
-
*
|
|
472
|
-
* Entries are the same `TranscriptState` objects the reducer held — retention,
|
|
473
|
-
* not duplication — and the bound is what keeps retention from becoming a
|
|
474
|
-
* leak. Eviction is least-recently-STORED: every detach stores, so store
|
|
475
|
-
* recency is viewing recency, and reads don't need to reorder.
|
|
476
|
-
*
|
|
477
|
-
* Keys come from {@link transcriptCacheKey} and carry the client's
|
|
478
|
-
* `identityKey` (gateway + auth headers), never the session id alone: a
|
|
479
|
-
* session id is unique only within one gateway, and an entry must never be
|
|
480
|
-
* readable through a client speaking as a different principal.
|
|
481
|
-
*/
|
|
482
|
-
/**
|
|
483
|
-
* How many detached transcripts stay warm.
|
|
484
|
-
*
|
|
485
|
-
* Five covers the working set the feature exists for — an operator alternating
|
|
486
|
-
* between the handful of sessions that are simultaneously working or awaiting
|
|
487
|
-
* them — while keeping the pathological case (five `perf`-fixture-sized
|
|
488
|
-
* transcripts of ~4k items each) in the tens of megabytes, no more than a few
|
|
489
|
-
* times what the one mounted panel already holds. Too small degrades to
|
|
490
|
-
* today's behaviour (a replay on switch-back); too large is memory held
|
|
491
|
-
* forever in a webview — the asymmetry favours small.
|
|
492
|
-
*/
|
|
493
400
|
const MAX_ENTRIES = 5;
|
|
494
|
-
const entries = /* @__PURE__ */ new Map();
|
|
495
|
-
/** Cache key for one session as seen through one (gateway, principal). The
|
|
496
|
-
* NUL separator is unambiguous: the identity key is `JSON.stringify` output,
|
|
497
|
-
* which escapes control characters, so no two (identity, session) pairs can
|
|
498
|
-
* spell the same key. */
|
|
401
|
+
const entries$1 = /* @__PURE__ */ new Map();
|
|
499
402
|
function transcriptCacheKey(client, sessionId) {
|
|
500
403
|
return `${client.identityKey}\u0000${sessionId}`;
|
|
501
404
|
}
|
|
502
405
|
function readTranscriptCache(key) {
|
|
503
|
-
return entries.get(key);
|
|
406
|
+
return entries$1.get(key);
|
|
504
407
|
}
|
|
505
408
|
function writeTranscriptCache(key, state) {
|
|
506
|
-
entries.delete(key);
|
|
507
|
-
entries.set(key, state);
|
|
508
|
-
if (entries.size > MAX_ENTRIES) {
|
|
509
|
-
const oldest = entries.keys().next().value;
|
|
510
|
-
if (oldest !== void 0) entries.delete(oldest);
|
|
409
|
+
entries$1.delete(key);
|
|
410
|
+
entries$1.set(key, state);
|
|
411
|
+
if (entries$1.size > MAX_ENTRIES) {
|
|
412
|
+
const oldest = entries$1.keys().next().value;
|
|
413
|
+
if (oldest !== void 0) entries$1.delete(oldest);
|
|
511
414
|
}
|
|
512
415
|
}
|
|
513
416
|
function deleteTranscriptCache(key) {
|
|
514
|
-
entries.delete(key);
|
|
417
|
+
entries$1.delete(key);
|
|
515
418
|
}
|
|
516
|
-
/**
|
|
517
|
-
* Drop every cached transcript. For an embedder changing principals in place
|
|
518
|
-
* (a logout that keeps the page alive) — entries are unreachable through the
|
|
519
|
-
* new principal's client either way, but scrubbing them is free and final.
|
|
520
|
-
*/
|
|
521
419
|
function clearTranscriptCache() {
|
|
522
|
-
entries.clear();
|
|
420
|
+
entries$1.clear();
|
|
523
421
|
}
|
|
524
422
|
//#endregion
|
|
525
423
|
//#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
424
|
function attachSeedToken(resyncSeq, key) {
|
|
548
425
|
return `${resyncSeq}:${key}`;
|
|
549
426
|
}
|
|
550
|
-
/**
|
|
551
|
-
* Decide what one run of the attach effect does before it opens the socket.
|
|
552
|
-
*/
|
|
553
427
|
function planAttach(input) {
|
|
554
428
|
const seedToken = attachSeedToken(input.resyncSeq, input.key);
|
|
555
429
|
const warm = input.cacheEnabled && !input.skipCache ? input.warm : void 0;
|
|
@@ -562,107 +436,31 @@ function planAttach(input) {
|
|
|
562
436
|
...held.lastSeq > 0 ? { afterSeq: held.lastSeq } : {}
|
|
563
437
|
};
|
|
564
438
|
}
|
|
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
439
|
function shouldWriteParting(input) {
|
|
575
440
|
return input.cacheEnabled && !input.skipCache && input.parting.lastSeq > 0 && input.parting.session !== void 0;
|
|
576
441
|
}
|
|
577
442
|
//#endregion
|
|
578
443
|
//#region src/hooks/use-session.ts
|
|
579
|
-
/** Session events drive the reducer; the attach snapshot seeds fields (permission
|
|
580
|
-
* mode, model) that a promptless session's event stream doesn't carry yet. */
|
|
581
444
|
function reduce(state, action) {
|
|
582
445
|
if (action.type === "transcript_seed") return action.state;
|
|
583
446
|
if (action.type === "transcript_hydrate_result") return hydrateToolResult(state, action.toolUseId, action.text);
|
|
584
447
|
return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
|
|
585
448
|
}
|
|
586
|
-
/** Failed attempts in a row before "reconnecting…" stops being the honest word.
|
|
587
|
-
* Three is ~3.5s of backoff — past a blip. Matches the iOS client. */
|
|
588
449
|
const OFFLINE_AFTER_ATTEMPTS = 3;
|
|
589
|
-
/**
|
|
590
|
-
* The seq the initial attach replay ends on, or undefined when there is nothing
|
|
591
|
-
* to hold for.
|
|
592
|
-
*
|
|
593
|
-
* This is an exact signal, not a heuristic: the `attached` frame is sent before
|
|
594
|
-
* any replayed `event` frame and carries the runner's seq at attach time
|
|
595
|
-
* (`session.lastSeq`), so the moment the frame arrives the client knows
|
|
596
|
-
* precisely which seq the replay ends on. Every runner keeps its full event log
|
|
597
|
-
* and always delivers the highest-seq event on a fresh replay (the
|
|
598
|
-
* `conversation_reset` skip is strictly-below-the-reset, and the reset's seq is
|
|
599
|
-
* itself ≤ lastSeq), so `TranscriptState.lastSeq >= target` means the replay
|
|
600
|
-
* has landed. No quiet window or other arrival heuristic belongs here.
|
|
601
|
-
*
|
|
602
|
-
* Only a FRESH attach yields a target (`replayingFrom === 0`): a reconnect
|
|
603
|
-
* replays into a transcript the reader is already looking at, and blanking it
|
|
604
|
-
* mid-turn would be a worse bug than the flicker the hold exists to fix. A
|
|
605
|
-
* brand-new session (`lastSeq === 0`) has nothing to replay and never holds.
|
|
606
|
-
*/
|
|
607
450
|
function initialReplayTarget(frame) {
|
|
608
451
|
return frame.replayingFrom === 0 && frame.session.lastSeq > 0 ? frame.session.lastSeq : void 0;
|
|
609
452
|
}
|
|
610
|
-
/**
|
|
611
|
-
* Whether an attach frame describes a DIFFERENT event log than the transcript
|
|
612
|
-
* `held` was built from — in which case attaching with `afterSeq: held.lastSeq`
|
|
613
|
-
* has already gone wrong: every event in the new log has seq ≤ afterSeq, so
|
|
614
|
-
* nothing will ever arrive and the stale rows would stand forever, with no
|
|
615
|
-
* error. The only recovery is to forget the state and re-attach from seq 0.
|
|
616
|
-
*
|
|
617
|
-
* A log resets on routine paths, not corner cases: a dormant session
|
|
618
|
-
* (claude/codex surviving a gateway restart) is rebuilt with a brand-new
|
|
619
|
-
* runner whose log starts at 0 and refills from the engine's own store. Two
|
|
620
|
-
* checks, each of which the other misses:
|
|
621
|
-
*
|
|
622
|
-
* - `session.lastSeq < held.lastSeq` — the server's log is shorter than what
|
|
623
|
-
* we hold. Within one log seq only grows, so this is proof of a reset. It
|
|
624
|
-
* catches a rebuilt runner that has not yet re-run far — but not one whose
|
|
625
|
-
* backfill already advanced past us.
|
|
626
|
-
* - `session.createdAt !== held.session.createdAt` — a different runner
|
|
627
|
-
* incarnation. The claude and codex runners stamp `Date.now()` at
|
|
628
|
-
* construction, so a dormant rebuild always changes it; the provider runner
|
|
629
|
-
* restores `createdAt` from its snapshot precisely when it also restores
|
|
630
|
-
* the event log and seq counter (ai-sdk-runner's `#restore`), so equality
|
|
631
|
-
* truthfully means "same log" for every engine.
|
|
632
|
-
*
|
|
633
|
-
* A full replay (`replayingFrom === 0`) is never stale — it carries the whole
|
|
634
|
-
* log, so the caller heals by resetting state and applying it — and holding
|
|
635
|
-
* nothing (`held.lastSeq === 0`) has nothing to be stale about. That first
|
|
636
|
-
* clause is also what makes the recovery loop-proof: the re-attach from 0 can
|
|
637
|
-
* never re-trigger this predicate.
|
|
638
|
-
*
|
|
639
|
-
* Not cache-specific: a live handle reconnecting after a gateway restart
|
|
640
|
-
* re-attaches with its own advanced `afterSeq` against the rebuilt log and
|
|
641
|
-
* hits the identical silence, so the hook applies this to every attach frame.
|
|
642
|
-
*/
|
|
643
453
|
function staleAttach(frame, held) {
|
|
644
454
|
if (frame.replayingFrom === 0 || held.lastSeq === 0) return false;
|
|
645
455
|
if (frame.session.lastSeq < held.lastSeq) return true;
|
|
646
456
|
return held.session !== void 0 && frame.session.createdAt !== held.session.createdAt;
|
|
647
457
|
}
|
|
648
|
-
/**
|
|
649
|
-
* Backstop for the replay hold: if the target seq has not landed after this
|
|
650
|
-
* long, reveal what has arrived. On a healthy attach the target is always
|
|
651
|
-
* reached (see {@link initialReplayTarget}); the backstop exists because a
|
|
652
|
-
* blank panel forever would be a much worse failure than a visible stream, so
|
|
653
|
-
* the hold is bounded no matter what a future filter or a lossy path does. It
|
|
654
|
-
* runs from the attach — a per-event re-arm would be a quiet-window heuristic
|
|
655
|
-
* in a new costume.
|
|
656
|
-
*/
|
|
657
458
|
const REPLAY_HOLD_MAX_MS = 1500;
|
|
658
|
-
/** Attach to a session and maintain live transcript state. Detaches on unmount. */
|
|
659
459
|
function useClaudeSession(client, sessionId, options) {
|
|
660
460
|
const [state, dispatch] = useReducer(reduce, void 0, () => (options?.cacheTranscript !== false && sessionId !== void 0 ? readTranscriptCache(transcriptCacheKey(client, sessionId)) : void 0) ?? initialTranscriptState);
|
|
661
461
|
const [connection, setConnection] = useState("reconnecting");
|
|
662
462
|
const [protocolMismatch, setProtocolMismatch] = useState();
|
|
663
|
-
/** Where the current attach's replay ends, while one is being held for. */
|
|
664
463
|
const [replayTarget, setReplayTarget] = useState();
|
|
665
|
-
/** Bumped to force a fresh attach from seq 0 after a stale-log detection. */
|
|
666
464
|
const [resyncSeq, setResyncSeq] = useState(0);
|
|
667
465
|
const [handleState, setHandleState] = useState();
|
|
668
466
|
const handleRef = useRef(null);
|
|
@@ -761,10 +559,11 @@ function useClaudeSession(client, sessionId, options) {
|
|
|
761
559
|
if (!result?.truncated || result.sourceSeq === void 0) return false;
|
|
762
560
|
try {
|
|
763
561
|
const full = await client.toolResult(sessionId, result.sourceSeq, toolUseId);
|
|
562
|
+
const text = typeof full.content === "string" ? full.content : (full.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
|
|
764
563
|
dispatch({
|
|
765
564
|
type: "transcript_hydrate_result",
|
|
766
565
|
toolUseId,
|
|
767
|
-
text
|
|
566
|
+
text
|
|
768
567
|
});
|
|
769
568
|
return true;
|
|
770
569
|
} catch {
|
|
@@ -802,15 +601,6 @@ function useClaudeSession(client, sessionId, options) {
|
|
|
802
601
|
loadFullResult
|
|
803
602
|
]);
|
|
804
603
|
}
|
|
805
|
-
/**
|
|
806
|
-
* The session's profile catalog, fetched once and only when it could matter —
|
|
807
|
-
* i.e. when the engine has reported no models of its own.
|
|
808
|
-
*
|
|
809
|
-
* Fire-and-forget on purpose: an empty catalog is exactly the state a picker
|
|
810
|
-
* already handles, so a failed or 404'd `/profiles` (a server predating them)
|
|
811
|
-
* degrades to the old behaviour rather than raising an error about a list the
|
|
812
|
-
* operator may never open.
|
|
813
|
-
*/
|
|
814
604
|
function useProfileModelFallback(client, sessionId, state) {
|
|
815
605
|
const [catalog, setCatalog] = useState([]);
|
|
816
606
|
const profile = state.session?.profile;
|
|
@@ -834,21 +624,102 @@ function useProfileModelFallback(client, sessionId, state) {
|
|
|
834
624
|
return hasReported ? reported : catalog;
|
|
835
625
|
}
|
|
836
626
|
//#endregion
|
|
837
|
-
//#region src/
|
|
627
|
+
//#region src/lib/profile-usage-cache.ts
|
|
838
628
|
/**
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
629
|
+
* Last known profile usage, kept outside React so a session switch does not blank it.
|
|
630
|
+
*
|
|
631
|
+
* Usage belongs to the *account*, not the session, but it is fetched by a hook that lives inside the per-session
|
|
632
|
+
* panel — so remounting that panel used to drop the authoritative reading to `undefined` for a whole round trip,
|
|
633
|
+
* leaving only the newly-attached session's own replayed (and possibly days-old) numbers to render. That is the
|
|
634
|
+
* "switching sessions resets my weekly usage to 1%, then it catches up" report.
|
|
635
|
+
*
|
|
636
|
+
* Keyed by client identity + profile because a different profile is a different account's plan, never a stale view
|
|
637
|
+
* of this one. Same shape and reasoning as `transcript-cache.ts` next door.
|
|
842
638
|
*/
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
639
|
+
const entries = /* @__PURE__ */ new Map();
|
|
640
|
+
function profileUsageCacheKey(client, profile) {
|
|
641
|
+
return `${client.identityKey}\u0000${profile}`;
|
|
642
|
+
}
|
|
643
|
+
function readProfileUsageCache(key) {
|
|
644
|
+
return entries.get(key);
|
|
645
|
+
}
|
|
646
|
+
function writeProfileUsageCache(key, usage) {
|
|
647
|
+
if (usage === void 0) return;
|
|
648
|
+
entries.set(key, usage);
|
|
649
|
+
}
|
|
650
|
+
function clearProfileUsageCache() {
|
|
651
|
+
entries.clear();
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/lib/draft-store.ts
|
|
655
|
+
/**
|
|
656
|
+
* Unsent composer text, kept per session on the client that typed it.
|
|
657
|
+
*
|
|
658
|
+
* A draft is not session state: it never reaches the gateway and never syncs between clients. Two people looking at
|
|
659
|
+
* one session are each mid-sentence in their own way, and a half-written prompt is not something either of them
|
|
660
|
+
* asked to publish.
|
|
661
|
+
*
|
|
662
|
+
* It is persisted rather than merely held in memory because the two ways drafts got lost are different failures. A
|
|
663
|
+
* session switch remounts the composer, which a module-scope map alone would survive; a Vite HMR reload or a VS Code
|
|
664
|
+
* `dev:host` webview re-render replaces the whole document, which it would not.
|
|
665
|
+
*/
|
|
666
|
+
const KEY = "workerdeck.drafts.v1";
|
|
667
|
+
/** Drafts are a convenience, so the store stays small and drops the least recently touched first. */
|
|
668
|
+
const MAX_DRAFTS = 20;
|
|
669
|
+
let memory;
|
|
670
|
+
function storage() {
|
|
671
|
+
try {
|
|
672
|
+
return globalThis.localStorage;
|
|
673
|
+
} catch {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function load() {
|
|
678
|
+
if (memory) return memory;
|
|
679
|
+
memory = {};
|
|
680
|
+
const raw = storage()?.getItem(KEY);
|
|
681
|
+
if (raw) try {
|
|
682
|
+
const parsed = JSON.parse(raw);
|
|
683
|
+
for (const [key, draft] of Object.entries(parsed)) if (typeof draft?.text === "string" && typeof draft.savedAt === "number") memory[key] = draft;
|
|
684
|
+
} catch {}
|
|
685
|
+
return memory;
|
|
686
|
+
}
|
|
687
|
+
function persist(drafts) {
|
|
688
|
+
const entries = Object.entries(drafts);
|
|
689
|
+
if (entries.length > MAX_DRAFTS) {
|
|
690
|
+
entries.sort((a, b) => b[1].savedAt - a[1].savedAt);
|
|
691
|
+
for (const [key] of entries.slice(MAX_DRAFTS)) delete drafts[key];
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
storage()?.setItem(KEY, JSON.stringify(drafts));
|
|
695
|
+
} catch {}
|
|
696
|
+
}
|
|
697
|
+
function draftKey(client, sessionId) {
|
|
698
|
+
return `${client.identityKey}\u0000${sessionId}`;
|
|
699
|
+
}
|
|
700
|
+
function readDraft(key) {
|
|
701
|
+
return load()[key]?.text ?? "";
|
|
702
|
+
}
|
|
703
|
+
function writeDraft(key, text, now = Date.now()) {
|
|
704
|
+
const drafts = load();
|
|
705
|
+
if (text.trim() === "") {
|
|
706
|
+
if (drafts[key] === void 0) return;
|
|
707
|
+
delete drafts[key];
|
|
708
|
+
} else drafts[key] = {
|
|
709
|
+
text,
|
|
710
|
+
savedAt: now
|
|
711
|
+
};
|
|
712
|
+
persist(drafts);
|
|
849
713
|
}
|
|
850
|
-
|
|
851
|
-
|
|
714
|
+
function clearDrafts() {
|
|
715
|
+
memory = {};
|
|
716
|
+
try {
|
|
717
|
+
storage()?.removeItem(KEY);
|
|
718
|
+
} catch {}
|
|
719
|
+
}
|
|
720
|
+
//#endregion
|
|
721
|
+
//#region src/hooks/use-attachments.ts
|
|
722
|
+
const TEXTUAL_TYPES = /* @__PURE__ */ new Set([
|
|
852
723
|
"application/json",
|
|
853
724
|
"application/xml",
|
|
854
725
|
"application/yaml",
|
|
@@ -859,26 +730,19 @@ const TEXTUAL_TYPES = new Set([
|
|
|
859
730
|
"application/x-sh",
|
|
860
731
|
"application/sql"
|
|
861
732
|
]);
|
|
862
|
-
/** Longest edge an image is downscaled to before upload. Anthropic's own
|
|
863
|
-
* recommendation, and the same number the iOS client uses — a phone photo is
|
|
864
|
-
* several times this in each direction and costs tokens for nothing. */
|
|
865
733
|
const MAX_IMAGE_EDGE = 1568;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
*/
|
|
734
|
+
function attachmentKind(mediaType) {
|
|
735
|
+
const type = mediaType.split(";")[0].trim().toLowerCase();
|
|
736
|
+
if (type.startsWith("image/")) return "image";
|
|
737
|
+
if (type === "application/pdf") return "pdf";
|
|
738
|
+
if (type.startsWith("text/")) return "text";
|
|
739
|
+
if (TEXTUAL_TYPES.has(type)) return "text";
|
|
740
|
+
}
|
|
874
741
|
function useAttachments(client, sessionId, { capabilities, engine }) {
|
|
875
742
|
const [items, setItems] = useState([]);
|
|
876
743
|
const [error, setError] = useState();
|
|
877
744
|
const counter = useRef(0);
|
|
878
|
-
/** The originals, kept so a failed upload can be retried without re-picking. */
|
|
879
745
|
const fileByKey = useRef(/* @__PURE__ */ new Map());
|
|
880
|
-
/** Mirrors the live preview URLs so unmount can revoke them all — an unmount
|
|
881
|
-
* with blobs outstanding is a leak the GC does not clean up. */
|
|
882
746
|
const previewUrls = useRef([]);
|
|
883
747
|
previewUrls.current = items.flatMap((item) => item.previewUrl ? [item.previewUrl] : []);
|
|
884
748
|
const accepts = capabilities.attachments;
|
|
@@ -998,9 +862,6 @@ function useAttachments(client, sessionId, { capabilities, engine }) {
|
|
|
998
862
|
error
|
|
999
863
|
]);
|
|
1000
864
|
}
|
|
1001
|
-
/** What a file input should offer. The full set keeps the open door (anything —
|
|
1002
|
-
* the gateway refuses the rest with a clear message); a narrower record narrows
|
|
1003
|
-
* the browsing too, so most refusals never happen. */
|
|
1004
865
|
function acceptAttribute(kinds) {
|
|
1005
866
|
if (kinds.length === 0) return "";
|
|
1006
867
|
const parts = [];
|
|
@@ -1010,15 +871,6 @@ function acceptAttribute(kinds) {
|
|
|
1010
871
|
return kinds.length === 3 ? "" : parts.join(",");
|
|
1011
872
|
}
|
|
1012
873
|
const imaging = globalThis;
|
|
1013
|
-
/**
|
|
1014
|
-
* The bytes to upload, and the type they are.
|
|
1015
|
-
*
|
|
1016
|
-
* Oversized images are redrawn to {@link MAX_IMAGE_EDGE} first: a modern phone
|
|
1017
|
-
* photo is 4000px on its long edge, which costs tokens for detail no model
|
|
1018
|
-
* reads, and often exceeds the gateway's per-file cap outright. Everything else
|
|
1019
|
-
* — and anything the browser can't decode — is uploaded as-is, so a failure here
|
|
1020
|
-
* is never worse than not trying.
|
|
1021
|
-
*/
|
|
1022
874
|
async function prepare(file) {
|
|
1023
875
|
const mediaType = file.type || "application/octet-stream";
|
|
1024
876
|
const { createImageBitmap, document } = imaging;
|
|
@@ -1071,14 +923,8 @@ async function prepare(file) {
|
|
|
1071
923
|
}
|
|
1072
924
|
//#endregion
|
|
1073
925
|
//#region src/lib/prompt-tokens.ts
|
|
1074
|
-
/** Characters a command name may contain after the slash. Deliberately excludes
|
|
1075
|
-
* `/`, so an absolute path pasted into a message (`/Users/me/…`) is not mistaken
|
|
1076
|
-
* for a command; `:` is in because namespaced skills (`dev:wrapup`) are spelled
|
|
1077
|
-
* that way. */
|
|
1078
926
|
const COMMAND_BODY = /^[A-Za-z0-9\-_.:]+$/;
|
|
1079
|
-
|
|
1080
|
-
* "see @README.md." styles the path and leaves the period alone. */
|
|
1081
|
-
const SENTENCE_TAIL = new Set([
|
|
927
|
+
const SENTENCE_TAIL = /* @__PURE__ */ new Set([
|
|
1082
928
|
".",
|
|
1083
929
|
",",
|
|
1084
930
|
";",
|
|
@@ -1091,12 +937,6 @@ const SENTENCE_TAIL = new Set([
|
|
|
1091
937
|
"\"",
|
|
1092
938
|
"'"
|
|
1093
939
|
]);
|
|
1094
|
-
/**
|
|
1095
|
-
* Every token in a sent message.
|
|
1096
|
-
*
|
|
1097
|
-
* Stricter than what a composer completes: a bare `@` is a token being typed, but
|
|
1098
|
-
* in a sent message it is just an at sign.
|
|
1099
|
-
*/
|
|
1100
940
|
function scanPromptTokens(text) {
|
|
1101
941
|
const tokens = [];
|
|
1102
942
|
const words = /\S+/g;
|
|
@@ -1120,58 +960,64 @@ function scanPromptTokens(text) {
|
|
|
1120
960
|
return tokens;
|
|
1121
961
|
}
|
|
1122
962
|
//#endregion
|
|
963
|
+
//#region src/lib/async-guards.ts
|
|
964
|
+
function useAliveRef() {
|
|
965
|
+
const alive = useRef(true);
|
|
966
|
+
useEffect(() => {
|
|
967
|
+
alive.current = true;
|
|
968
|
+
return () => {
|
|
969
|
+
alive.current = false;
|
|
970
|
+
};
|
|
971
|
+
}, []);
|
|
972
|
+
return alive;
|
|
973
|
+
}
|
|
974
|
+
function isRouteUnsupported(e) {
|
|
975
|
+
return e instanceof WorkerDeckError && e.status === 404;
|
|
976
|
+
}
|
|
977
|
+
//#endregion
|
|
1123
978
|
//#region src/lib/host-tree.ts
|
|
1124
|
-
/**
|
|
1125
|
-
* Flatten the loaded directories into the rows the tree shows.
|
|
1126
|
-
*
|
|
1127
|
-
* Pure, so the interesting part of a file tree — which nodes are visible at what
|
|
1128
|
-
* depth once a few directories are expanded and one of them is still loading —
|
|
1129
|
-
* is testable without a DOM or a gateway.
|
|
1130
|
-
*
|
|
1131
|
-
* Only *expanded* directories contribute children, and only if their listing has
|
|
1132
|
-
* arrived. An expanded-but-unlisted directory yields its own row with
|
|
1133
|
-
* `loading: true` and no children: expansion is a request the user already made,
|
|
1134
|
-
* so the row must say the answer is coming rather than look like an empty folder.
|
|
1135
|
-
*/
|
|
1136
979
|
function flattenHostTree(root, dirs, expanded) {
|
|
1137
980
|
const rows = [];
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
981
|
+
const rootState = dirs.get(root);
|
|
982
|
+
if (!rootState) return rows;
|
|
983
|
+
const stack = [{
|
|
984
|
+
entries: rootState.entries,
|
|
985
|
+
index: 0,
|
|
986
|
+
depth: 0
|
|
987
|
+
}];
|
|
988
|
+
while (stack.length > 0) {
|
|
989
|
+
const frame = stack[stack.length - 1];
|
|
990
|
+
const entry = frame.entries[frame.index];
|
|
991
|
+
if (entry === void 0) {
|
|
992
|
+
stack.pop();
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
frame.index += 1;
|
|
996
|
+
const depth = frame.depth;
|
|
997
|
+
if (entry.type !== "dir") {
|
|
1151
998
|
rows.push({
|
|
1152
999
|
entry,
|
|
1153
|
-
depth
|
|
1154
|
-
expanded: isExpanded,
|
|
1155
|
-
loading: isExpanded && !childState,
|
|
1156
|
-
truncated: isExpanded ? childState?.truncated : void 0
|
|
1000
|
+
depth
|
|
1157
1001
|
});
|
|
1158
|
-
|
|
1002
|
+
continue;
|
|
1159
1003
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1004
|
+
const isExpanded = expanded.has(entry.path);
|
|
1005
|
+
const childState = dirs.get(entry.path);
|
|
1006
|
+
rows.push({
|
|
1007
|
+
entry,
|
|
1008
|
+
depth,
|
|
1009
|
+
expanded: isExpanded,
|
|
1010
|
+
loading: isExpanded && !childState,
|
|
1011
|
+
truncated: isExpanded ? childState?.truncated : void 0
|
|
1012
|
+
});
|
|
1013
|
+
if (isExpanded && childState) stack.push({
|
|
1014
|
+
entries: childState.entries,
|
|
1015
|
+
index: 0,
|
|
1016
|
+
depth: depth + 1
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1162
1019
|
return rows;
|
|
1163
1020
|
}
|
|
1164
|
-
/**
|
|
1165
|
-
* Every ancestor of `path` below `root`, outermost first — the directories that
|
|
1166
|
-
* must be expanded for `path` to be on screen.
|
|
1167
|
-
*
|
|
1168
|
-
* Returns `[]` when `path` is not under `root` rather than guessing: revealing a
|
|
1169
|
-
* file the tree cannot contain is a no-op, not an error worth raising, and the
|
|
1170
|
-
* caller has no better answer either.
|
|
1171
|
-
*
|
|
1172
|
-
* The prefix test is on a **path boundary** (`root` + `/`), so `/src/app` is not
|
|
1173
|
-
* treated as living under `/src/a`.
|
|
1174
|
-
*/
|
|
1175
1021
|
function ancestorsWithin(root, path) {
|
|
1176
1022
|
const base = root.endsWith("/") ? root.slice(0, -1) : root;
|
|
1177
1023
|
if (path === base || !path.startsWith(`${base}/`)) return [];
|
|
@@ -1186,19 +1032,6 @@ function ancestorsWithin(root, path) {
|
|
|
1186
1032
|
}
|
|
1187
1033
|
//#endregion
|
|
1188
1034
|
//#region src/hooks/use-host-files.ts
|
|
1189
|
-
/**
|
|
1190
|
-
* Fuzzy file search rooted at a session's working directory — what an `@file`
|
|
1191
|
-
* picker needs.
|
|
1192
|
-
*
|
|
1193
|
-
* Deliberately session-scoped: the server's `hostFiles.roots` are the security
|
|
1194
|
-
* boundary, but what someone wants while talking to an agent is *this* project's
|
|
1195
|
-
* tree, so this never offers the roots list.
|
|
1196
|
-
*
|
|
1197
|
-
* A gateway that answers 404 once has answered for the session: host files are
|
|
1198
|
-
* either configured or they aren't, and the answer will not change while the cwd
|
|
1199
|
-
* holds. Asking again on every character would be a request per keystroke for a
|
|
1200
|
-
* feature that does not exist here.
|
|
1201
|
-
*/
|
|
1202
1035
|
function useHostFileSearch(client, cwd) {
|
|
1203
1036
|
const [unsupported, setUnsupported] = useState(false);
|
|
1204
1037
|
const lastCwd = useRef(cwd);
|
|
@@ -1214,7 +1047,7 @@ function useHostFileSearch(client, cwd) {
|
|
|
1214
1047
|
const response = await client.findHostFiles(cwd, query, options?.limit ?? 8);
|
|
1215
1048
|
return options?.signal?.aborted ? [] : response.matches;
|
|
1216
1049
|
} catch (e) {
|
|
1217
|
-
if (e
|
|
1050
|
+
if (isRouteUnsupported(e)) setUnsupported(true);
|
|
1218
1051
|
return [];
|
|
1219
1052
|
}
|
|
1220
1053
|
}, [
|
|
@@ -1227,13 +1060,6 @@ function useHostFileSearch(client, cwd) {
|
|
|
1227
1060
|
search
|
|
1228
1061
|
};
|
|
1229
1062
|
}
|
|
1230
|
-
/**
|
|
1231
|
-
* Whether host files are served here, and whether they may be written.
|
|
1232
|
-
*
|
|
1233
|
-
* One request per client, cached for the life of the hook: the roots and the
|
|
1234
|
-
* write flag are gateway configuration, not session state, and they do not
|
|
1235
|
-
* change while the tab is open.
|
|
1236
|
-
*/
|
|
1237
1063
|
function useHostFileRoots(client) {
|
|
1238
1064
|
const [result, setResult] = useState({
|
|
1239
1065
|
available: false,
|
|
@@ -1258,23 +1084,6 @@ function useHostFileRoots(client) {
|
|
|
1258
1084
|
}, [client]);
|
|
1259
1085
|
return result;
|
|
1260
1086
|
}
|
|
1261
|
-
/**
|
|
1262
|
-
* An expandable file tree rooted at a session's working directory.
|
|
1263
|
-
*
|
|
1264
|
-
* Rooted at the cwd rather than at `/fs/roots` for the same reason
|
|
1265
|
-
* {@link useHostFileSearch} is: the roots are the *security* boundary the server
|
|
1266
|
-
* enforces on every request, but what someone wants while watching an agent work
|
|
1267
|
-
* is this project's tree. The roots may well be broader; showing them would
|
|
1268
|
-
* offer navigation to directories the session has nothing to do with.
|
|
1269
|
-
*
|
|
1270
|
-
* Listings are cached per directory and kept across a collapse, so reopening a
|
|
1271
|
-
* folder is instant and does not re-ask. That staleness is deliberate and
|
|
1272
|
-
* bounded: `refresh` exists, and knowing when to call it is the *next* problem
|
|
1273
|
-
* (the agent is editing this same tree), not something a tree can guess.
|
|
1274
|
-
*
|
|
1275
|
-
* Like the search hook, a 404 is answered once for the session: host files are
|
|
1276
|
-
* either configured here or they are not.
|
|
1277
|
-
*/
|
|
1278
1087
|
function useHostFileTree(client, cwd) {
|
|
1279
1088
|
const [dirs, setDirs] = useState(() => /* @__PURE__ */ new Map());
|
|
1280
1089
|
const [expanded, setExpanded] = useState(() => /* @__PURE__ */ new Set());
|
|
@@ -1289,13 +1098,7 @@ function useHostFileTree(client, cwd) {
|
|
|
1289
1098
|
setUnsupported(false);
|
|
1290
1099
|
setError(void 0);
|
|
1291
1100
|
}, [cwd]);
|
|
1292
|
-
const alive =
|
|
1293
|
-
useEffect(() => {
|
|
1294
|
-
alive.current = true;
|
|
1295
|
-
return () => {
|
|
1296
|
-
alive.current = false;
|
|
1297
|
-
};
|
|
1298
|
-
}, []);
|
|
1101
|
+
const alive = useAliveRef();
|
|
1299
1102
|
const requested = useRef(/* @__PURE__ */ new Set());
|
|
1300
1103
|
const list = useCallback((target, { force = false } = {}) => {
|
|
1301
1104
|
if (unsupported) return;
|
|
@@ -1314,7 +1117,7 @@ function useHostFileTree(client, cwd) {
|
|
|
1314
1117
|
}).catch((e) => {
|
|
1315
1118
|
if (!alive.current) return;
|
|
1316
1119
|
requested.current.delete(target);
|
|
1317
|
-
if (e
|
|
1120
|
+
if (isRouteUnsupported(e)) {
|
|
1318
1121
|
setUnsupported(true);
|
|
1319
1122
|
return;
|
|
1320
1123
|
}
|
|
@@ -1368,36 +1171,6 @@ function useHostFileTree(client, cwd) {
|
|
|
1368
1171
|
}
|
|
1369
1172
|
//#endregion
|
|
1370
1173
|
//#region src/hooks/use-project-icons.ts
|
|
1371
|
-
/**
|
|
1372
|
-
* Project icon bytes for a list of sessions, as object URLs keyed by the icon's
|
|
1373
|
-
* own content hash.
|
|
1374
|
-
*
|
|
1375
|
-
* **Keyed by hash, and cached for the life of the page.** That is what the
|
|
1376
|
-
* wire's `ProjectIcon.image.hash` is for: every session in one project serves
|
|
1377
|
-
* identical bytes, so twelve rows of one repo cost one request, and two
|
|
1378
|
-
* *different* projects that happen to declare the same file cost one between
|
|
1379
|
-
* them. A hash names its bytes, so an entry can never go stale — editing the
|
|
1380
|
-
* icon changes the hash, which arrives on the next poll as a key this cache has
|
|
1381
|
-
* not seen. The old entry is dead weight rather than a wrong answer, and the
|
|
1382
|
-
* population is bounded by how many distinct icons an operator has open.
|
|
1383
|
-
*
|
|
1384
|
-
* The cache is **module scope on purpose**, like `useSessions`' store: the
|
|
1385
|
-
* sidebar and any other surface rendering rows mount this at once, and a
|
|
1386
|
-
* per-hook cache would be N copies each fetching the same bytes.
|
|
1387
|
-
*
|
|
1388
|
-
* A failure is cached as a failure. The route's 404 is the uniform "no icon"
|
|
1389
|
-
* (no project, a glyph, or one the gateway refused), so retrying it every poll
|
|
1390
|
-
* would be a request per session per poll for a picture that is never coming.
|
|
1391
|
-
*
|
|
1392
|
-
* Object URLs are never revoked, which is the same decision stated twice: they
|
|
1393
|
-
* are the cache. Revoking one would break every row still pointing at it, and
|
|
1394
|
-
* the whole point of hashing is that nothing here is ever superseded.
|
|
1395
|
-
*
|
|
1396
|
-
* The VS Code extension has the same three-set structure in `project-icons.ts`
|
|
1397
|
-
* and cannot share this one — its webview has no external `connect-src` at all,
|
|
1398
|
-
* so its bytes arrive as data URLs pushed from the extension host. One design,
|
|
1399
|
-
* two implementations, for a reason that is in the transport rather than here.
|
|
1400
|
-
*/
|
|
1401
1174
|
const byHash = /* @__PURE__ */ new Map();
|
|
1402
1175
|
const inFlight = /* @__PURE__ */ new Set();
|
|
1403
1176
|
const failed = /* @__PURE__ */ new Set();
|
|
@@ -1427,39 +1200,34 @@ function useProjectIcons(rows, clientFor) {
|
|
|
1427
1200
|
return resolved;
|
|
1428
1201
|
}
|
|
1429
1202
|
//#endregion
|
|
1430
|
-
//#region src/hooks/use-
|
|
1203
|
+
//#region src/hooks/use-draft.ts
|
|
1431
1204
|
/**
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
* The session's own event stream carries a `rate_limit` reading only when the
|
|
1435
|
-
* engine volunteers one — for claude that is at a turn's edges and nowhere else,
|
|
1436
|
-
* so a session idle since yesterday replays yesterday's number, and a session
|
|
1437
|
-
* opened today knows nothing of what a sibling on the same account spent an hour
|
|
1438
|
-
* ago. `GET /profiles` answers the account-wide question, which is why this is a
|
|
1439
|
-
* poll and not a subscription: nothing pushes it.
|
|
1440
|
-
*
|
|
1441
|
-
* Polling and not attaching, deliberately — a second WebSocket per surface is
|
|
1442
|
-
* exactly what the bridge's "asks the first attached client" rule forbids, and
|
|
1443
|
-
* this is one small GET a minute.
|
|
1444
|
-
*
|
|
1445
|
-
* Self-disabling on a 404, like {@link useHostFileSearch}: a gateway without the
|
|
1446
|
-
* route will never grow one mid-session, so stop asking rather than log a miss
|
|
1447
|
-
* every minute.
|
|
1205
|
+
* Remember unsent composer text for a session. Purely local: it never reaches the gateway and never syncs between
|
|
1206
|
+
* clients, because a half-written prompt is not something anyone asked to publish.
|
|
1448
1207
|
*/
|
|
1208
|
+
function useDraft(client, sessionId) {
|
|
1209
|
+
const key = sessionId ? draftKey(client, sessionId) : void 0;
|
|
1210
|
+
return {
|
|
1211
|
+
initialText: useMemo(() => key ? readDraft(key) : "", [key]),
|
|
1212
|
+
save: useCallback((text) => {
|
|
1213
|
+
if (key) writeDraft(key, text);
|
|
1214
|
+
}, [key]),
|
|
1215
|
+
clear: useCallback(() => {
|
|
1216
|
+
if (key) writeDraft(key, "");
|
|
1217
|
+
}, [key])
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/hooks/use-profile-usage.ts
|
|
1449
1222
|
function useProfileUsage(client, profile, options = {}) {
|
|
1450
1223
|
const { intervalMs = 6e4, enabled = true } = options;
|
|
1451
|
-
const
|
|
1224
|
+
const cacheKey = profile ? profileUsageCacheKey(client, profile) : void 0;
|
|
1225
|
+
const [usage, setUsage] = useState(() => cacheKey ? readProfileUsageCache(cacheKey) : void 0);
|
|
1452
1226
|
const [unsupported, setUnsupported] = useState(false);
|
|
1453
1227
|
const [nonce, setNonce] = useState(0);
|
|
1454
1228
|
const refresh = useCallback(() => setNonce((n) => n + 1), []);
|
|
1455
|
-
useEffect(() => setUsage(void 0), [
|
|
1456
|
-
const alive =
|
|
1457
|
-
useEffect(() => {
|
|
1458
|
-
alive.current = true;
|
|
1459
|
-
return () => {
|
|
1460
|
-
alive.current = false;
|
|
1461
|
-
};
|
|
1462
|
-
}, []);
|
|
1229
|
+
useEffect(() => setUsage(cacheKey ? readProfileUsageCache(cacheKey) : void 0), [cacheKey]);
|
|
1230
|
+
const alive = useAliveRef();
|
|
1463
1231
|
useEffect(() => {
|
|
1464
1232
|
if (!profile || !enabled || unsupported) return;
|
|
1465
1233
|
let cancelled = false;
|
|
@@ -1467,10 +1235,12 @@ function useProfileUsage(client, profile, options = {}) {
|
|
|
1467
1235
|
if (globalThis.document?.hidden) return;
|
|
1468
1236
|
client.listProfiles().then((res) => {
|
|
1469
1237
|
if (cancelled || !alive.current) return;
|
|
1470
|
-
|
|
1238
|
+
const next = res.profiles.find((p) => p.name === profile)?.usage;
|
|
1239
|
+
if (cacheKey) writeProfileUsageCache(cacheKey, next);
|
|
1240
|
+
setUsage(next);
|
|
1471
1241
|
}).catch((e) => {
|
|
1472
1242
|
if (cancelled || !alive.current) return;
|
|
1473
|
-
if (e
|
|
1243
|
+
if (isRouteUnsupported(e)) setUnsupported(true);
|
|
1474
1244
|
});
|
|
1475
1245
|
};
|
|
1476
1246
|
load();
|
|
@@ -1485,7 +1255,8 @@ function useProfileUsage(client, profile, options = {}) {
|
|
|
1485
1255
|
enabled,
|
|
1486
1256
|
unsupported,
|
|
1487
1257
|
intervalMs,
|
|
1488
|
-
nonce
|
|
1258
|
+
nonce,
|
|
1259
|
+
cacheKey
|
|
1489
1260
|
]);
|
|
1490
1261
|
return {
|
|
1491
1262
|
usage,
|
|
@@ -1494,18 +1265,6 @@ function useProfileUsage(client, profile, options = {}) {
|
|
|
1494
1265
|
}
|
|
1495
1266
|
//#endregion
|
|
1496
1267
|
//#region src/hooks/use-session-info.ts
|
|
1497
|
-
/**
|
|
1498
|
-
* The registry's record of one session, over REST.
|
|
1499
|
-
*
|
|
1500
|
-
* Separate from {@link useClaudeSession} on purpose: that hook attaches a
|
|
1501
|
-
* WebSocket and streams a transcript, which is far more than a caller needs to
|
|
1502
|
-
* know a session's `cwd` or title — and a second attach would be a second
|
|
1503
|
-
* client on the bridge, which is the one thing the bridge's "asks the first
|
|
1504
|
-
* attached client" rule cannot tolerate.
|
|
1505
|
-
*
|
|
1506
|
-
* Fetched once per session id. The record is registry state, not a live feed;
|
|
1507
|
-
* anything that changes during a run arrives on the session's event stream.
|
|
1508
|
-
*/
|
|
1509
1268
|
function useSessionInfo(client, sessionId) {
|
|
1510
1269
|
const [info, setInfo] = useState();
|
|
1511
1270
|
const [loading, setLoading] = useState(!!sessionId);
|
|
@@ -1542,39 +1301,13 @@ function useSessionInfo(client, sessionId) {
|
|
|
1542
1301
|
}
|
|
1543
1302
|
//#endregion
|
|
1544
1303
|
//#region src/lib/open-files.ts
|
|
1545
|
-
/** Whether a tab has edits that are not on disk. Derived, so typing something
|
|
1546
|
-
* and undoing it back leaves the tab clean — which is what an editor should do
|
|
1547
|
-
* and what a boolean flag set on first keystroke would get wrong. */
|
|
1548
1304
|
function isDirty(file) {
|
|
1549
1305
|
return file.draft !== void 0 && file.draft !== file.content;
|
|
1550
1306
|
}
|
|
1551
|
-
/** What a tab would write: its edits if it has any, else what it read. */
|
|
1552
1307
|
function currentText(file) {
|
|
1553
1308
|
return file.draft ?? file.content ?? "";
|
|
1554
1309
|
}
|
|
1555
1310
|
const initialOpenFilesState = { files: [] };
|
|
1556
|
-
/**
|
|
1557
|
-
* The tab strip and the editor's whole behaviour, as a pure function.
|
|
1558
|
-
*
|
|
1559
|
-
* The rules worth stating, because they are the ones a naive implementation
|
|
1560
|
-
* gets wrong:
|
|
1561
|
-
*
|
|
1562
|
-
* - **Opening an open path never re-reads it.** It focuses the tab. Re-reading
|
|
1563
|
-
* would silently discard that tab's unsaved edits on a double click.
|
|
1564
|
-
* - **Closing the focused tab focuses its right-hand neighbour**, falling back
|
|
1565
|
-
* to the left when it was last. Focusing "the first tab" instead is what makes
|
|
1566
|
-
* closing several tabs in a row jump the user around.
|
|
1567
|
-
* - **A successful save is applied against the text that was sent**, not against
|
|
1568
|
-
* the tab's current text. Typing during a save is normal; treating the write's
|
|
1569
|
-
* completion as "the tab is now clean" would silently drop those keystrokes.
|
|
1570
|
-
* - **Nothing here discards edits implicitly.** `revert` and `loaded` are the
|
|
1571
|
-
* only two things that clear a draft, and both are the direct result of
|
|
1572
|
-
* someone asking for it. The conditional write exists so a browser edit cannot
|
|
1573
|
-
* clobber the agent mid-run; this holds the same line in the other direction.
|
|
1574
|
-
*
|
|
1575
|
-
* Late results are addressed by path and dropped if that tab is gone, so a slow
|
|
1576
|
-
* read of a closed file cannot resurrect it.
|
|
1577
|
-
*/
|
|
1578
1311
|
function openFilesReducer(state, action) {
|
|
1579
1312
|
switch (action.type) {
|
|
1580
1313
|
case "open": {
|
|
@@ -1666,8 +1399,6 @@ function openFilesReducer(state, action) {
|
|
|
1666
1399
|
}));
|
|
1667
1400
|
}
|
|
1668
1401
|
}
|
|
1669
|
-
/** Replace one file in place, preserving tab order; a no-op if it was closed
|
|
1670
|
-
* while the request was in flight. */
|
|
1671
1402
|
function patch(state, path, next) {
|
|
1672
1403
|
const index = state.files.findIndex((f) => f.path === path);
|
|
1673
1404
|
if (index === -1) return state;
|
|
@@ -1681,29 +1412,12 @@ function patch(state, path, next) {
|
|
|
1681
1412
|
files
|
|
1682
1413
|
};
|
|
1683
1414
|
}
|
|
1684
|
-
/** Last path segment. Trailing slashes are not expected here — these are file
|
|
1685
|
-
* paths from `/fs/list` and `/fs/find` — but a bare `/` should still show as
|
|
1686
|
-
* something rather than as an empty tab. */
|
|
1687
1415
|
function baseName(path) {
|
|
1688
1416
|
const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
1689
1417
|
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || trimmed || path;
|
|
1690
1418
|
}
|
|
1691
1419
|
//#endregion
|
|
1692
1420
|
//#region src/hooks/use-open-files.ts
|
|
1693
|
-
/**
|
|
1694
|
-
* The open-file tabs of a workspace: which files are open, which one is focused,
|
|
1695
|
-
* the bytes behind each, and the edits on top of them.
|
|
1696
|
-
*
|
|
1697
|
-
* Reads are fired from an effect keyed on "which tabs are still loading" rather
|
|
1698
|
-
* than from `open` itself, so the reducer stays pure and a tab that was opened,
|
|
1699
|
-
* closed and reopened does not carry a stale in-flight request with it.
|
|
1700
|
-
*
|
|
1701
|
-
* Deliberately **not** given the session's cwd: a tab is an absolute host path,
|
|
1702
|
-
* and where it came from — the tree, a search hit, a path in the transcript — is
|
|
1703
|
-
* the caller's business. Containment is the server's job on every `/fs/read` and
|
|
1704
|
-
* `/fs/write`, not something re-derived here from a directory this hook would
|
|
1705
|
-
* have to trust.
|
|
1706
|
-
*/
|
|
1707
1421
|
function useOpenFiles(client) {
|
|
1708
1422
|
const [state, dispatch] = useReducer(openFilesReducer, initialOpenFilesState);
|
|
1709
1423
|
const requested = useRef(/* @__PURE__ */ new Set());
|
|
@@ -1720,7 +1434,7 @@ function useOpenFiles(client) {
|
|
|
1720
1434
|
}, [state]);
|
|
1721
1435
|
const pending = state.files.filter((f) => f.status === "loading").map((f) => f.path).join("\n");
|
|
1722
1436
|
const read = useCallback((path) => client.readHostFile(path).then((response) => {
|
|
1723
|
-
if (!alive.current) return
|
|
1437
|
+
if (!alive.current) return;
|
|
1724
1438
|
dispatch({
|
|
1725
1439
|
type: "loaded",
|
|
1726
1440
|
path,
|
|
@@ -1789,8 +1503,6 @@ function useOpenFiles(client) {
|
|
|
1789
1503
|
});
|
|
1790
1504
|
});
|
|
1791
1505
|
}, [read]);
|
|
1792
|
-
/** One conditional write. Shared by `save` and `overwrite`, which differ only
|
|
1793
|
-
* in where the hash came from. */
|
|
1794
1506
|
const write = useCallback(async (path, text, expectedHash) => {
|
|
1795
1507
|
try {
|
|
1796
1508
|
const response = await client.writeHostFile({
|
|
@@ -1868,14 +1580,6 @@ function useOpenFiles(client) {
|
|
|
1868
1580
|
}
|
|
1869
1581
|
//#endregion
|
|
1870
1582
|
//#region src/lib/tool-host.ts
|
|
1871
|
-
/**
|
|
1872
|
-
* Answers server-bridged tool calls by executing them in this browser tab.
|
|
1873
|
-
* Framework-free — {@link useToolCallHost} is a thin React wrapper.
|
|
1874
|
-
*
|
|
1875
|
-
* The point is data locality: documents fetched or held client-side can be
|
|
1876
|
-
* evaluated here and never touch the server. The engine loads lazily, so a page
|
|
1877
|
-
* that never bridges a call never pays for the WASM guest.
|
|
1878
|
-
*/
|
|
1879
1583
|
function createToolCallHost(handle, options = {}) {
|
|
1880
1584
|
const inFlight = /* @__PURE__ */ new Map();
|
|
1881
1585
|
let enginePromise;
|
|
@@ -1963,7 +1667,7 @@ function createToolCallHost(handle, options = {}) {
|
|
|
1963
1667
|
const sandbox = await import("@workerdeck/sandbox");
|
|
1964
1668
|
const vfs = sandbox.createVfs(frame.vfsSeed);
|
|
1965
1669
|
const timeoutMs = Math.min(frame.limits?.timeoutMs ?? Number.POSITIVE_INFINITY, options.timeoutMs ?? 5e3);
|
|
1966
|
-
const memoryLimitBytes = Math.min(frame.limits?.memoryLimitBytes ?? Number.POSITIVE_INFINITY, options.memoryLimitBytes ??
|
|
1670
|
+
const memoryLimitBytes = Math.min(frame.limits?.memoryLimitBytes ?? Number.POSITIVE_INFINITY, options.memoryLimitBytes ?? 67108864);
|
|
1967
1671
|
const result = options.execute ? await options.execute({
|
|
1968
1672
|
script,
|
|
1969
1673
|
vfs,
|
|
@@ -2036,19 +1740,12 @@ function createToolCallHost(handle, options = {}) {
|
|
|
2036
1740
|
inFlight.clear();
|
|
2037
1741
|
} };
|
|
2038
1742
|
}
|
|
2039
|
-
/** The single-file browser build keeps this to one lazy chunk — no separate
|
|
2040
|
-
* .wasm fetch, and nothing at all until the first bridged call. */
|
|
2041
1743
|
async function defaultLoadEngine() {
|
|
2042
1744
|
const [sandbox, variant] = await Promise.all([import("@workerdeck/sandbox"), import("@jitl/quickjs-singlefile-browser-release-asyncify")]);
|
|
2043
1745
|
return sandbox.loadEngine(variant);
|
|
2044
1746
|
}
|
|
2045
1747
|
//#endregion
|
|
2046
1748
|
//#region src/hooks/use-tool-host.ts
|
|
2047
|
-
/**
|
|
2048
|
-
* React wrapper around {@link createToolCallHost}: subscribes while mounted and
|
|
2049
|
-
* exposes recent executions for rendering. All the logic lives in the
|
|
2050
|
-
* framework-free host — this only manages the subscription's lifetime.
|
|
2051
|
-
*/
|
|
2052
1749
|
function useToolCallHost(handle, options = {}) {
|
|
2053
1750
|
const [executions, setExecutions] = useState([]);
|
|
2054
1751
|
const optionsRef = useRef(options);
|
|
@@ -2061,7 +1758,7 @@ function useToolCallHost(handle, options = {}) {
|
|
|
2061
1758
|
const client = optionsRef.current.clientTools;
|
|
2062
1759
|
if (!client) return base;
|
|
2063
1760
|
const clientNames = Object.keys(client);
|
|
2064
|
-
return base ? [
|
|
1761
|
+
return base ? [.../* @__PURE__ */ new Set([...base, ...clientNames])] : clientNames;
|
|
2065
1762
|
},
|
|
2066
1763
|
get clientTools() {
|
|
2067
1764
|
return optionsRef.current.clientTools;
|
|
@@ -2093,15 +1790,6 @@ function useToolCallHost(handle, options = {}) {
|
|
|
2093
1790
|
}
|
|
2094
1791
|
//#endregion
|
|
2095
1792
|
//#region src/lib/recap.ts
|
|
2096
|
-
/**
|
|
2097
|
-
* Summarize the items from `fromIndex` onward — the boundary being the number
|
|
2098
|
-
* of items that existed when the session was last looked at.
|
|
2099
|
-
*
|
|
2100
|
-
* An out-of-range boundary is clamped rather than rejected: a transcript can
|
|
2101
|
-
* *shrink* (a `/clear`, a fresh attach after a compaction), and the honest
|
|
2102
|
-
* reading of "you last saw 40 items, there are now 12" is "everything here is
|
|
2103
|
-
* new", not a negative count.
|
|
2104
|
-
*/
|
|
2105
1793
|
function summarizeSince(state, fromIndex) {
|
|
2106
1794
|
const start = Math.max(0, Math.min(fromIndex, state.items.length));
|
|
2107
1795
|
const fresh = state.items.slice(start);
|
|
@@ -2127,10 +1815,7 @@ function summarizeSince(state, fromIndex) {
|
|
|
2127
1815
|
case "file_delivered":
|
|
2128
1816
|
files += 1;
|
|
2129
1817
|
break;
|
|
2130
|
-
case "notice":
|
|
2131
|
-
if (item.level === "error") errors += 1;
|
|
2132
|
-
break;
|
|
2133
|
-
default: break;
|
|
1818
|
+
case "notice": if (item.level === "error") errors += 1;
|
|
2134
1819
|
}
|
|
2135
1820
|
const toolNames = [...toolCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
|
|
2136
1821
|
const pending = state.pendingApprovals?.length ?? 0;
|
|
@@ -2145,15 +1830,8 @@ function summarizeSince(state, fromIndex) {
|
|
|
2145
1830
|
any: turns + replies + tools + files + errors + pending > 0
|
|
2146
1831
|
};
|
|
2147
1832
|
}
|
|
2148
|
-
/**
|
|
2149
|
-
* The recap as one line of text, in the order a person reads it: what got done,
|
|
2150
|
-
* what it used, what went wrong, what is waiting.
|
|
2151
|
-
*
|
|
2152
|
-
* Returns `undefined` when there is nothing to say, so a caller can render the
|
|
2153
|
-
* row or not on the value alone.
|
|
2154
|
-
*/
|
|
2155
1833
|
function recapLine(summary) {
|
|
2156
|
-
if (!summary.any) return
|
|
1834
|
+
if (!summary.any) return;
|
|
2157
1835
|
const parts = [];
|
|
2158
1836
|
if (summary.turns > 0) parts.push(plural(summary.turns, "turn"));
|
|
2159
1837
|
else if (summary.replies > 0) parts.push(plural(summary.replies, "reply", "replies"));
|
|
@@ -2171,6 +1849,6 @@ function plural(count, one, many = `${one}s`) {
|
|
|
2171
1849
|
return `${count} ${count === 1 ? one : many}`;
|
|
2172
1850
|
}
|
|
2173
1851
|
//#endregion
|
|
2174
|
-
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 };
|
|
1852
|
+
export { REPLAY_HOLD_MAX_MS, ancestorsWithin, applyEvent, attachmentKind, clearDrafts, clearProfileUsageCache, clearTranscriptCache, createToolCallHost, currentText, flattenHostTree, hydrateToolResult, initialOpenFilesState, initialReplayTarget, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, staleAttach, summarizeSince, useAttachments, useClaudeSession, useDraft, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useProfileUsage, useProjectIcons, useSessionInfo, useToolCallHost };
|
|
2175
1853
|
|
|
2176
1854
|
//# sourceMappingURL=index.mjs.map
|