@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.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 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
+ 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 void 0;
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 void 0;
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: typeof full.content === "string" ? full.content : (full.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n")
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/hooks/use-attachments.ts
627
+ //#region src/lib/profile-usage-cache.ts
838
628
  /**
839
- * How a media type reaches a model, in the capability record's vocabulary.
840
- * `undefined` means this build can't classify it — the upload still goes,
841
- * because the gateway's vocabulary is the authoritative one.
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
- function attachmentKind(mediaType) {
844
- const type = mediaType.split(";")[0].trim().toLowerCase();
845
- if (type.startsWith("image/")) return "image";
846
- if (type === "application/pdf") return "pdf";
847
- if (type.startsWith("text/")) return "text";
848
- if (TEXTUAL_TYPES.has(type)) return "text";
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
- /** Textual types whose media type doesn't start with `text/` — mirrors core. */
851
- const TEXTUAL_TYPES = new Set([
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
- * Stage, upload and track files for the next message of a session.
868
- *
869
- * Refusals happen as early as they can be known: a kind the capability record
870
- * forswears never reaches the network (the gateway would 415 it), and everything
871
- * else is the gateway's call — its vocabulary is authoritative, so an unknown
872
- * media type is uploaded rather than guessed at.
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
- /** Trailing punctuation that belongs to the sentence, not the token — so
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 walk = (dir, depth) => {
1139
- const state = dirs.get(dir);
1140
- if (!state) return;
1141
- for (const entry of state.entries) {
1142
- if (entry.type !== "dir") {
1143
- rows.push({
1144
- entry,
1145
- depth
1146
- });
1147
- continue;
1148
- }
1149
- const isExpanded = expanded.has(entry.path);
1150
- const childState = dirs.get(entry.path);
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
- if (isExpanded && childState) walk(entry.path, depth + 1);
1002
+ continue;
1159
1003
  }
1160
- };
1161
- walk(root, 0);
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 instanceof WorkerDeckError && e.status === 404) setUnsupported(true);
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 = useRef(true);
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 instanceof WorkerDeckError && e.status === 404) {
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-profile-usage.ts
1203
+ //#region src/hooks/use-draft.ts
1431
1204
  /**
1432
- * The gateway's per-profile plan usage, over REST.
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 [usage, setUsage] = useState();
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), [client, profile]);
1456
- const alive = useRef(true);
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
- setUsage(res.profiles.find((p) => p.name === profile)?.usage);
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 instanceof WorkerDeckError && e.status === 404) setUnsupported(true);
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 void 0;
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 ?? 64 * 1024 * 1024);
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 ? [...new Set([...base, ...clientNames])] : clientNames;
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 void 0;
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