@workerdeck/react 0.7.0 → 0.11.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
@@ -1,7 +1,10 @@
1
- import { useEffect, useMemo, useReducer, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
2
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION } from "@workerdeck/protocol";
3
+ import { WorkerDeckError } from "@workerdeck/client";
2
4
  //#region src/transcript.ts
3
5
  const initialTranscriptState = {
4
6
  status: "starting",
7
+ capabilities: ENGINE_CAPABILITIES.claude,
5
8
  items: [],
6
9
  pendingApprovals: [],
7
10
  totalCostUsd: 0,
@@ -45,6 +48,7 @@ function upsert(items, item) {
45
48
  * haven't set yet; the event stream stays authoritative.
46
49
  */
47
50
  function seedFromSessionInfo(state, info) {
51
+ const engine = info.engine ?? state.engine;
48
52
  return {
49
53
  ...state,
50
54
  status: state.lastSeq === 0 ? info.status : state.status,
@@ -52,9 +56,29 @@ function seedFromSessionInfo(state, info) {
52
56
  permissionMode: state.permissionMode ?? info.permissionMode,
53
57
  cwd: state.cwd ?? info.cwd,
54
58
  sdkSessionId: state.sdkSessionId ?? info.sdkSessionId,
55
- engine: info.engine ?? state.engine
59
+ engine,
60
+ capabilities: info.capabilities ?? ENGINE_CAPABILITIES[engine ?? "claude"],
61
+ session: info
56
62
  };
57
63
  }
64
+ /**
65
+ * The session's rate-limit windows in reading order: the session window, the
66
+ * weekly window, then whichever per-model weekly windows it reports.
67
+ *
68
+ * Discovered rather than hardcoded — the SDK's set of windows is an open union
69
+ * and has grown before — but ordered, so the first two always mean the same
70
+ * thing. A window with no `utilization` is *unknown*, not zero, and is dropped
71
+ * entirely rather than drawn as an empty bar that reads as "plenty left".
72
+ */
73
+ function rateLimitWindows(state) {
74
+ const all = Object.entries(state.rateLimits ?? {}).filter(([, info]) => info.utilization !== void 0).map(([key, info]) => ({
75
+ key,
76
+ info
77
+ }));
78
+ const named = ["five_hour", "seven_day"].flatMap((key) => all.filter((w) => w.key === key));
79
+ const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
80
+ return [...named, ...perModel];
81
+ }
58
82
  function applyEvent(state, event) {
59
83
  if (event.seq <= state.lastSeq) return state;
60
84
  const base = {
@@ -77,7 +101,23 @@ function applyEvent(state, event) {
77
101
  case "capabilities": return {
78
102
  ...base,
79
103
  models: event.models,
80
- commands: event.commands
104
+ commands: event.commands,
105
+ defaultModel: event.defaultModel ?? base.defaultModel
106
+ };
107
+ case "skills": return {
108
+ ...base,
109
+ skills: event.skills
110
+ };
111
+ case "file_produced": return {
112
+ ...base,
113
+ producedFiles: {
114
+ ...base.producedFiles,
115
+ [event.path]: {
116
+ fileId: event.fileId,
117
+ ...event.mediaType ? { mediaType: event.mediaType } : {},
118
+ ...event.bytes !== void 0 ? { bytes: event.bytes } : {}
119
+ }
120
+ }
81
121
  };
82
122
  case "model_changed": return event.model === void 0 ? base : {
83
123
  ...base,
@@ -99,9 +139,14 @@ function applyEvent(state, event) {
99
139
  rateLimits: {
100
140
  ...base.rateLimits,
101
141
  [key]: event.info
102
- }
142
+ },
143
+ rateLimitsUpdatedAt: event.ts
103
144
  };
104
145
  }
146
+ case "plan_info": return {
147
+ ...base,
148
+ subscriptionType: event.subscriptionType
149
+ };
105
150
  case "user_message": {
106
151
  let items = base.items;
107
152
  for (const block of contentToBlocks(event.message.content)) if (block.type === "tool_result") {
@@ -127,7 +172,8 @@ function applyEvent(state, event) {
127
172
  else items = upsert(items, {
128
173
  kind: "user",
129
174
  id: event.uuid ?? `user-${event.seq}`,
130
- text
175
+ text,
176
+ attachments: event.attachments
131
177
  });
132
178
  }
133
179
  return {
@@ -298,10 +344,14 @@ function applyEvent(state, event) {
298
344
  function reduce(state, action) {
299
345
  return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
300
346
  }
347
+ /** Failed attempts in a row before "reconnecting…" stops being the honest word.
348
+ * Three is ~3.5s of backoff — past a blip. Matches the iOS client. */
349
+ const OFFLINE_AFTER_ATTEMPTS = 3;
301
350
  /** Attach to a session and maintain live transcript state. Detaches on unmount. */
302
351
  function useClaudeSession(client, sessionId, options) {
303
352
  const [state, dispatch] = useReducer(reduce, initialTranscriptState);
304
- const [connected, setConnected] = useState(false);
353
+ const [connection, setConnection] = useState("reconnecting");
354
+ const [protocolMismatch, setProtocolMismatch] = useState();
305
355
  const [handleState, setHandleState] = useState();
306
356
  const handleRef = useRef(null);
307
357
  const onProtocolErrorRef = useRef(options?.onProtocolError);
@@ -312,8 +362,12 @@ function useClaudeSession(client, sessionId, options) {
312
362
  handleRef.current = handle;
313
363
  setHandleState(handle);
314
364
  const offEvent = handle.on("event", (event) => dispatch(event));
315
- const offAttached = handle.on("attached", (frame) => dispatch(frame));
316
- const offConn = handle.on("connectionChange", setConnected);
365
+ const offAttached = handle.on("attached", (frame) => {
366
+ dispatch(frame);
367
+ setProtocolMismatch(frame.protocolVersion === PROTOCOL_VERSION ? void 0 : frame.protocolVersion);
368
+ });
369
+ const offConn = handle.on("connectionChange", (open) => setConnection(open ? "live" : "reconnecting"));
370
+ const offRetry = handle.on("reconnectAttempt", (attempts) => setConnection(attempts >= OFFLINE_AFTER_ATTEMPTS ? "offline" : "reconnecting"));
317
371
  const offProtocolError = handle.on("protocolError", (message) => {
318
372
  onProtocolErrorRef.current?.(message);
319
373
  });
@@ -321,28 +375,981 @@ function useClaudeSession(client, sessionId, options) {
321
375
  offEvent();
322
376
  offAttached();
323
377
  offConn();
378
+ offRetry();
324
379
  offProtocolError();
325
380
  handle.detach();
326
381
  handleRef.current = null;
327
382
  setHandleState(void 0);
383
+ setConnection("reconnecting");
384
+ setProtocolMismatch(void 0);
328
385
  };
329
386
  }, [client, sessionId]);
387
+ const models = useProfileModelFallback(client, sessionId, state);
388
+ const connected = connection === "live";
389
+ const reconnectNow = useCallback(() => handleRef.current?.reconnectNow(), []);
330
390
  return useMemo(() => ({
331
391
  state,
332
392
  connected,
393
+ connection,
394
+ protocolMismatch,
395
+ models,
396
+ effectiveModel: state.model ?? state.defaultModel,
333
397
  handle: handleState,
334
- send: (text) => handleRef.current?.send(text),
398
+ send: (text, attachmentIds) => handleRef.current?.send(text, attachmentIds),
335
399
  approve: (requestId, updatedInput) => handleRef.current?.approve(requestId, updatedInput),
336
- deny: (requestId, message) => handleRef.current?.deny(requestId, message),
400
+ deny: (requestId, message, interrupt) => handleRef.current?.deny(requestId, message, interrupt),
337
401
  interrupt: () => handleRef.current?.interrupt(),
338
402
  setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
339
403
  setModel: (model) => handleRef.current?.setModel(model),
340
- closeSession: () => handleRef.current?.closeSession()
404
+ closeSession: () => handleRef.current?.closeSession(),
405
+ reconnectNow
341
406
  }), [
342
407
  state,
343
408
  connected,
344
- handleState
409
+ connection,
410
+ protocolMismatch,
411
+ models,
412
+ handleState,
413
+ reconnectNow
414
+ ]);
415
+ }
416
+ /**
417
+ * The session's profile catalog, fetched once and only when it could matter —
418
+ * i.e. when the engine has reported no models of its own.
419
+ *
420
+ * Fire-and-forget on purpose: an empty catalog is exactly the state a picker
421
+ * already handles, so a failed or 404'd `/profiles` (a server predating them)
422
+ * degrades to the old behaviour rather than raising an error about a list the
423
+ * operator may never open.
424
+ */
425
+ function useProfileModelFallback(client, sessionId, state) {
426
+ const [catalog, setCatalog] = useState([]);
427
+ const profile = state.session?.profile;
428
+ const reported = state.models;
429
+ const hasReported = !!reported?.length;
430
+ useEffect(() => setCatalog([]), [sessionId]);
431
+ useEffect(() => {
432
+ if (!profile || hasReported) return;
433
+ let cancelled = false;
434
+ client.listProfiles().then((response) => {
435
+ if (!cancelled) setCatalog(response.profiles.find((p) => p.name === profile)?.models ?? []);
436
+ }).catch(() => {});
437
+ return () => {
438
+ cancelled = true;
439
+ };
440
+ }, [
441
+ client,
442
+ profile,
443
+ hasReported
444
+ ]);
445
+ return hasReported ? reported : catalog;
446
+ }
447
+ //#endregion
448
+ //#region src/use-attachments.ts
449
+ /**
450
+ * How a media type reaches a model, in the capability record's vocabulary.
451
+ * `undefined` means this build can't classify it — the upload still goes,
452
+ * because the gateway's vocabulary is the authoritative one.
453
+ */
454
+ function attachmentKind(mediaType) {
455
+ const type = mediaType.split(";")[0].trim().toLowerCase();
456
+ if (type.startsWith("image/")) return "image";
457
+ if (type === "application/pdf") return "pdf";
458
+ if (type.startsWith("text/")) return "text";
459
+ if (TEXTUAL_TYPES.has(type)) return "text";
460
+ }
461
+ /** Textual types whose media type doesn't start with `text/` — mirrors core. */
462
+ const TEXTUAL_TYPES = new Set([
463
+ "application/json",
464
+ "application/xml",
465
+ "application/yaml",
466
+ "application/x-yaml",
467
+ "application/toml",
468
+ "application/javascript",
469
+ "application/typescript",
470
+ "application/x-sh",
471
+ "application/sql"
472
+ ]);
473
+ /** Longest edge an image is downscaled to before upload. Anthropic's own
474
+ * recommendation, and the same number the iOS client uses — a phone photo is
475
+ * several times this in each direction and costs tokens for nothing. */
476
+ const MAX_IMAGE_EDGE = 1568;
477
+ /**
478
+ * Stage, upload and track files for the next message of a session.
479
+ *
480
+ * Refusals happen as early as they can be known: a kind the capability record
481
+ * forswears never reaches the network (the gateway would 415 it), and everything
482
+ * else is the gateway's call — its vocabulary is authoritative, so an unknown
483
+ * media type is uploaded rather than guessed at.
484
+ */
485
+ function useAttachments(client, sessionId, { capabilities, engine }) {
486
+ const [items, setItems] = useState([]);
487
+ const [error, setError] = useState();
488
+ const counter = useRef(0);
489
+ /** The originals, kept so a failed upload can be retried without re-picking. */
490
+ const fileByKey = useRef(/* @__PURE__ */ new Map());
491
+ /** Mirrors the live preview URLs so unmount can revoke them all — an unmount
492
+ * with blobs outstanding is a leak the GC does not clean up. */
493
+ const previewUrls = useRef([]);
494
+ previewUrls.current = items.flatMap((item) => item.previewUrl ? [item.previewUrl] : []);
495
+ const accepts = capabilities.attachments;
496
+ useEffect(() => () => {
497
+ for (const url of previewUrls.current) URL.revokeObjectURL(url);
498
+ }, []);
499
+ const patch = useCallback((key, next) => {
500
+ setItems((current) => current.map((item) => item.key === key ? {
501
+ ...item,
502
+ ...next
503
+ } : item));
504
+ }, []);
505
+ const upload = useCallback((key, file) => {
506
+ if (!sessionId) return;
507
+ patch(key, {
508
+ status: "uploading",
509
+ error: void 0
510
+ });
511
+ (async () => {
512
+ try {
513
+ const data = await prepare(file);
514
+ const uploaded = await client.uploadAttachment(sessionId, {
515
+ name: file.name,
516
+ mediaType: data.mediaType,
517
+ data: data.body
518
+ });
519
+ patch(key, {
520
+ status: "ready",
521
+ id: uploaded.id,
522
+ bytes: uploaded.bytes ?? file.size
523
+ });
524
+ } catch (e) {
525
+ patch(key, {
526
+ status: "failed",
527
+ error: e instanceof Error ? e.message : "Upload failed"
528
+ });
529
+ }
530
+ })();
531
+ }, [
532
+ client,
533
+ patch,
534
+ sessionId
535
+ ]);
536
+ const add = useCallback((files) => {
537
+ const staged = [];
538
+ const pending = [];
539
+ for (const file of files) {
540
+ const mediaType = file.type || "application/octet-stream";
541
+ const kind = attachmentKind(mediaType);
542
+ if (kind && !accepts.includes(kind)) {
543
+ setError(`The ${engine ?? "claude"} engine does not take ${kind} attachments.`);
544
+ continue;
545
+ }
546
+ const key = `att-${++counter.current}`;
547
+ staged.push({
548
+ key,
549
+ name: file.name,
550
+ mediaType,
551
+ bytes: file.size,
552
+ previewUrl: kind === "image" ? URL.createObjectURL(file) : void 0,
553
+ status: "uploading"
554
+ });
555
+ pending.push({
556
+ key,
557
+ file
558
+ });
559
+ }
560
+ if (staged.length === 0) return;
561
+ setItems((current) => [...current, ...staged]);
562
+ fileByKey.current = new Map([...fileByKey.current, ...pending.map(({ key, file }) => [key, file])]);
563
+ for (const { key, file } of pending) upload(key, file);
564
+ }, [
565
+ accepts,
566
+ engine,
567
+ upload
568
+ ]);
569
+ const forget = useCallback((keys) => {
570
+ setItems((current) => {
571
+ for (const item of current) if (keys.includes(item.key) && item.previewUrl) URL.revokeObjectURL(item.previewUrl);
572
+ return current.filter((item) => !keys.includes(item.key));
573
+ });
574
+ for (const key of keys) fileByKey.current.delete(key);
575
+ }, []);
576
+ const remove = useCallback((key) => forget([key]), [forget]);
577
+ const clear = useCallback(() => {
578
+ setItems((current) => {
579
+ for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
580
+ return [];
581
+ });
582
+ fileByKey.current.clear();
583
+ }, []);
584
+ const retry = useCallback((key) => {
585
+ const file = fileByKey.current.get(key);
586
+ if (file) upload(key, file);
587
+ }, [upload]);
588
+ return useMemo(() => ({
589
+ items,
590
+ readyIds: items.flatMap((item) => item.id ? [item.id] : []),
591
+ uploading: items.some((item) => item.status === "uploading"),
592
+ hasFailure: items.some((item) => item.status === "failed"),
593
+ accept: acceptAttribute(accepts),
594
+ disabled: accepts.length === 0 || !sessionId,
595
+ add,
596
+ retry,
597
+ remove,
598
+ clear,
599
+ error,
600
+ dismissError: () => setError(void 0)
601
+ }), [
602
+ items,
603
+ accepts,
604
+ sessionId,
605
+ add,
606
+ retry,
607
+ remove,
608
+ clear,
609
+ error
610
+ ]);
611
+ }
612
+ /** What a file input should offer. The full set keeps the open door (anything —
613
+ * the gateway refuses the rest with a clear message); a narrower record narrows
614
+ * the browsing too, so most refusals never happen. */
615
+ function acceptAttribute(kinds) {
616
+ if (kinds.length === 0) return "";
617
+ const parts = [];
618
+ if (kinds.includes("image")) parts.push("image/*");
619
+ if (kinds.includes("pdf")) parts.push("application/pdf");
620
+ if (kinds.includes("text")) parts.push("text/*", ".md", ".json", ".yaml", ".yml", ".toml");
621
+ return kinds.length === 3 ? "" : parts.join(",");
622
+ }
623
+ const imaging = globalThis;
624
+ /**
625
+ * The bytes to upload, and the type they are.
626
+ *
627
+ * Oversized images are redrawn to {@link MAX_IMAGE_EDGE} first: a modern phone
628
+ * photo is 4000px on its long edge, which costs tokens for detail no model
629
+ * reads, and often exceeds the gateway's per-file cap outright. Everything else
630
+ * — and anything the browser can't decode — is uploaded as-is, so a failure here
631
+ * is never worse than not trying.
632
+ */
633
+ async function prepare(file) {
634
+ const mediaType = file.type || "application/octet-stream";
635
+ const { createImageBitmap, document } = imaging;
636
+ if (!createImageBitmap || !document || !mediaType.startsWith("image/")) return {
637
+ body: file,
638
+ mediaType
639
+ };
640
+ if (mediaType === "image/gif") return {
641
+ body: file,
642
+ mediaType
643
+ };
644
+ try {
645
+ const bitmap = await createImageBitmap(file);
646
+ const longest = Math.max(bitmap.width, bitmap.height);
647
+ if (longest <= MAX_IMAGE_EDGE) {
648
+ bitmap.close();
649
+ return {
650
+ body: file,
651
+ mediaType
652
+ };
653
+ }
654
+ const scale = MAX_IMAGE_EDGE / longest;
655
+ const canvas = document.createElement("canvas");
656
+ canvas.width = Math.round(bitmap.width * scale);
657
+ canvas.height = Math.round(bitmap.height * scale);
658
+ const context = canvas.getContext("2d");
659
+ if (!context) {
660
+ bitmap.close();
661
+ return {
662
+ body: file,
663
+ mediaType
664
+ };
665
+ }
666
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
667
+ bitmap.close();
668
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .85));
669
+ return blob ? {
670
+ body: blob,
671
+ mediaType: "image/jpeg"
672
+ } : {
673
+ body: file,
674
+ mediaType
675
+ };
676
+ } catch {
677
+ return {
678
+ body: file,
679
+ mediaType
680
+ };
681
+ }
682
+ }
683
+ //#endregion
684
+ //#region src/prompt-tokens.ts
685
+ /** Characters a command name may contain after the slash. Deliberately excludes
686
+ * `/`, so an absolute path pasted into a message (`/Users/me/…`) is not mistaken
687
+ * for a command; `:` is in because namespaced skills (`dev:wrapup`) are spelled
688
+ * that way. */
689
+ const COMMAND_BODY = /^[A-Za-z0-9\-_.:]+$/;
690
+ /** Trailing punctuation that belongs to the sentence, not the token — so
691
+ * "see @README.md." styles the path and leaves the period alone. */
692
+ const SENTENCE_TAIL = new Set([
693
+ ".",
694
+ ",",
695
+ ";",
696
+ ":",
697
+ "!",
698
+ "?",
699
+ ")",
700
+ "]",
701
+ "}",
702
+ "\"",
703
+ "'"
704
+ ]);
705
+ /**
706
+ * Every token in a sent message.
707
+ *
708
+ * Stricter than what a composer completes: a bare `@` is a token being typed, but
709
+ * in a sent message it is just an at sign.
710
+ */
711
+ function scanPromptTokens(text) {
712
+ const tokens = [];
713
+ const words = /\S+/g;
714
+ let match;
715
+ while ((match = words.exec(text)) !== null) {
716
+ const word = match[0];
717
+ const kind = word[0] === "@" ? "file" : word[0] === "/" ? "command" : void 0;
718
+ if (!kind) continue;
719
+ let end = match.index + word.length;
720
+ while (end > match.index && SENTENCE_TAIL.has(text[end - 1])) end--;
721
+ const body = text.slice(match.index + 1, end);
722
+ if (!body) continue;
723
+ if (kind === "command" && !COMMAND_BODY.test(body)) continue;
724
+ tokens.push({
725
+ kind,
726
+ start: match.index,
727
+ end,
728
+ text: text.slice(match.index, end)
729
+ });
730
+ }
731
+ return tokens;
732
+ }
733
+ //#endregion
734
+ //#region src/host-tree.ts
735
+ /**
736
+ * Flatten the loaded directories into the rows the tree shows.
737
+ *
738
+ * Pure, so the interesting part of a file tree — which nodes are visible at what
739
+ * depth once a few directories are expanded and one of them is still loading —
740
+ * is testable without a DOM or a gateway.
741
+ *
742
+ * Only *expanded* directories contribute children, and only if their listing has
743
+ * arrived. An expanded-but-unlisted directory yields its own row with
744
+ * `loading: true` and no children: expansion is a request the user already made,
745
+ * so the row must say the answer is coming rather than look like an empty folder.
746
+ */
747
+ function flattenHostTree(root, dirs, expanded) {
748
+ const rows = [];
749
+ const walk = (dir, depth) => {
750
+ const state = dirs.get(dir);
751
+ if (!state) return;
752
+ for (const entry of state.entries) {
753
+ if (entry.type !== "dir") {
754
+ rows.push({
755
+ entry,
756
+ depth
757
+ });
758
+ continue;
759
+ }
760
+ const isExpanded = expanded.has(entry.path);
761
+ const childState = dirs.get(entry.path);
762
+ rows.push({
763
+ entry,
764
+ depth,
765
+ expanded: isExpanded,
766
+ loading: isExpanded && !childState,
767
+ truncated: isExpanded ? childState?.truncated : void 0
768
+ });
769
+ if (isExpanded && childState) walk(entry.path, depth + 1);
770
+ }
771
+ };
772
+ walk(root, 0);
773
+ return rows;
774
+ }
775
+ /**
776
+ * Every ancestor of `path` below `root`, outermost first — the directories that
777
+ * must be expanded for `path` to be on screen.
778
+ *
779
+ * Returns `[]` when `path` is not under `root` rather than guessing: revealing a
780
+ * file the tree cannot contain is a no-op, not an error worth raising, and the
781
+ * caller has no better answer either.
782
+ *
783
+ * The prefix test is on a **path boundary** (`root` + `/`), so `/src/app` is not
784
+ * treated as living under `/src/a`.
785
+ */
786
+ function ancestorsWithin(root, path) {
787
+ const base = root.endsWith("/") ? root.slice(0, -1) : root;
788
+ if (path === base || !path.startsWith(`${base}/`)) return [];
789
+ const rest = path.slice(base.length + 1).split("/");
790
+ const out = [];
791
+ let current = base;
792
+ for (const segment of rest.slice(0, -1)) {
793
+ current = `${current}/${segment}`;
794
+ out.push(current);
795
+ }
796
+ return out;
797
+ }
798
+ //#endregion
799
+ //#region src/use-host-files.ts
800
+ /**
801
+ * Fuzzy file search rooted at a session's working directory — what an `@file`
802
+ * picker needs.
803
+ *
804
+ * Deliberately session-scoped: the server's `hostFiles.roots` are the security
805
+ * boundary, but what someone wants while talking to an agent is *this* project's
806
+ * tree, so this never offers the roots list.
807
+ *
808
+ * A gateway that answers 404 once has answered for the session: host files are
809
+ * either configured or they aren't, and the answer will not change while the cwd
810
+ * holds. Asking again on every character would be a request per keystroke for a
811
+ * feature that does not exist here.
812
+ */
813
+ function useHostFileSearch(client, cwd) {
814
+ const [unsupported, setUnsupported] = useState(false);
815
+ const lastCwd = useRef(cwd);
816
+ useEffect(() => {
817
+ if (lastCwd.current !== cwd) {
818
+ lastCwd.current = cwd;
819
+ setUnsupported(false);
820
+ }
821
+ }, [cwd]);
822
+ const search = useCallback(async (query, options) => {
823
+ if (!cwd || unsupported) return [];
824
+ try {
825
+ const response = await client.findHostFiles(cwd, query, options?.limit ?? 8);
826
+ return options?.signal?.aborted ? [] : response.matches;
827
+ } catch (e) {
828
+ if (e instanceof WorkerDeckError && e.status === 404) setUnsupported(true);
829
+ return [];
830
+ }
831
+ }, [
832
+ client,
833
+ cwd,
834
+ unsupported
835
+ ]);
836
+ return {
837
+ available: !!cwd && !unsupported,
838
+ search
839
+ };
840
+ }
841
+ /**
842
+ * Whether host files are served here, and whether they may be written.
843
+ *
844
+ * One request per client, cached for the life of the hook: the roots and the
845
+ * write flag are gateway configuration, not session state, and they do not
846
+ * change while the tab is open.
847
+ */
848
+ function useHostFileRoots(client) {
849
+ const [result, setResult] = useState({
850
+ available: false,
851
+ canWrite: false
852
+ });
853
+ useEffect(() => {
854
+ let cancelled = false;
855
+ client.listHostRoots().then((response) => {
856
+ if (!cancelled) setResult({
857
+ available: true,
858
+ canWrite: response.canWrite
859
+ });
860
+ }).catch(() => {
861
+ if (!cancelled) setResult({
862
+ available: false,
863
+ canWrite: false
864
+ });
865
+ });
866
+ return () => {
867
+ cancelled = true;
868
+ };
869
+ }, [client]);
870
+ return result;
871
+ }
872
+ /**
873
+ * An expandable file tree rooted at a session's working directory.
874
+ *
875
+ * Rooted at the cwd rather than at `/fs/roots` for the same reason
876
+ * {@link useHostFileSearch} is: the roots are the *security* boundary the server
877
+ * enforces on every request, but what someone wants while watching an agent work
878
+ * is this project's tree. The roots may well be broader; showing them would
879
+ * offer navigation to directories the session has nothing to do with.
880
+ *
881
+ * Listings are cached per directory and kept across a collapse, so reopening a
882
+ * folder is instant and does not re-ask. That staleness is deliberate and
883
+ * bounded: `refresh` exists, and knowing when to call it is the *next* problem
884
+ * (the agent is editing this same tree), not something a tree can guess.
885
+ *
886
+ * Like the search hook, a 404 is answered once for the session: host files are
887
+ * either configured here or they are not.
888
+ */
889
+ function useHostFileTree(client, cwd) {
890
+ const [dirs, setDirs] = useState(() => /* @__PURE__ */ new Map());
891
+ const [expanded, setExpanded] = useState(() => /* @__PURE__ */ new Set());
892
+ const [unsupported, setUnsupported] = useState(false);
893
+ const [error, setError] = useState();
894
+ const lastCwd = useRef(cwd);
895
+ useEffect(() => {
896
+ if (lastCwd.current === cwd) return;
897
+ lastCwd.current = cwd;
898
+ setDirs(/* @__PURE__ */ new Map());
899
+ setExpanded(/* @__PURE__ */ new Set());
900
+ setUnsupported(false);
901
+ setError(void 0);
902
+ }, [cwd]);
903
+ const alive = useRef(true);
904
+ useEffect(() => {
905
+ alive.current = true;
906
+ return () => {
907
+ alive.current = false;
908
+ };
909
+ }, []);
910
+ const requested = useRef(/* @__PURE__ */ new Set());
911
+ const list = useCallback((target, { force = false } = {}) => {
912
+ if (unsupported) return;
913
+ if (!force && requested.current.has(target)) return;
914
+ requested.current.add(target);
915
+ client.listHostDir(target).then((response) => {
916
+ if (!alive.current) return;
917
+ setDirs((previous) => {
918
+ const next = new Map(previous);
919
+ next.set(target, {
920
+ entries: response.entries,
921
+ truncated: response.truncated
922
+ });
923
+ return next;
924
+ });
925
+ }).catch((e) => {
926
+ if (!alive.current) return;
927
+ requested.current.delete(target);
928
+ if (e instanceof WorkerDeckError && e.status === 404) {
929
+ setUnsupported(true);
930
+ return;
931
+ }
932
+ setError(e instanceof Error ? e.message : "Could not read that directory");
933
+ });
934
+ }, [client, unsupported]);
935
+ useEffect(() => {
936
+ if (cwd) list(cwd);
937
+ }, [cwd, list]);
938
+ const toggle = useCallback((path) => {
939
+ setExpanded((previous) => {
940
+ const next = new Set(previous);
941
+ if (next.has(path)) next.delete(path);
942
+ else next.add(path);
943
+ return next;
944
+ });
945
+ list(path);
946
+ }, [list]);
947
+ const reveal = useCallback((path) => {
948
+ if (!cwd) return;
949
+ const ancestors = ancestorsWithin(cwd, path);
950
+ if (ancestors.length === 0) return;
951
+ for (const dir of ancestors) list(dir);
952
+ setExpanded((previous) => {
953
+ const next = new Set(previous);
954
+ for (const dir of ancestors) next.add(dir);
955
+ return next;
956
+ });
957
+ }, [cwd, list]);
958
+ const refresh = useCallback((path) => {
959
+ const target = path ?? cwd;
960
+ if (!target) return;
961
+ setError(void 0);
962
+ list(target, { force: true });
963
+ }, [cwd, list]);
964
+ const rows = useMemo(() => cwd ? flattenHostTree(cwd, dirs, expanded) : [], [
965
+ cwd,
966
+ dirs,
967
+ expanded
345
968
  ]);
969
+ return {
970
+ available: !!cwd && !unsupported,
971
+ root: cwd,
972
+ rows,
973
+ loading: !!cwd && !unsupported && !dirs.has(cwd) && !error,
974
+ error,
975
+ toggle,
976
+ reveal,
977
+ refresh
978
+ };
979
+ }
980
+ //#endregion
981
+ //#region src/use-session-info.ts
982
+ /**
983
+ * The registry's record of one session, over REST.
984
+ *
985
+ * Separate from {@link useClaudeSession} on purpose: that hook attaches a
986
+ * WebSocket and streams a transcript, which is far more than a caller needs to
987
+ * know a session's `cwd` or title — and a second attach would be a second
988
+ * client on the bridge, which is the one thing the bridge's "asks the first
989
+ * attached client" rule cannot tolerate.
990
+ *
991
+ * Fetched once per session id. The record is registry state, not a live feed;
992
+ * anything that changes during a run arrives on the session's event stream.
993
+ */
994
+ function useSessionInfo(client, sessionId) {
995
+ const [info, setInfo] = useState();
996
+ const [loading, setLoading] = useState(!!sessionId);
997
+ const [error, setError] = useState();
998
+ useEffect(() => {
999
+ if (!sessionId) {
1000
+ setInfo(void 0);
1001
+ setLoading(false);
1002
+ setError(void 0);
1003
+ return;
1004
+ }
1005
+ let cancelled = false;
1006
+ setLoading(true);
1007
+ setError(void 0);
1008
+ setInfo(void 0);
1009
+ client.getSession(sessionId).then((next) => {
1010
+ if (cancelled) return;
1011
+ setInfo(next);
1012
+ setLoading(false);
1013
+ }).catch((e) => {
1014
+ if (cancelled) return;
1015
+ setError(e instanceof Error ? e.message : "Session not found");
1016
+ setLoading(false);
1017
+ });
1018
+ return () => {
1019
+ cancelled = true;
1020
+ };
1021
+ }, [client, sessionId]);
1022
+ return {
1023
+ info,
1024
+ loading,
1025
+ error
1026
+ };
1027
+ }
1028
+ //#endregion
1029
+ //#region src/open-files.ts
1030
+ /** Whether a tab has edits that are not on disk. Derived, so typing something
1031
+ * and undoing it back leaves the tab clean — which is what an editor should do
1032
+ * and what a boolean flag set on first keystroke would get wrong. */
1033
+ function isDirty(file) {
1034
+ return file.draft !== void 0 && file.draft !== file.content;
1035
+ }
1036
+ /** What a tab would write: its edits if it has any, else what it read. */
1037
+ function currentText(file) {
1038
+ return file.draft ?? file.content ?? "";
1039
+ }
1040
+ const initialOpenFilesState = { files: [] };
1041
+ /**
1042
+ * The tab strip and the editor's whole behaviour, as a pure function.
1043
+ *
1044
+ * The rules worth stating, because they are the ones a naive implementation
1045
+ * gets wrong:
1046
+ *
1047
+ * - **Opening an open path never re-reads it.** It focuses the tab. Re-reading
1048
+ * would silently discard that tab's unsaved edits on a double click.
1049
+ * - **Closing the focused tab focuses its right-hand neighbour**, falling back
1050
+ * to the left when it was last. Focusing "the first tab" instead is what makes
1051
+ * closing several tabs in a row jump the user around.
1052
+ * - **A successful save is applied against the text that was sent**, not against
1053
+ * the tab's current text. Typing during a save is normal; treating the write's
1054
+ * completion as "the tab is now clean" would silently drop those keystrokes.
1055
+ * - **Nothing here discards edits implicitly.** `revert` and `loaded` are the
1056
+ * only two things that clear a draft, and both are the direct result of
1057
+ * someone asking for it. The conditional write exists so a browser edit cannot
1058
+ * clobber the agent mid-run; this holds the same line in the other direction.
1059
+ *
1060
+ * Late results are addressed by path and dropped if that tab is gone, so a slow
1061
+ * read of a closed file cannot resurrect it.
1062
+ */
1063
+ function openFilesReducer(state, action) {
1064
+ switch (action.type) {
1065
+ case "open": {
1066
+ if (state.files.some((f) => f.path === action.path)) return state.activePath === action.path ? state : {
1067
+ ...state,
1068
+ activePath: action.path
1069
+ };
1070
+ const file = {
1071
+ path: action.path,
1072
+ name: baseName(action.path),
1073
+ status: "loading"
1074
+ };
1075
+ return {
1076
+ files: [...state.files, file],
1077
+ activePath: action.path
1078
+ };
1079
+ }
1080
+ case "close": {
1081
+ const index = state.files.findIndex((f) => f.path === action.path);
1082
+ if (index === -1) return state;
1083
+ const files = state.files.filter((f) => f.path !== action.path);
1084
+ if (state.activePath !== action.path) return {
1085
+ ...state,
1086
+ files
1087
+ };
1088
+ return {
1089
+ files,
1090
+ activePath: (files[index] ?? files[index - 1])?.path
1091
+ };
1092
+ }
1093
+ case "closeAll": return initialOpenFilesState;
1094
+ case "activate":
1095
+ if (!state.files.some((f) => f.path === action.path)) return state;
1096
+ return state.activePath === action.path ? state : {
1097
+ ...state,
1098
+ activePath: action.path
1099
+ };
1100
+ case "loaded": return patch(state, action.path, () => ({
1101
+ path: action.path,
1102
+ name: baseName(action.path),
1103
+ status: action.encoding === "utf8" ? "ready" : "binary",
1104
+ content: action.encoding === "utf8" ? action.content : void 0,
1105
+ bytes: action.bytes,
1106
+ hash: action.hash,
1107
+ modifiedAt: action.modifiedAt
1108
+ }));
1109
+ case "failed": return patch(state, action.path, (file) => ({
1110
+ ...file,
1111
+ status: "error",
1112
+ error: action.error
1113
+ }));
1114
+ case "edit": return patch(state, action.path, (file) => file.status === "ready" ? {
1115
+ ...file,
1116
+ draft: action.content
1117
+ } : file);
1118
+ case "revert": return patch(state, action.path, (file) => ({
1119
+ ...file,
1120
+ draft: void 0,
1121
+ saveError: void 0,
1122
+ conflict: false
1123
+ }));
1124
+ case "saveStart": return patch(state, action.path, (file) => ({
1125
+ ...file,
1126
+ saving: true,
1127
+ saveError: void 0,
1128
+ conflict: false
1129
+ }));
1130
+ case "saved": return patch(state, action.path, (file) => ({
1131
+ ...file,
1132
+ saving: false,
1133
+ saveError: void 0,
1134
+ conflict: false,
1135
+ content: action.content,
1136
+ bytes: action.bytes,
1137
+ hash: action.hash,
1138
+ modifiedAt: action.modifiedAt,
1139
+ draft: file.draft === action.content ? void 0 : file.draft
1140
+ }));
1141
+ case "saveFailed": return patch(state, action.path, (file) => ({
1142
+ ...file,
1143
+ saving: false,
1144
+ saveError: action.error,
1145
+ conflict: action.conflict ?? false
1146
+ }));
1147
+ case "dismissConflict": return patch(state, action.path, (file) => ({
1148
+ ...file,
1149
+ conflict: false,
1150
+ saveError: void 0
1151
+ }));
1152
+ }
1153
+ }
1154
+ /** Replace one file in place, preserving tab order; a no-op if it was closed
1155
+ * while the request was in flight. */
1156
+ function patch(state, path, next) {
1157
+ const index = state.files.findIndex((f) => f.path === path);
1158
+ if (index === -1) return state;
1159
+ const current = state.files[index];
1160
+ const updated = next(current);
1161
+ if (updated === current) return state;
1162
+ const files = state.files.slice();
1163
+ files[index] = updated;
1164
+ return {
1165
+ ...state,
1166
+ files
1167
+ };
1168
+ }
1169
+ /** Last path segment. Trailing slashes are not expected here — these are file
1170
+ * paths from `/fs/list` and `/fs/find` — but a bare `/` should still show as
1171
+ * something rather than as an empty tab. */
1172
+ function baseName(path) {
1173
+ const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
1174
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1) || trimmed || path;
1175
+ }
1176
+ //#endregion
1177
+ //#region src/use-open-files.ts
1178
+ /**
1179
+ * The open-file tabs of a workspace: which files are open, which one is focused,
1180
+ * the bytes behind each, and the edits on top of them.
1181
+ *
1182
+ * Reads are fired from an effect keyed on "which tabs are still loading" rather
1183
+ * than from `open` itself, so the reducer stays pure and a tab that was opened,
1184
+ * closed and reopened does not carry a stale in-flight request with it.
1185
+ *
1186
+ * Deliberately **not** given the session's cwd: a tab is an absolute host path,
1187
+ * and where it came from — the tree, a search hit, a path in the transcript — is
1188
+ * the caller's business. Containment is the server's job on every `/fs/read` and
1189
+ * `/fs/write`, not something re-derived here from a directory this hook would
1190
+ * have to trust.
1191
+ */
1192
+ function useOpenFiles(client) {
1193
+ const [state, dispatch] = useReducer(openFilesReducer, initialOpenFilesState);
1194
+ const requested = useRef(/* @__PURE__ */ new Set());
1195
+ const alive = useRef(true);
1196
+ useEffect(() => {
1197
+ alive.current = true;
1198
+ return () => {
1199
+ alive.current = false;
1200
+ };
1201
+ }, []);
1202
+ const latest = useRef(state);
1203
+ useEffect(() => {
1204
+ latest.current = state;
1205
+ }, [state]);
1206
+ const pending = state.files.filter((f) => f.status === "loading").map((f) => f.path).join("\n");
1207
+ const read = useCallback((path) => client.readHostFile(path).then((response) => {
1208
+ if (!alive.current) return void 0;
1209
+ dispatch({
1210
+ type: "loaded",
1211
+ path,
1212
+ content: response.content,
1213
+ encoding: response.encoding,
1214
+ bytes: response.bytes,
1215
+ hash: response.hash,
1216
+ modifiedAt: response.modifiedAt
1217
+ });
1218
+ return response;
1219
+ }), [client]);
1220
+ useEffect(() => {
1221
+ for (const path of pending ? pending.split("\n") : []) {
1222
+ if (requested.current.has(path)) continue;
1223
+ requested.current.add(path);
1224
+ read(path).catch((e) => {
1225
+ if (!alive.current) return;
1226
+ dispatch({
1227
+ type: "failed",
1228
+ path,
1229
+ error: e instanceof Error ? e.message : "Could not read that file"
1230
+ });
1231
+ });
1232
+ }
1233
+ }, [read, pending]);
1234
+ const open = useCallback((path) => dispatch({
1235
+ type: "open",
1236
+ path
1237
+ }), []);
1238
+ const close = useCallback((path) => {
1239
+ requested.current.delete(path);
1240
+ dispatch({
1241
+ type: "close",
1242
+ path
1243
+ });
1244
+ }, []);
1245
+ const closeAll = useCallback(() => {
1246
+ requested.current.clear();
1247
+ dispatch({ type: "closeAll" });
1248
+ }, []);
1249
+ const activate = useCallback((path) => dispatch({
1250
+ type: "activate",
1251
+ path
1252
+ }), []);
1253
+ const edit = useCallback((path, content) => dispatch({
1254
+ type: "edit",
1255
+ path,
1256
+ content
1257
+ }), []);
1258
+ const revert = useCallback((path) => dispatch({
1259
+ type: "revert",
1260
+ path
1261
+ }), []);
1262
+ const dismissConflict = useCallback((path) => dispatch({
1263
+ type: "dismissConflict",
1264
+ path
1265
+ }), []);
1266
+ const reload = useCallback((path) => {
1267
+ requested.current.add(path);
1268
+ read(path).catch((e) => {
1269
+ if (!alive.current) return;
1270
+ dispatch({
1271
+ type: "failed",
1272
+ path,
1273
+ error: e instanceof Error ? e.message : "Could not re-read that file"
1274
+ });
1275
+ });
1276
+ }, [read]);
1277
+ /** One conditional write. Shared by `save` and `overwrite`, which differ only
1278
+ * in where the hash came from. */
1279
+ const write = useCallback(async (path, text, expectedHash) => {
1280
+ try {
1281
+ const response = await client.writeHostFile({
1282
+ path,
1283
+ content: text,
1284
+ expectedHash
1285
+ });
1286
+ if (!alive.current) return;
1287
+ dispatch({
1288
+ type: "saved",
1289
+ path,
1290
+ content: text,
1291
+ bytes: response.bytes,
1292
+ hash: response.hash,
1293
+ modifiedAt: response.modifiedAt
1294
+ });
1295
+ } catch (e) {
1296
+ if (!alive.current) return;
1297
+ const conflict = e instanceof WorkerDeckError && e.status === 409;
1298
+ dispatch({
1299
+ type: "saveFailed",
1300
+ path,
1301
+ conflict,
1302
+ error: conflict ? "This file changed on disk since you opened it." : e instanceof Error ? e.message : "Could not save that file"
1303
+ });
1304
+ }
1305
+ }, [client]);
1306
+ const save = useCallback(async (path) => {
1307
+ const file = latest.current.files.find((f) => f.path === path);
1308
+ if (!file || file.saving || !isDirty(file)) return;
1309
+ dispatch({
1310
+ type: "saveStart",
1311
+ path
1312
+ });
1313
+ await write(path, currentText(file), file.hash);
1314
+ }, [write]);
1315
+ const overwrite = useCallback(async (path) => {
1316
+ const file = latest.current.files.find((f) => f.path === path);
1317
+ if (!file || file.saving) return;
1318
+ const mine = currentText(file);
1319
+ dispatch({
1320
+ type: "saveStart",
1321
+ path
1322
+ });
1323
+ try {
1324
+ const fresh = await client.readHostFile(path);
1325
+ if (!alive.current) return;
1326
+ await write(path, mine, fresh.hash);
1327
+ } catch (e) {
1328
+ if (!alive.current) return;
1329
+ dispatch({
1330
+ type: "saveFailed",
1331
+ path,
1332
+ error: e instanceof Error ? e.message : "Could not save that file"
1333
+ });
1334
+ }
1335
+ }, [client, write]);
1336
+ const active = useMemo(() => state.files.find((f) => f.path === state.activePath), [state.files, state.activePath]);
1337
+ const hasUnsaved = useMemo(() => state.files.some(isDirty), [state.files]);
1338
+ return {
1339
+ ...state,
1340
+ active,
1341
+ hasUnsaved,
1342
+ open,
1343
+ close,
1344
+ closeAll,
1345
+ activate,
1346
+ edit,
1347
+ save,
1348
+ revert,
1349
+ reload,
1350
+ overwrite,
1351
+ dismissConflict
1352
+ };
346
1353
  }
347
1354
  //#endregion
348
1355
  //#region src/tool-host.ts
@@ -515,6 +1522,85 @@ function useToolCallHost(handle, options = {}) {
515
1522
  return { executions };
516
1523
  }
517
1524
  //#endregion
518
- export { applyEvent, createToolCallHost, initialTranscriptState, seedFromSessionInfo, useClaudeSession, useToolCallHost };
1525
+ //#region src/recap.ts
1526
+ /**
1527
+ * Summarize the items from `fromIndex` onward — the boundary being the number
1528
+ * of items that existed when the session was last looked at.
1529
+ *
1530
+ * An out-of-range boundary is clamped rather than rejected: a transcript can
1531
+ * *shrink* (a `/clear`, a fresh attach after a compaction), and the honest
1532
+ * reading of "you last saw 40 items, there are now 12" is "everything here is
1533
+ * new", not a negative count.
1534
+ */
1535
+ function summarizeSince(state, fromIndex) {
1536
+ const start = Math.max(0, Math.min(fromIndex, state.items.length));
1537
+ const fresh = state.items.slice(start);
1538
+ const toolCounts = /* @__PURE__ */ new Map();
1539
+ let turns = 0;
1540
+ let replies = 0;
1541
+ let tools = 0;
1542
+ let files = 0;
1543
+ let errors = 0;
1544
+ for (const item of fresh) switch (item.kind) {
1545
+ case "turn_result":
1546
+ turns += 1;
1547
+ if (item.isError) errors += 1;
1548
+ break;
1549
+ case "assistant_text":
1550
+ replies += 1;
1551
+ break;
1552
+ case "tool_call":
1553
+ tools += 1;
1554
+ toolCounts.set(item.name, (toolCounts.get(item.name) ?? 0) + 1);
1555
+ if (item.status === "failed" || item.result?.isError) errors += 1;
1556
+ break;
1557
+ case "file_delivered":
1558
+ files += 1;
1559
+ break;
1560
+ case "notice":
1561
+ if (item.level === "error") errors += 1;
1562
+ break;
1563
+ default: break;
1564
+ }
1565
+ const toolNames = [...toolCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
1566
+ const pending = state.pendingApprovals?.length ?? 0;
1567
+ return {
1568
+ turns,
1569
+ replies,
1570
+ tools,
1571
+ toolNames,
1572
+ files,
1573
+ errors,
1574
+ pending,
1575
+ any: turns + replies + tools + files + errors + pending > 0
1576
+ };
1577
+ }
1578
+ /**
1579
+ * The recap as one line of text, in the order a person reads it: what got done,
1580
+ * what it used, what went wrong, what is waiting.
1581
+ *
1582
+ * Returns `undefined` when there is nothing to say, so a caller can render the
1583
+ * row or not on the value alone.
1584
+ */
1585
+ function recapLine(summary) {
1586
+ if (!summary.any) return void 0;
1587
+ const parts = [];
1588
+ if (summary.turns > 0) parts.push(plural(summary.turns, "turn"));
1589
+ else if (summary.replies > 0) parts.push(plural(summary.replies, "reply", "replies"));
1590
+ if (summary.tools > 0) {
1591
+ const named = summary.toolNames.slice(0, 3).join(", ");
1592
+ const rest = summary.toolNames.length - 3;
1593
+ parts.push(`${plural(summary.tools, "tool call")}${named ? ` (${named}${rest > 0 ? `, +${rest}` : ""})` : ""}`);
1594
+ }
1595
+ if (summary.files > 0) parts.push(plural(summary.files, "file"));
1596
+ if (summary.errors > 0) parts.push(plural(summary.errors, "error"));
1597
+ if (summary.pending > 0) parts.push(`${plural(summary.pending, "approval")} waiting`);
1598
+ return parts.join(" · ");
1599
+ }
1600
+ function plural(count, one, many = `${one}s`) {
1601
+ return `${count} ${count === 1 ? one : many}`;
1602
+ }
1603
+ //#endregion
1604
+ export { ancestorsWithin, applyEvent, attachmentKind, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, useToolCallHost };
519
1605
 
520
1606
  //# sourceMappingURL=index.mjs.map