@workerdeck/react 0.9.0 → 0.10.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 = {
@@ -80,6 +104,21 @@ function applyEvent(state, event) {
80
104
  commands: event.commands,
81
105
  defaultModel: event.defaultModel ?? base.defaultModel
82
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
+ }
121
+ };
83
122
  case "model_changed": return event.model === void 0 ? base : {
84
123
  ...base,
85
124
  model: event.model
@@ -100,7 +139,8 @@ function applyEvent(state, event) {
100
139
  rateLimits: {
101
140
  ...base.rateLimits,
102
141
  [key]: event.info
103
- }
142
+ },
143
+ rateLimitsUpdatedAt: event.ts
104
144
  };
105
145
  }
106
146
  case "plan_info": return {
@@ -304,10 +344,14 @@ function applyEvent(state, event) {
304
344
  function reduce(state, action) {
305
345
  return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
306
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;
307
350
  /** Attach to a session and maintain live transcript state. Detaches on unmount. */
308
351
  function useClaudeSession(client, sessionId, options) {
309
352
  const [state, dispatch] = useReducer(reduce, initialTranscriptState);
310
- const [connected, setConnected] = useState(false);
353
+ const [connection, setConnection] = useState("reconnecting");
354
+ const [protocolMismatch, setProtocolMismatch] = useState();
311
355
  const [handleState, setHandleState] = useState();
312
356
  const handleRef = useRef(null);
313
357
  const onProtocolErrorRef = useRef(options?.onProtocolError);
@@ -318,8 +362,12 @@ function useClaudeSession(client, sessionId, options) {
318
362
  handleRef.current = handle;
319
363
  setHandleState(handle);
320
364
  const offEvent = handle.on("event", (event) => dispatch(event));
321
- const offAttached = handle.on("attached", (frame) => dispatch(frame));
322
- 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"));
323
371
  const offProtocolError = handle.on("protocolError", (message) => {
324
372
  onProtocolErrorRef.current?.(message);
325
373
  });
@@ -327,28 +375,981 @@ function useClaudeSession(client, sessionId, options) {
327
375
  offEvent();
328
376
  offAttached();
329
377
  offConn();
378
+ offRetry();
330
379
  offProtocolError();
331
380
  handle.detach();
332
381
  handleRef.current = null;
333
382
  setHandleState(void 0);
383
+ setConnection("reconnecting");
384
+ setProtocolMismatch(void 0);
334
385
  };
335
386
  }, [client, sessionId]);
387
+ const models = useProfileModelFallback(client, sessionId, state);
388
+ const connected = connection === "live";
389
+ const reconnectNow = useCallback(() => handleRef.current?.reconnectNow(), []);
336
390
  return useMemo(() => ({
337
391
  state,
338
392
  connected,
393
+ connection,
394
+ protocolMismatch,
395
+ models,
396
+ effectiveModel: state.model ?? state.defaultModel,
339
397
  handle: handleState,
340
398
  send: (text, attachmentIds) => handleRef.current?.send(text, attachmentIds),
341
399
  approve: (requestId, updatedInput) => handleRef.current?.approve(requestId, updatedInput),
342
- deny: (requestId, message) => handleRef.current?.deny(requestId, message),
400
+ deny: (requestId, message, interrupt) => handleRef.current?.deny(requestId, message, interrupt),
343
401
  interrupt: () => handleRef.current?.interrupt(),
344
402
  setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
345
403
  setModel: (model) => handleRef.current?.setModel(model),
346
- closeSession: () => handleRef.current?.closeSession()
404
+ closeSession: () => handleRef.current?.closeSession(),
405
+ reconnectNow
347
406
  }), [
348
407
  state,
349
408
  connected,
350
- 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
351
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
+ };
352
1353
  }
353
1354
  //#endregion
354
1355
  //#region src/tool-host.ts
@@ -521,6 +1522,6 @@ function useToolCallHost(handle, options = {}) {
521
1522
  return { executions };
522
1523
  }
523
1524
  //#endregion
524
- export { applyEvent, createToolCallHost, initialTranscriptState, seedFromSessionInfo, useClaudeSession, useToolCallHost };
1525
+ export { ancestorsWithin, applyEvent, attachmentKind, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, scanPromptTokens, seedFromSessionInfo, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, useToolCallHost };
525
1526
 
526
1527
  //# sourceMappingURL=index.mjs.map