@alexkroman1/aai-ui 0.14.0 → 0.14.2

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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-BCbI7gD4.js";
2
2
  import { n as pageBaseUrl, r as setPageTitle, t as isTypingTarget } from "./_utils-Mi-SCFA1.js";
3
3
  import { n as fetchClientConfig, t as buildAgentUrl } from "./client-config-C7fQrioM.js";
4
- import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-D9jM5-TF.js";
4
+ import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-CYdjRvbl.js";
5
5
  import { ThemeProvider, useOptionalSessionCore, useSession, useSessionActions, useSessionCore, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
6
6
  import { a as useAgentState, i as selectAgentState, n as useToolCallStart, r as useToolResult, t as useEvent } from "./hooks-DN4gHYsj.js";
7
7
  import { a as useConversation, i as interleave, n as Markdown, o as AutoScroll, r as ConversationView, t as MessageList } from "./message-list-DHZcLhRA.js";
@@ -17,12 +17,12 @@ import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as
17
17
  import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
18
18
  import { errorMessage, errorMessage as errorMessage$1 } from "@alexkroman1/aai";
19
19
  import { InboxServerFrameSchema } from "@alexkroman1/aai/protocol";
20
- import { CLIENT_ID_RE, createEpoch, jitteredBackoff } from "@alexkroman1/aai/internal";
20
+ import { CLIENT_ID_RE, createBackoffLoop, createEpoch } from "@alexkroman1/aai/internal";
21
21
  import { Fragment, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
22
22
  import clsx from "clsx";
23
23
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
24
24
  import { formatBytes, isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
25
- import { assign, createActor, raise, setup, stateIn } from "xstate";
25
+ import { assign, createActor, fromPromise, raise, setup, stateIn } from "xstate";
26
26
  //#region src/client-tool.ts
27
27
  /**
28
28
  * The page's half of a `clientTool`: run the handler when the agent calls the
@@ -2160,7 +2160,8 @@ function createNoticeAssembler(busy) {
2160
2160
  * half is `aai-runtime`'s `aai-runtime/src/inbox/inbox.ts`.
2161
2161
  *
2162
2162
  * - **Held from creation, reconnected until `close()`**, on a jittered backoff
2163
- * from 1 s doubling to 30 s (`jitteredBackoff`), reset by every open. The
2163
+ * from 1 s doubling to 30 s (`createBackoffLoop` with `jitter`), reset by
2164
+ * every open. The
2164
2165
  * jitter matters here more than for most loops: an agent restart drops every
2165
2166
  * open inbox at once, and every tab of every user comes back together.
2166
2167
  * - **Its own socket, not partysocket.** The session's reconnecting socket
@@ -2227,23 +2228,12 @@ function createInbox(options) {
2227
2228
  const subscribers = /* @__PURE__ */ new Set();
2228
2229
  let socket;
2229
2230
  let open = false;
2230
- let closed = false;
2231
- let failures = 0;
2232
- let retry;
2233
2231
  const assembler = createNoticeAssembler(() => options.busy?.() ?? false);
2234
2232
  function setOpen(next) {
2235
2233
  if (open === next) return;
2236
2234
  open = next;
2237
2235
  for (const callback of subscribers) callback();
2238
2236
  }
2239
- function scheduleRetry() {
2240
- if (closed) return;
2241
- failures++;
2242
- retry = setTimeout(connect, jitteredBackoff(failures, {
2243
- baseMs: INBOX_RECONNECT_BASE_MS,
2244
- maxMs: INBOX_RECONNECT_MAX_MS
2245
- }));
2246
- }
2247
2237
  function url(client, queryToken) {
2248
2238
  const target = buildAgentUrl(options.platformUrl, "inbox");
2249
2239
  target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
@@ -2272,11 +2262,9 @@ function createInbox(options) {
2272
2262
  return typeof answer === "string" || answer === void 0 ? resolveSessionTokenSync(answer, attempt) : resolveSessionToken(() => answer, attempt);
2273
2263
  }
2274
2264
  function connect() {
2275
- retry = void 0;
2276
- if (closed) return;
2277
2265
  const client = resolveReported(options.client);
2278
2266
  if (!(client && CLIENT_ID_RE.test(client))) {
2279
- scheduleRetry();
2267
+ loop.retry();
2280
2268
  return;
2281
2269
  }
2282
2270
  const token = askToken();
@@ -2285,7 +2273,7 @@ function createInbox(options) {
2285
2273
  return;
2286
2274
  }
2287
2275
  token.then((answer) => {
2288
- if (!closed) dial(client, answer);
2276
+ if (!loop.stopped()) dial(client, answer);
2289
2277
  });
2290
2278
  }
2291
2279
  function dial(client, token) {
@@ -2300,7 +2288,7 @@ function createInbox(options) {
2300
2288
  ws.send(JSON.stringify(out.reply));
2301
2289
  };
2302
2290
  ws.addEventListener("open", () => {
2303
- failures = 0;
2291
+ loop.reset();
2304
2292
  setOpen(true);
2305
2293
  });
2306
2294
  ws.addEventListener("message", (e) => {
@@ -2317,10 +2305,15 @@ function createInbox(options) {
2317
2305
  socket = void 0;
2318
2306
  setOpen(false);
2319
2307
  assembler.reset();
2320
- scheduleRetry();
2308
+ loop.retry();
2321
2309
  });
2322
2310
  }
2323
- connect();
2311
+ const loop = createBackoffLoop(connect, {
2312
+ baseMs: INBOX_RECONNECT_BASE_MS,
2313
+ maxMs: INBOX_RECONNECT_MAX_MS,
2314
+ jitter: true
2315
+ });
2316
+ loop.start();
2324
2317
  return {
2325
2318
  connected: () => open,
2326
2319
  subscribe(callback) {
@@ -2330,8 +2323,7 @@ function createInbox(options) {
2330
2323
  };
2331
2324
  },
2332
2325
  close() {
2333
- closed = true;
2334
- clearTimeout(retry);
2326
+ loop.stop();
2335
2327
  const ws = socket;
2336
2328
  socket = void 0;
2337
2329
  ws?.close();
@@ -2395,100 +2387,6 @@ function createLinkedClient(options) {
2395
2387
  });
2396
2388
  }
2397
2389
  //#endregion
2398
- //#region src/_recover-run.ts
2399
- /**
2400
- * Finding a run again when the page has lost its id.
2401
- *
2402
- * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
2403
- * which was only half true of the hooks above it: the run id lived in plain
2404
- * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
2405
- * live run with nothing anywhere able to name it. The run really did continue;
2406
- * the person really could not get back to it.
2407
- *
2408
- * `StartOptions.key` is the handle that survives that, and it always was — a
2409
- * caller's own name for a run, indexed by the agent, read back with
2410
- * `find(workflow, key)`. What was missing is the two lines that ASK. This is
2411
- * them, plus the four decisions they turn out to carry.
2412
- *
2413
- * ## It is a MOUNT-time act, not "whenever there is no run"
2414
- *
2415
- * The tempting spelling is "if we hold no run id, look one up", and it breaks
2416
- * `reset()`: a form put back to its initial state holds no run id, so the next
2417
- * pass would re-adopt the very run the person had just dismissed — a Clear
2418
- * button that clears nothing. So the lookup runs once per mount (and again only
2419
- * if the KEY changes, which is a different person's run), and every later
2420
- * absence of a run id is taken at face value.
2421
- *
2422
- * ## The lookup NEVER wins a race against a submit
2423
- *
2424
- * A person who reloads and immediately submits has started the run they want,
2425
- * and an answer that was already in flight names an older one. The caller
2426
- * therefore adopts through `current ?? found`: the recovered id fills an empty
2427
- * slot and never replaces a full one.
2428
- *
2429
- * ## A failed lookup is REPORTED
2430
- *
2431
- * The alternative is a page that quietly shows an empty form to somebody whose
2432
- * run is live, who then starts a second one — the duplicated work the key
2433
- * exists to prevent, and on a workflow app that is real money. A person who has
2434
- * never run anything pays a banner they can ignore. Same trade as
2435
- * `useWorkflows`, for the same reason: an empty answer here is a confident
2436
- * false statement.
2437
- *
2438
- * ## It is ON by default, and it used to be opt-in
2439
- *
2440
- * The argument for opt-in was that a `key` on its own means only "record this
2441
- * with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
2442
- * means, there being no page to put a run back on. A FORM is the other case: it
2443
- * is the page, and losing the run is the thing it cannot recover from. Six of
2444
- * six page templates wrote `useRunKey()` and `recover: true` together, which is
2445
- * the same shape `session/resume-store.ts` names on the voice side — a default
2446
- * in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
2447
- *
2448
- * `enabled` remains, because `recover: false` remains: a page whose form must
2449
- * always open empty says so, and then nothing here runs.
2450
- */
2451
- /**
2452
- * Look up the newest run for a key, once, as the component mounts.
2453
- *
2454
- * @param opts - See {@link RecoverRunOptions}.
2455
- * @returns Whether the lookup is still out. A caller folds it into its own
2456
- * `pending`, because a form offering Submit while a live run is arriving is a
2457
- * form inviting a second one.
2458
- *
2459
- * @internal
2460
- */
2461
- function useRecoveredRun(opts) {
2462
- const { workflow, key, enabled, getClient } = opts;
2463
- const [recovering, setRecovering] = useState(enabled);
2464
- const handlers = useRef(opts);
2465
- handlers.current = opts;
2466
- useEffect(() => {
2467
- if (!enabled) return;
2468
- let cancelled = false;
2469
- setRecovering(true);
2470
- getClient().find(workflow, key, { limit: 1 }).then((found) => {
2471
- if (cancelled) return;
2472
- const newest = found[0];
2473
- if (newest !== void 0) handlers.current.onFound(newest.runId);
2474
- setRecovering(false);
2475
- }).catch((err) => {
2476
- if (cancelled) return;
2477
- handlers.current.onError(errorMessage$1(err));
2478
- setRecovering(false);
2479
- });
2480
- return () => {
2481
- cancelled = true;
2482
- };
2483
- }, [
2484
- enabled,
2485
- key,
2486
- workflow,
2487
- getClient
2488
- ]);
2489
- return recovering;
2490
- }
2491
- //#endregion
2492
2390
  //#region src/_run-controls.ts
2493
2391
  /**
2494
2392
  * The two things a page does TO a run it started, bound to the run it has.
@@ -2527,6 +2425,392 @@ function useRunControls(runId, getClient) {
2527
2425
  };
2528
2426
  }
2529
2427
  //#endregion
2428
+ //#region src/_recover-run.ts
2429
+ /**
2430
+ * Look up the newest run for a key.
2431
+ *
2432
+ * @returns The run's id, or `undefined` for a key with no runs. A failure
2433
+ * REJECTS, so the machine can report it — see "A failed lookup is REPORTED".
2434
+ */
2435
+ async function findRecoveredRun(client, workflow, key) {
2436
+ return (await client.find(workflow, key, { limit: 1 }))[0]?.runId;
2437
+ }
2438
+ //#endregion
2439
+ //#region src/_actor-store.ts
2440
+ /** Start `actor` and expose it through `read`, its memoized projection. */
2441
+ function actorStore(actor, read) {
2442
+ actor.start();
2443
+ return {
2444
+ send: (event) => actor.send(event),
2445
+ getView: read,
2446
+ subscribe(listener) {
2447
+ const sub = actor.subscribe(() => listener());
2448
+ return () => sub.unsubscribe();
2449
+ },
2450
+ stop: () => actor.stop()
2451
+ };
2452
+ }
2453
+ //#endregion
2454
+ //#region src/_workflow-form-state.ts
2455
+ /**
2456
+ * A workflow form's submission as a statechart, with no React: the mount-time
2457
+ * lookup, the submission in flight, the person's pause, and what the page is
2458
+ * left holding afterwards. `useWorkflowSubmit` and `useWorkflowStream` feed it
2459
+ * through `_submission-state.ts`; `_workflow-form-state.test.ts` specs it
2460
+ * without a renderer. The pattern is `_tap-to-talk-state.ts`'s and
2461
+ * `session/audio-state.ts`'s: a `setup()` machine, effects in `input`,
2462
+ * cancellable work as a `fromPromise` invoke.
2463
+ *
2464
+ * ```text
2465
+ * idle ──RECOVER──► recovering ──found──► following
2466
+ * ▲ │ └──none──► idle
2467
+ * │ └──failed──► failed
2468
+ * RESET (anywhere)
2469
+ * SUBMIT (anywhere) ──► submitting { active ⇄ paused } ──done──► following
2470
+ * └─failed─► failed
2471
+ * ```
2472
+ *
2473
+ * It replaces four `useState`s, a `startedHere` flag, a `recovering` flag and
2474
+ * a ref holding the live submission, and three rules that were each a patch
2475
+ * over a race the position now answers:
2476
+ *
2477
+ * - **A superseded submission owns nothing.** It used to be dropped by a token
2478
+ * compare in `end()` (`current !== token`). A `SUBMIT` now RE-ENTERS
2479
+ * `submitting`, which stops the old invoke: its signal aborts, which cancels
2480
+ * its gate, and XState drops its result. Reports from it are refused at the
2481
+ * producer, on the same signal — the one "is this still wanted?" test, in the
2482
+ * one place that knows the answer.
2483
+ * - **The lookup never wins a race against a submit.** It used to be
2484
+ * `setRunId(current => current ?? found)`. The lookup is an invoke of
2485
+ * `recovering` now, so a `SUBMIT` (or a `RESET`) that leaves it stops it,
2486
+ * and an answer that lands afterwards is an answer nobody asked for.
2487
+ * - **`pending` was three hooks' flags OR-ed together.** Two of the three were
2488
+ * positions in this machine; {@link WorkflowFormView.busy} is both. The
2489
+ * third, the run's own `polling`, stays outside on purpose: it is
2490
+ * `useWorkflowRun`'s and cannot be derived from the snapshot (see that hook).
2491
+ *
2492
+ * ## What `submitting` does not split
2493
+ *
2494
+ * Uploading and starting are not child states. The two hooks run them in
2495
+ * OPPOSITE orders — `useWorkflowSubmit` stores the bytes and then starts the
2496
+ * run, `useWorkflowStream` starts the run and then streams the bytes — so a
2497
+ * fixed `uploading → starting` order would be wrong for one of them, and
2498
+ * nothing this machine decides depends on which step is current. `active` /
2499
+ * `paused` is the split that does matter: it is the gate, and the bar's
2500
+ * `paused` flag, moving together.
2501
+ */
2502
+ const machine$1 = setup({
2503
+ types: {},
2504
+ actors: {
2505
+ lookup: fromPromise(({ input }) => input.find()),
2506
+ /**
2507
+ * One submission. An invoke rather than a fire-and-forget call so that
2508
+ * leaving `submitting` — a superseding `SUBMIT`, a `RESET`, an unmount —
2509
+ * stops it, and stopping it is what abandons it.
2510
+ */
2511
+ submission: fromPromise(async ({ input, signal }) => {
2512
+ const { gate, work, settle, send } = input;
2513
+ signal.addEventListener("abort", () => gate?.cancel(), { once: true });
2514
+ const live = (event) => {
2515
+ if (!signal.aborted) send(event);
2516
+ };
2517
+ try {
2518
+ await work?.({
2519
+ progress: (upload) => live({
2520
+ type: "PROGRESS",
2521
+ upload
2522
+ }),
2523
+ started: (runId) => live({
2524
+ type: "STARTED",
2525
+ runId
2526
+ })
2527
+ });
2528
+ } finally {
2529
+ if (signal.aborted) settle?.();
2530
+ }
2531
+ })
2532
+ },
2533
+ guards: {
2534
+ found: ({ event }) => "output" in event && typeof event.output === "string",
2535
+ hasRun: ({ context }) => context.runId !== void 0,
2536
+ noRun: ({ context }) => context.runId === void 0
2537
+ },
2538
+ actions: {
2539
+ open: assign(({ event }) => event.type === "SUBMIT" ? {
2540
+ gate: event.gate,
2541
+ work: event.work,
2542
+ settle: event.settle,
2543
+ runId: void 0,
2544
+ startError: void 0,
2545
+ upload: void 0,
2546
+ startedHere: true
2547
+ } : {}),
2548
+ /**
2549
+ * Leaving `submitting`, by whichever edge. The bar goes too: from here the
2550
+ * wait belongs to the RUN, and a bar left at 100% under a running workflow
2551
+ * reads as the thing taking the time.
2552
+ */
2553
+ close: assign({
2554
+ gate: void 0,
2555
+ work: void 0,
2556
+ settle: void 0,
2557
+ upload: void 0
2558
+ }),
2559
+ /**
2560
+ * `submit()`'s promise. Before `close`, which forgets it; and before the
2561
+ * edge's own actions only in program order — a resolution runs its
2562
+ * continuation as a microtask, after the whole transition has landed, so
2563
+ * an awaiting caller already reads the error or the run.
2564
+ */
2565
+ settle: ({ context }) => context.settle?.(),
2566
+ clear: assign({
2567
+ runId: void 0,
2568
+ startError: void 0,
2569
+ startedHere: false
2570
+ }),
2571
+ pauseGate: ({ context }) => context.gate?.pause(),
2572
+ resumeGate: ({ context }) => context.gate?.resume(),
2573
+ /** Folded rather than replaced: which file and how far are still true. */
2574
+ markPaused: assign({ upload: ({ context }) => context.upload ? {
2575
+ ...context.upload,
2576
+ paused: true
2577
+ } : void 0 }),
2578
+ markResumed: assign({ upload: ({ context }) => context.upload ? {
2579
+ ...context.upload,
2580
+ paused: false
2581
+ } : void 0 })
2582
+ }
2583
+ }).createMachine({
2584
+ id: "workflowForm",
2585
+ context: ({ input }) => ({
2586
+ effects: input,
2587
+ runId: void 0,
2588
+ startError: void 0,
2589
+ upload: void 0,
2590
+ startedHere: false,
2591
+ gate: void 0,
2592
+ work: void 0,
2593
+ settle: void 0
2594
+ }),
2595
+ initial: "idle",
2596
+ on: {
2597
+ SUBMIT: {
2598
+ target: ".submitting",
2599
+ reenter: true,
2600
+ actions: "open"
2601
+ },
2602
+ RESET: {
2603
+ target: ".idle",
2604
+ actions: "clear"
2605
+ }
2606
+ },
2607
+ states: {
2608
+ idle: { on: { RECOVER: "recovering" } },
2609
+ /**
2610
+ * The mount-time lookup (`_recover-run.ts`). Leaving by any other edge —
2611
+ * a submit, a reset — stops it, so a late answer adopts nothing.
2612
+ */
2613
+ recovering: {
2614
+ entry: assign({ startError: void 0 }),
2615
+ invoke: {
2616
+ src: "lookup",
2617
+ input: ({ context }) => context.effects,
2618
+ onDone: [{
2619
+ guard: "found",
2620
+ target: "following",
2621
+ actions: assign({ runId: ({ event }) => event.output })
2622
+ }, { target: "idle" }],
2623
+ onError: {
2624
+ target: "failed",
2625
+ actions: assign({ startError: ({ event }) => errorMessage$1(event.error) })
2626
+ }
2627
+ }
2628
+ },
2629
+ submitting: {
2630
+ initial: "active",
2631
+ exit: ["settle", "close"],
2632
+ invoke: {
2633
+ src: "submission",
2634
+ input: ({ context, self }) => ({
2635
+ gate: context.gate,
2636
+ work: context.work,
2637
+ settle: context.settle,
2638
+ send: (event) => {
2639
+ self.send(event);
2640
+ }
2641
+ }),
2642
+ onDone: [{
2643
+ guard: "hasRun",
2644
+ target: "following"
2645
+ }, { target: "idle" }],
2646
+ onError: {
2647
+ target: "failed",
2648
+ actions: assign({ startError: ({ event }) => errorMessage$1(event.error) })
2649
+ }
2650
+ },
2651
+ on: {
2652
+ PROGRESS: { actions: assign({ upload: ({ event }) => event.upload }) },
2653
+ STARTED: { actions: assign({ runId: ({ event }) => event.runId }) }
2654
+ },
2655
+ states: {
2656
+ active: { on: { PAUSE: {
2657
+ target: "paused",
2658
+ actions: ["pauseGate", "markPaused"]
2659
+ } } },
2660
+ paused: { on: { RESUME: {
2661
+ target: "active",
2662
+ actions: ["resumeGate", "markResumed"]
2663
+ } } }
2664
+ }
2665
+ },
2666
+ /** A run on the page — started here, or adopted by the lookup. */
2667
+ following: {},
2668
+ /**
2669
+ * The submission's or the lookup's own failure. A stream submission can
2670
+ * land here WITH a run (started, then its upload failed and it was
2671
+ * cancelled), which is why `RECOVER` is guarded rather than plain.
2672
+ */
2673
+ failed: { on: { RECOVER: {
2674
+ guard: "noRun",
2675
+ target: "recovering"
2676
+ } } }
2677
+ }
2678
+ });
2679
+ /** Start a machine over `effects`. */
2680
+ function createWorkflowForm(effects) {
2681
+ const actor = createActor(machine$1, { input: effects });
2682
+ let view = {
2683
+ runId: void 0,
2684
+ startError: void 0,
2685
+ upload: void 0,
2686
+ startedHere: false,
2687
+ busy: false
2688
+ };
2689
+ const read = () => {
2690
+ const at = actor.getSnapshot();
2691
+ const { runId, startError, upload, startedHere } = at.context;
2692
+ const busy = at.matches("recovering") || at.matches("submitting");
2693
+ if (runId !== view.runId || startError !== view.startError || upload !== view.upload || startedHere !== view.startedHere || busy !== view.busy) view = {
2694
+ runId,
2695
+ startError,
2696
+ upload,
2697
+ startedHere,
2698
+ busy
2699
+ };
2700
+ return view;
2701
+ };
2702
+ return actorStore(actor, read);
2703
+ }
2704
+ //#endregion
2705
+ //#region src/_submission-state.ts
2706
+ /**
2707
+ * The state a form submission carries, for both hooks that make one: the
2708
+ * form's statechart (`_workflow-form-state.ts`) bridged into React with
2709
+ * `useSyncExternalStore`, the way `use-tap-to-talk.ts` bridges its own.
2710
+ *
2711
+ * `useWorkflowSubmit` and `useWorkflowStream` stay separate hooks — they differ
2712
+ * in WHEN the run is created relative to the bytes, which is the whole reason
2713
+ * the streaming one exists — but everything around that ordering is shared:
2714
+ * the run id, the submission's own failure, the bar, `startedHere`, the
2715
+ * supersede rule, `reset` and the pause pair. Each hook still owns its own
2716
+ * submission BODY, handed to {@link SubmissionActions.submit}.
2717
+ *
2718
+ * Nothing here decides anything. The decisions are positions of the machine,
2719
+ * which is why this module holds no token, no ref to a live submission, and no
2720
+ * "is this still the current one?" check.
2721
+ */
2722
+ const IDLE_VIEW$1 = {
2723
+ runId: void 0,
2724
+ startError: void 0,
2725
+ upload: void 0,
2726
+ startedHere: false,
2727
+ busy: false
2728
+ };
2729
+ /**
2730
+ * The frames before the machine exists, for a page that will look its run up.
2731
+ * Busy from the FIRST render rather than from the effect, so there is no frame
2732
+ * in which a page about to adopt a run reads as idle.
2733
+ */
2734
+ const RECOVERING_VIEW = {
2735
+ ...IDLE_VIEW$1,
2736
+ busy: true
2737
+ };
2738
+ const NO_SUBSCRIPTION$1 = () => () => void 0;
2739
+ const idleView = () => IDLE_VIEW$1;
2740
+ const recoveringView = () => RECOVERING_VIEW;
2741
+ /** What a lookup is FOR, so a re-render asking the same question asks nothing. */
2742
+ function questionOf(recover) {
2743
+ return recover?.enabled ? `${recover.workflow}\n${recover.key}` : void 0;
2744
+ }
2745
+ /**
2746
+ * The shared submission scaffold. See the module doc.
2747
+ *
2748
+ * @param recover - The mount-time lookup (`_recover-run.ts`), or nothing for a
2749
+ * hook that refuses it (`useWorkflowStream`).
2750
+ */
2751
+ function useSubmissionState(recover) {
2752
+ const lookup = useRef(recover);
2753
+ lookup.current = recover;
2754
+ const asked = useRef(void 0);
2755
+ const live = useRef(null);
2756
+ const [store, setStore] = useState(null);
2757
+ useEffect(() => {
2758
+ const next = createWorkflowForm({ find: async () => {
2759
+ const at = lookup.current;
2760
+ if (!at) return;
2761
+ return await findRecoveredRun(at.getClient(), at.workflow, at.key);
2762
+ } });
2763
+ asked.current = questionOf(lookup.current);
2764
+ if (asked.current !== void 0) next.send({ type: "RECOVER" });
2765
+ live.current = next;
2766
+ setStore(next);
2767
+ return () => {
2768
+ next.stop();
2769
+ live.current = null;
2770
+ asked.current = void 0;
2771
+ setStore(null);
2772
+ };
2773
+ }, []);
2774
+ const question = questionOf(recover);
2775
+ useEffect(() => {
2776
+ if (!store || question === asked.current) return;
2777
+ asked.current = question;
2778
+ if (question !== void 0) store.send({ type: "RECOVER" });
2779
+ }, [store, question]);
2780
+ const beforeStore = question === void 0 ? idleView : recoveringView;
2781
+ const view = useSyncExternalStore(store?.subscribe ?? NO_SUBSCRIPTION$1, store ? store.getView : beforeStore);
2782
+ const submit = useCallback((gate, work) => {
2783
+ const machine = live.current;
2784
+ if (!machine) return Promise.resolve();
2785
+ const done = Promise.withResolvers();
2786
+ machine.send({
2787
+ type: "SUBMIT",
2788
+ gate,
2789
+ work,
2790
+ settle: () => done.resolve()
2791
+ });
2792
+ return done.promise;
2793
+ }, []);
2794
+ const reset = useCallback(() => live.current?.send({ type: "RESET" }), []);
2795
+ const pauseUpload = useCallback(() => live.current?.send({ type: "PAUSE" }), []);
2796
+ const resumeUpload = useCallback(() => live.current?.send({ type: "RESUME" }), []);
2797
+ const actions = useMemo(() => ({
2798
+ submit,
2799
+ reset,
2800
+ pauseUpload,
2801
+ resumeUpload
2802
+ }), [
2803
+ submit,
2804
+ reset,
2805
+ pauseUpload,
2806
+ resumeUpload
2807
+ ]);
2808
+ return {
2809
+ ...view,
2810
+ actions
2811
+ };
2812
+ }
2813
+ //#endregion
2530
2814
  //#region src/_workflow-files.ts
2531
2815
  /**
2532
2816
  * Which of a submitted form's values are FILES.
@@ -2739,44 +3023,44 @@ async function sendThroughGate(gate, send) {
2739
3023
  */
2740
3024
  function createUploadGate() {
2741
3025
  let controller = new AbortController();
2742
- let paused = false;
2743
- let cancelled = false;
3026
+ let phase = "running";
2744
3027
  let open;
2745
3028
  let closed;
3029
+ /** Wake an uploader parked on `settle()`, whichever way the gate is opening. */
3030
+ const release = () => {
3031
+ open?.();
3032
+ open = void 0;
3033
+ closed = void 0;
3034
+ };
2746
3035
  return {
2747
3036
  get paused() {
2748
- return paused;
3037
+ return phase === "paused";
2749
3038
  },
2750
3039
  get cancelled() {
2751
- return cancelled;
3040
+ return phase === "cancelled";
2752
3041
  },
2753
3042
  get signal() {
2754
3043
  return controller.signal;
2755
3044
  },
2756
3045
  pause() {
2757
- if (paused || cancelled) return;
2758
- paused = true;
3046
+ if (phase !== "running") return;
3047
+ phase = "paused";
2759
3048
  const gate = Promise.withResolvers();
2760
3049
  closed = gate.promise;
2761
3050
  open = gate.resolve;
2762
3051
  controller.abort();
2763
3052
  },
2764
3053
  resume() {
2765
- if (!paused || cancelled) return;
2766
- paused = false;
3054
+ if (phase !== "paused") return;
3055
+ phase = "running";
2767
3056
  controller = new AbortController();
2768
- open?.();
2769
- open = void 0;
2770
- closed = void 0;
3057
+ release();
2771
3058
  },
2772
3059
  cancel() {
2773
- if (cancelled) return;
2774
- cancelled = true;
2775
- paused = false;
3060
+ if (phase === "cancelled") return;
3061
+ phase = "cancelled";
2776
3062
  controller.abort();
2777
- open?.();
2778
- open = void 0;
2779
- closed = void 0;
3063
+ release();
2780
3064
  },
2781
3065
  async settle() {
2782
3066
  if (closed) await closed;
@@ -2950,46 +3234,6 @@ async function uploadFiles(api, input, report, parallel, session) {
2950
3234
  return out;
2951
3235
  }
2952
3236
  //#endregion
2953
- //#region src/upload/pause.ts
2954
- /**
2955
- * The two buttons a page puts over a live upload, bound to whichever gate the
2956
- * submission is currently holding.
2957
- *
2958
- * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
2959
- * both reported the pause the same way: park the gate, then fold `paused` into
2960
- * the status the bar is already drawing. Folded rather than replaced, because
2961
- * everything else about that status — which file, how far, of how many — is
2962
- * still true. Two copies of that rule are two copies that can stop agreeing
2963
- * about what a paused bar says.
2964
- */
2965
- /**
2966
- * Bind pause/resume to the live submission's gate.
2967
- *
2968
- * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
2969
- * stable across submissions rather than being re-created per gate.
2970
- * @param setUpload - The status setter the progress bar renders from.
2971
- *
2972
- * @internal
2973
- */
2974
- function useUploadPause(getGate, setUpload) {
2975
- const setPaused = useCallback((paused) => {
2976
- setUpload((current) => current ? {
2977
- ...current,
2978
- paused
2979
- } : current);
2980
- }, [setUpload]);
2981
- return {
2982
- pauseUpload: useCallback(() => {
2983
- getGate()?.pause();
2984
- setPaused(true);
2985
- }, [getGate, setPaused]),
2986
- resumeUpload: useCallback(() => {
2987
- getGate()?.resume();
2988
- setPaused(false);
2989
- }, [getGate, setPaused])
2990
- };
2991
- }
2992
- //#endregion
2993
3237
  //#region src/upload/report.ts
2994
3238
  /**
2995
3239
  * What the bar draws, as a comparable string.
@@ -3026,59 +3270,6 @@ function coalesceUploadReports(set) {
3026
3270
  };
3027
3271
  }
3028
3272
  //#endregion
3029
- //#region src/_submission-state.ts
3030
- /** The shared submission scaffold. See the module doc. */
3031
- function useSubmissionState() {
3032
- const [runId, setRunId] = useState(void 0);
3033
- const [starting, setStarting] = useState(false);
3034
- const [startError, setStartError] = useState(void 0);
3035
- const [upload, setUpload] = useState(void 0);
3036
- const current = useRef(void 0);
3037
- const begin = useCallback((token) => {
3038
- setStarting(true);
3039
- setStartError(void 0);
3040
- setRunId(void 0);
3041
- current.current?.gate.cancel();
3042
- current.current = token;
3043
- }, []);
3044
- const end = useCallback((token) => {
3045
- if (current.current !== token) return;
3046
- current.current = void 0;
3047
- setStarting(false);
3048
- setUpload(void 0);
3049
- }, []);
3050
- const reset = useCallback(() => {
3051
- current.current?.gate.cancel();
3052
- current.current = void 0;
3053
- setRunId(void 0);
3054
- setStartError(void 0);
3055
- setUpload(void 0);
3056
- }, []);
3057
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => current.current?.gate, []), setUpload);
3058
- return {
3059
- runId,
3060
- starting,
3061
- startError,
3062
- upload,
3063
- actions: useMemo(() => ({
3064
- setRunId,
3065
- setStartError,
3066
- setUpload,
3067
- begin,
3068
- end,
3069
- reset,
3070
- pauseUpload,
3071
- resumeUpload
3072
- }), [
3073
- begin,
3074
- end,
3075
- reset,
3076
- pauseUpload,
3077
- resumeUpload
3078
- ])
3079
- };
3080
- }
3081
- //#endregion
3082
3273
  //#region src/use-run-key.ts
3083
3274
  /**
3084
3275
  * The handle a page keeps on the runs it started, across a reload.
@@ -3304,42 +3495,31 @@ function useRunKey(options = {}) {
3304
3495
  function useWorkflowSubmit(workflow, options = {}) {
3305
3496
  const { api, recover = true, wait, intervalMs, parallel } = options;
3306
3497
  const key = useDefaultRunKey(options.key);
3307
- const state = useSubmissionState();
3308
- const { runId, actions } = state;
3309
3498
  const getClient = useWorkflowApiRef(api);
3499
+ const form = useSubmissionState({
3500
+ workflow,
3501
+ key,
3502
+ enabled: recover,
3503
+ getClient
3504
+ });
3505
+ const { runId, actions } = form;
3310
3506
  const tracked = useWorkflowRun(runId, omitUndefined({
3311
3507
  api,
3312
3508
  intervalMs
3313
3509
  }));
3314
3510
  const { wake, cancel } = useRunControls(runId, getClient);
3315
- const [startedHere, setStartedHere] = useState(false);
3316
- const recovering = useRecoveredRun({
3317
- workflow,
3318
- key,
3319
- enabled: recover,
3320
- getClient,
3321
- onFound: (found) => {
3322
- actions.setRunId((current) => current ?? found);
3323
- },
3324
- onError: actions.setStartError
3325
- });
3326
- const submit = useCallback(async (input) => {
3511
+ const submit = useCallback((input) => {
3327
3512
  const client = getClient();
3328
- const current = createUploadSession(workflow);
3329
- setStartedHere(true);
3330
- actions.begin(current);
3331
- try {
3513
+ const session = createUploadSession(workflow);
3514
+ return actions.submit(session.gate, async ({ progress, started }) => {
3332
3515
  const options = omitUndefined({ key });
3333
- const started = await uploadFiles(client, input, coalesceUploadReports(actions.setUpload), parallel, current);
3334
- actions.setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
3516
+ const report = coalesceUploadReports(progress);
3517
+ const stored = await uploadFiles(client, input, report, parallel, session);
3518
+ started(wait === void 0 ? await client.start(workflow, stored, options) : (await client.startAndWait(workflow, stored, {
3335
3519
  ...options,
3336
3520
  wait
3337
3521
  })).runId);
3338
- } catch (err) {
3339
- if (!current.gate.cancelled) actions.setStartError(errorMessage$1(err));
3340
- } finally {
3341
- actions.end(current);
3342
- }
3522
+ });
3343
3523
  }, [
3344
3524
  workflow,
3345
3525
  key,
@@ -3351,19 +3531,16 @@ function useWorkflowSubmit(workflow, options = {}) {
3351
3531
  return {
3352
3532
  submit,
3353
3533
  submitForm: submit,
3354
- reset: useCallback(() => {
3355
- setStartedHere(false);
3356
- actions.reset();
3357
- }, [actions]),
3534
+ reset: actions.reset,
3358
3535
  wake,
3359
3536
  cancel,
3360
3537
  pauseUpload: actions.pauseUpload,
3361
3538
  resumeUpload: actions.resumeUpload,
3362
3539
  run: tracked.run,
3363
- startedHere,
3364
- pending: recovering || state.starting || tracked.polling,
3365
- upload: state.upload,
3366
- error: state.startError ?? tracked.error
3540
+ startedHere: form.startedHere,
3541
+ pending: form.busy || tracked.polling,
3542
+ upload: form.upload,
3543
+ error: form.startError ?? tracked.error
3367
3544
  };
3368
3545
  }
3369
3546
  //#endregion
@@ -4926,16 +5103,7 @@ function createTapToTalk(effects, timing) {
4926
5103
  };
4927
5104
  return view;
4928
5105
  };
4929
- actor.start();
4930
- return {
4931
- send: (event) => actor.send(event),
4932
- getView: read,
4933
- subscribe(listener) {
4934
- const sub = actor.subscribe(() => listener());
4935
- return () => sub.unsubscribe();
4936
- },
4937
- stop: () => actor.stop()
4938
- };
5106
+ return actorStore(actor, read);
4939
5107
  }
4940
5108
  //#endregion
4941
5109
  //#region src/use-tap-to-talk.ts
@@ -5338,50 +5506,46 @@ function useWorkflowRuns(workflow, options = {}) {
5338
5506
  */
5339
5507
  function useWorkflowStream(workflow, options = {}) {
5340
5508
  const { api, key, intervalMs, parallel } = options;
5341
- const state = useSubmissionState();
5342
- const { runId, actions } = state;
5343
5509
  const getClient = useWorkflowApiRef(api);
5510
+ const form = useSubmissionState();
5511
+ const { runId, actions } = form;
5344
5512
  const tracked = useWorkflowRun(runId, omitUndefined({
5345
5513
  api,
5346
5514
  intervalMs
5347
5515
  }));
5348
5516
  const { wake, cancel } = useRunControls(runId, getClient);
5349
- const [startedHere, setStartedHere] = useState(false);
5350
- const submit = useCallback(async (input) => {
5517
+ const submit = useCallback((input) => {
5351
5518
  const client = getClient();
5352
- const current = { gate: createUploadGate() };
5353
- const { gate } = current;
5354
- setStartedHere(true);
5355
- actions.begin(current);
5356
- let started;
5357
- try {
5358
- const id = randomUploadId();
5359
- const begun = await beginRun({
5360
- client,
5361
- workflow,
5362
- input,
5363
- id,
5364
- ...omitUndefined({ key })
5365
- });
5366
- started = begun.runId;
5367
- const chosen = begun.file;
5368
- actions.setRunId(started);
5369
- if (!chosen) return;
5370
- await streamFile({
5371
- client,
5372
- gate,
5373
- id,
5374
- file: chosen,
5375
- parallel,
5376
- report: coalesceUploadReports(actions.setUpload)
5377
- });
5378
- await client.wake(started).catch(() => void 0);
5379
- } catch (err) {
5380
- if (!gate.cancelled) actions.setStartError(errorMessage$1(err));
5381
- if (started) await client.cancel(started).catch(() => void 0);
5382
- } finally {
5383
- actions.end(current);
5384
- }
5519
+ const gate = createUploadGate();
5520
+ return actions.submit(gate, async ({ progress, started: adopt }) => {
5521
+ let started;
5522
+ try {
5523
+ const id = randomUploadId();
5524
+ const begun = await beginRun({
5525
+ client,
5526
+ workflow,
5527
+ input,
5528
+ id,
5529
+ ...omitUndefined({ key })
5530
+ });
5531
+ started = begun.runId;
5532
+ const chosen = begun.file;
5533
+ adopt(started);
5534
+ if (!chosen) return;
5535
+ await streamFile({
5536
+ client,
5537
+ gate,
5538
+ id,
5539
+ file: chosen,
5540
+ parallel,
5541
+ report: coalesceUploadReports(progress)
5542
+ });
5543
+ await client.wake(started).catch(() => void 0);
5544
+ } catch (err) {
5545
+ if (started) await client.cancel(started).catch(() => void 0);
5546
+ throw err;
5547
+ }
5548
+ });
5385
5549
  }, [
5386
5550
  workflow,
5387
5551
  key,
@@ -5392,19 +5556,16 @@ function useWorkflowStream(workflow, options = {}) {
5392
5556
  return {
5393
5557
  submit,
5394
5558
  submitForm: submit,
5395
- reset: useCallback(() => {
5396
- setStartedHere(false);
5397
- actions.reset();
5398
- }, [actions]),
5559
+ reset: actions.reset,
5399
5560
  wake,
5400
5561
  cancel,
5401
5562
  pauseUpload: actions.pauseUpload,
5402
5563
  resumeUpload: actions.resumeUpload,
5403
5564
  run: tracked.run,
5404
- startedHere,
5405
- pending: state.starting || tracked.polling,
5406
- upload: state.upload,
5407
- error: state.startError ?? tracked.error
5565
+ startedHere: form.startedHere,
5566
+ pending: form.busy || tracked.polling,
5567
+ upload: form.upload,
5568
+ error: form.startError ?? tracked.error
5408
5569
  };
5409
5570
  }
5410
5571
  /**