@agent-native/core 0.175.2-nightly-20260826165737 → 0.175.3

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.
Files changed (31) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
  3. package/corpus/templates/clips/desktop/src/app.tsx +171 -51
  4. package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
  5. package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
  7. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
  8. package/corpus/templates/clips/desktop/src/main.tsx +4 -4
  9. package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
  10. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
  11. package/corpus/templates/clips/desktop/src/styles.css +20 -308
  12. package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
  13. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
  14. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
  15. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
  16. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
  17. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
  18. package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
  19. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
  20. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
  21. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
  22. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
  23. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +50 -28
  24. package/corpus/templates/slides/app/context/DeckContext.tsx +78 -23
  25. package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -5
  26. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  27. package/dist/notifications/routes.d.ts +3 -3
  28. package/dist/observability/routes.d.ts +3 -3
  29. package/dist/secrets/routes.d.ts +9 -9
  30. package/package.json +3 -3
  31. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8618
34
+ - template files: 8620
@@ -681,7 +681,7 @@ function CommentComposer({
681
681
  className={cn(
682
682
  "resize-none border-0 bg-background text-sm",
683
683
  isSharePresentation
684
- ? "min-h-10 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
684
+ ? "min-h-10 flex-1 border-0 px-3 py-2 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
685
685
  : "min-h-[60px]",
686
686
  )}
687
687
  onKeyDown={(e) => {
@@ -408,6 +408,7 @@ const CAM_ON_KEY = "clips:camera-on";
408
408
  const MIC_ON_KEY = "clips:mic-on";
409
409
  const SYSTEM_AUDIO_KEY = "clips:system-audio";
410
410
  const READINESS_REVIEWED_KEY = "clips:readiness-reviewed";
411
+ const VIDEO_STORAGE_CONFIGURED_KEY = "clips:video-storage-configured";
411
412
  // The docs section for the tray's rolling buffer, published under the same
412
413
  // Rewind name the settings tab uses.
413
414
  const REWIND_DOCS_URL =
@@ -431,6 +432,10 @@ function stopRestartHandoff(handoff: RestartHandoff): void {
431
432
  [handoff.displayStream, handoff.audioStream].forEach((stream) =>
432
433
  stream?.getTracks().forEach((track) => track.stop()),
433
434
  );
435
+ // Every creation site attaches its own catch, so this teardown promise
436
+ // can't reject — this guard keeps a handoff abandoned mid-restart from
437
+ // ever surfacing an unhandled rejection if a future site forgets.
438
+ void handoff.transcriptionTornDown?.catch(() => {});
434
439
  }
435
440
 
436
441
  type FetchInput = Parameters<typeof fetch>[0];
@@ -490,64 +495,77 @@ async function fetchWithAbortTimeout(
490
495
 
491
496
  async function hasConfiguredVideoStorage(
492
497
  serverUrl: string,
498
+ account: string | null,
493
499
  ): Promise<VideoStorageProbe> {
494
500
  const base = serverUrl.replace(/\/+$/, "");
495
501
 
496
- // Track whether any endpoint gave a definitive answer. If both checks throw
497
- // or return non-OK/unparseable responses, we can't tell and return "unknown".
498
- let sawDefinitiveAnswer = false;
499
-
500
- try {
501
- const uploadStatus = await fetchWithAbortTimeout(
502
- `${base}/_agent-native/file-upload/status`,
503
- {
504
- credentials: "include",
505
- cache: "no-store",
506
- },
507
- VIDEO_STORAGE_PROBE_TIMEOUT_MS,
508
- );
509
- if (uploadStatus.ok) {
510
- const body = (await uploadStatus.json().catch(() => null)) as {
502
+ // One endpoint's answer: "configured", "missing" (a definitive
503
+ // not-configured), or "unknown" (threw, non-OK, or unparseable).
504
+ const probeEndpoint = async (path: string): Promise<VideoStorageProbe> => {
505
+ try {
506
+ const res = await fetchWithAbortTimeout(
507
+ `${base}${path}`,
508
+ {
509
+ credentials: "include",
510
+ cache: "no-store",
511
+ },
512
+ VIDEO_STORAGE_PROBE_TIMEOUT_MS,
513
+ );
514
+ if (!res.ok) return "unknown";
515
+ // coercion-ok: an unparseable body maps to the typed "unknown" probe
516
+ // result, which callers treat as distinct from configured/missing.
517
+ const body = (await res.json().catch(() => null)) as {
511
518
  configured?: boolean;
512
519
  } | null;
513
- if (body) {
514
- sawDefinitiveAnswer = true;
515
- if (body.configured) return "configured";
516
- }
520
+ if (!body) return "unknown";
521
+ return body.configured ? "configured" : "missing";
522
+ } catch {
523
+ return "unknown";
517
524
  }
518
- } catch {
519
- // Fall through to the Builder status endpoint.
520
- }
525
+ };
521
526
 
522
- try {
523
- const builderStatus = await fetchWithAbortTimeout(
524
- `${base}/_agent-native/builder/status`,
525
- {
526
- credentials: "include",
527
- cache: "no-store",
528
- },
529
- VIDEO_STORAGE_PROBE_TIMEOUT_MS,
530
- );
531
- if (builderStatus.ok) {
532
- const body = (await builderStatus.json().catch(() => null)) as {
533
- configured?: boolean;
534
- } | null;
535
- if (body) {
536
- sawDefinitiveAnswer = true;
537
- if (body.configured) return "configured";
538
- }
539
- }
540
- } catch {
541
- // Network error or unreachable server — treat as indeterminate below.
527
+ const probes = [
528
+ probeEndpoint("/_agent-native/file-upload/status"),
529
+ probeEndpoint("/_agent-native/builder/status"),
530
+ ];
531
+ // The probes run concurrently. "configured" wins as soon as either endpoint
532
+ // reports it, but "missing" is only declared after both have settled — a
533
+ // Builder-credits-only user has file-upload configured:false and builder
534
+ // configured:true and must not be routed to storage setup.
535
+ let probe: VideoStorageProbe;
536
+ if ((await Promise.race(probes)) === "configured") {
537
+ probe = "configured";
538
+ } else {
539
+ const results = await Promise.all(probes);
540
+ probe = results.includes("configured")
541
+ ? "configured"
542
+ : results.includes("missing")
543
+ ? "missing"
544
+ : "unknown";
542
545
  }
543
-
544
- return sawDefinitiveAnswer ? "missing" : "unknown";
546
+ // Last-known-good cache: seeds the next launch's Start button so it isn't
547
+ // held behind this round-trip. Only "configured" is ever cached —
548
+ // "missing"/"unknown" must always re-probe — and only when we know whose
549
+ // answer it is, so an unauthenticated probe never writes one.
550
+ if (probe === "configured" && account) {
551
+ saveBool(videoStorageConfiguredKey(serverUrl, account), true);
552
+ }
553
+ return probe;
545
554
  }
546
555
 
547
556
  function authTokenStorageKey(serverUrl: string): string {
548
557
  return `${AUTH_TOKEN_KEY}:${originForServer(serverUrl)}`;
549
558
  }
550
559
 
560
+ // Whether video storage is configured is a fact about one account on one
561
+ // server: both status endpoints answer as the authenticated user. A key any
562
+ // coarser lets one answer enable Start for a server or an account it was never
563
+ // about — including after a sign-out. `account` is null before the session
564
+ // probe settles, which is not an identity, so nothing is cached or seeded then.
565
+ function videoStorageConfiguredKey(serverUrl: string, account: string): string {
566
+ return `${VIDEO_STORAGE_CONFIGURED_KEY}:${originForServer(serverUrl)}:${account}`;
567
+ }
568
+
551
569
  function loadDesktopAuthToken(serverUrl: string): string {
552
570
  return loadString(authTokenStorageKey(serverUrl), "");
553
571
  }
@@ -1234,6 +1252,10 @@ export function App({
1234
1252
  const serverHostForSignIn = serverUrl
1235
1253
  .replace(/^https?:\/\//, "")
1236
1254
  .replace(/\/+$/, "");
1255
+ // Seeded from the last-known-good cache so a machine that ever probed
1256
+ // Starts at "checking" and is seeded from the cache only once the signed-in
1257
+ // account is known — the cached answer belongs to one account on one server,
1258
+ // and the seed effect below is what applies it.
1237
1259
  const [videoStorageStatus, setVideoStorageStatus] =
1238
1260
  useState<VideoStorageStatus>("checking");
1239
1261
  const [signedInAs, setSignedInAs] = useState<string | null>(null);
@@ -1295,14 +1317,43 @@ export function App({
1295
1317
  setDesktopAuthContext(serverUrl, loadDesktopAuthToken(serverUrl));
1296
1318
  }, [serverUrl]);
1297
1319
 
1320
+ // Who and where `videoStorageStatus` is an answer about. Every probe captures
1321
+ // this and drops its result if it has since moved on, so an in-flight probe
1322
+ // can never land one account-and-server's answer on another's.
1323
+ const videoStorageIdentity = `${originForServer(serverUrl)}|${signedInAs ?? ""}`;
1324
+ const videoStorageIdentityRef = useRef(videoStorageIdentity);
1325
+ // A change of server or account makes the current answer meaningless, and the
1326
+ // probe below deliberately never downgrades "configured" to "checking", so
1327
+ // without this the previous answer would keep Start enabled against the new
1328
+ // identity until its first probe lands. Re-seed from that identity's own
1329
+ // cache — which is also what applies the cache at launch, once the session
1330
+ // probe has said who is signed in.
1331
+ useEffect(() => {
1332
+ videoStorageIdentityRef.current = videoStorageIdentity;
1333
+ setVideoStorageStatus(
1334
+ signedInAs &&
1335
+ loadBool(videoStorageConfiguredKey(serverUrl, signedInAs), false)
1336
+ ? "configured"
1337
+ : "checking",
1338
+ );
1339
+ }, [videoStorageIdentity, serverUrl, signedInAs]);
1340
+
1298
1341
  const refreshVideoStorageStatus = useCallback(async () => {
1299
1342
  if (authStatus !== "authed" || localRecordingMode !== "off") {
1300
1343
  setVideoStorageStatus("configured");
1301
1344
  return true;
1302
1345
  }
1303
1346
 
1304
- setVideoStorageStatus((prev) => (prev === "missing" ? prev : "checking"));
1305
- const probe = await hasConfiguredVideoStorage(serverUrl);
1347
+ // Deliberately no "checking" reset here: the status may already be seeded
1348
+ // from the last-known-good cache or the mount-time warmup probe, and
1349
+ // downgrading "configured" to "checking" would re-disable Start for the
1350
+ // probe's whole round-trip. A definitive probe result below still wins.
1351
+ const probedIdentity = videoStorageIdentity;
1352
+ const probe = await hasConfiguredVideoStorage(serverUrl, signedInAs);
1353
+ // The server or the account moved on while this was in flight: this answer
1354
+ // is about an identity the UI is no longer using, and "configured over
1355
+ // there" is not evidence about what Start would record to now.
1356
+ if (videoStorageIdentityRef.current !== probedIdentity) return false;
1306
1357
  if (probe === "unknown") {
1307
1358
  // The check couldn't be completed (offline/unreachable). Never downgrade
1308
1359
  // an already-connected user to "missing" on an indeterminate result;
@@ -1316,12 +1367,25 @@ export function App({
1316
1367
  }
1317
1368
  setVideoStorageStatus(probe);
1318
1369
  return probe === "configured";
1319
- }, [authStatus, localRecordingMode, serverUrl]);
1370
+ }, [
1371
+ authStatus,
1372
+ localRecordingMode,
1373
+ serverUrl,
1374
+ signedInAs,
1375
+ videoStorageIdentity,
1376
+ ]);
1320
1377
 
1321
1378
  useEffect(() => {
1322
1379
  void refreshVideoStorageStatus();
1323
1380
  }, [refreshVideoStorageStatus]);
1324
1381
 
1382
+ // There is deliberately no mount-time warmup probe here any more. One used to
1383
+ // run in parallel with checkAuth to overlap the round-trips, but a probe
1384
+ // started before the session settles cannot say which account its answer
1385
+ // belongs to, and applying it anyway is exactly how one account inherited
1386
+ // another's "configured". A returning user is covered by the account-scoped
1387
+ // cache seed above at no round-trip cost; a first launch pays one probe.
1388
+
1325
1389
  useEffect(() => {
1326
1390
  if (
1327
1391
  authStatus !== "authed" ||
@@ -2604,6 +2668,13 @@ export function App({
2604
2668
  };
2605
2669
  }, []);
2606
2670
 
2671
+ // Warm the multi-second SCShareableContent lookup while the popover is
2672
+ // open so a recording start within the cache TTL skips it. Fire-and-forget.
2673
+ useEffect(() => {
2674
+ if (!popoverVisible) return;
2675
+ invoke("native_fullscreen_prefetch_capture_content").catch(() => {});
2676
+ }, [popoverVisible]);
2677
+
2607
2678
  const speechPermissionChecked = useRef(false);
2608
2679
  useEffect(() => {
2609
2680
  if (!popoverVisible || !micOn || speechPermissionChecked.current) return;
@@ -2676,6 +2747,12 @@ export function App({
2676
2747
  // failed). Stop and cancel are terminal transitions on the recorder a
2677
2748
  // restart is already tearing down, so they must not run against it.
2678
2749
  const restartInFlightRef = useRef(false);
2750
+ // The take the recorder last announced, tracked from the same
2751
+ // `clips:recorder-session` event the pill uses for its identity. A stop that
2752
+ // throws has no result to name, so this is the only way its failure event can
2753
+ // say which take it belongs to instead of being applied to whichever card
2754
+ // happens to be open.
2755
+ const sessionRecordingIdRef = useRef<string | null>(null);
2679
2756
  const recordingInFlight = isRecording || recordingFlowActive;
2680
2757
  useLayoutEffect(() => {
2681
2758
  recordingFlowGateRef.current = recordingInFlight;
@@ -3497,6 +3574,8 @@ export function App({
3497
3574
  preAcquiredCameraStream,
3498
3575
  preAcquiredDisplayStream: options?.resumeCapture?.displayStream ?? null,
3499
3576
  preAcquiredAudioStream: options?.resumeCapture?.audioStream ?? null,
3577
+ pendingTranscriptionTeardown:
3578
+ options?.resumeCapture?.transcriptionTornDown ?? null,
3500
3579
  signal: startController.signal,
3501
3580
  });
3502
3581
  // macOS: give WebKit a short window to dispatch getDisplayMedia before
@@ -3771,6 +3850,14 @@ export function App({
3771
3850
  // ignore — best-effort
3772
3851
  });
3773
3852
  };
3853
+ track(
3854
+ listen<{ recordingId?: string | null }>(
3855
+ "clips:recorder-session",
3856
+ (event) => {
3857
+ sessionRecordingIdRef.current = event.payload?.recordingId ?? null;
3858
+ },
3859
+ ),
3860
+ );
3774
3861
  track(
3775
3862
  listen("clips:recorder-stop", async () => {
3776
3863
  if (restartInFlightRef.current) return;
@@ -3797,6 +3884,9 @@ export function App({
3797
3884
 
3798
3885
  let stopFailed = false;
3799
3886
  let stopResult: RecorderStopResult | null = null;
3887
+ // Captured before the await: by the time a slow stop throws, a
3888
+ // replacement take may already have announced itself.
3889
+ const stoppingRecordingId = sessionRecordingIdRef.current;
3800
3890
  try {
3801
3891
  stopResult = await handle.stop();
3802
3892
  if (stopResult.localOnly) {
@@ -3804,6 +3894,15 @@ export function App({
3804
3894
  folderPath: stopResult.localFolder,
3805
3895
  files: stopResult.localFiles ?? [],
3806
3896
  });
3897
+ // A local-only stop has no upload, so nothing else ever publishes
3898
+ // its outcome. Without this the pill's completion card would hold
3899
+ // "finishing up" until its stall timeout — and it must not claim
3900
+ // the file was saved before the export actually returned.
3901
+ emit("clips:native-upload-finished", {
3902
+ recordingId: stopResult.recordingId,
3903
+ ok: true,
3904
+ localFilePath: stopResult.localFiles?.[0]?.path ?? null,
3905
+ }).catch(() => {});
3807
3906
  } else {
3808
3907
  setLastRecordingId(stopResult.recordingId);
3809
3908
  // The browser opens `/r/<id>` (the author's dashboard); what lands
@@ -3814,6 +3913,18 @@ export function App({
3814
3913
  } catch (err) {
3815
3914
  stopFailed = true;
3816
3915
  setRecError(err instanceof Error ? err.message : String(err));
3916
+ // Only when the stop itself threw. Past that point the upload
3917
+ // pipeline owns the completion event and a copy-link failure is not
3918
+ // an upload failure — but a stop that never produced a result is
3919
+ // not a completion, and a local-only take has no other publisher to
3920
+ // correct the card.
3921
+ if (!stopResult) {
3922
+ emit("clips:native-upload-finished", {
3923
+ recordingId: stoppingRecordingId ?? undefined,
3924
+ ok: false,
3925
+ error: err instanceof Error ? err.message : String(err),
3926
+ }).catch(() => {});
3927
+ }
3817
3928
  await loadPendingUploads();
3818
3929
  } finally {
3819
3930
  recordingStopFinalizingRef.current = false;
@@ -3836,8 +3947,19 @@ export function App({
3836
3947
  track(
3837
3948
  listen("clips:recorder-cancel", async () => {
3838
3949
  if (restartInFlightRef.current) return;
3950
+ const cancelDone = recorder.cancel();
3951
+ // Optimistic feedback: bring the popover back and clear the tray's
3952
+ // recording state the moment the cancel is dispatched — the recorder
3953
+ // teardown can take seconds and neither call depends on it. The flow
3954
+ // gate below must NOT be released here: it stays latched until
3955
+ // cancel() resolves so a fast Start can't race the tearing-down
3956
+ // session.
3957
+ if (!cancelled) {
3958
+ invoke("set_recording_state", { active: false }).catch(() => {});
3959
+ invoke("show_popover").catch(() => {});
3960
+ }
3839
3961
  try {
3840
- await recorder.cancel();
3962
+ await cancelDone;
3841
3963
  } finally {
3842
3964
  if (!cancelled) {
3843
3965
  (
@@ -3849,8 +3971,6 @@ export function App({
3849
3971
  setRecorder(null);
3850
3972
  setRecordingFlowActive(false);
3851
3973
  setBubbleSessionEpoch((epoch) => epoch + 1);
3852
- invoke("set_recording_state", { active: false }).catch(() => {});
3853
- invoke("show_popover").catch(() => {});
3854
3974
  }
3855
3975
  }
3856
3976
  }),
@@ -8101,7 +8221,7 @@ function Setup({
8101
8221
  /* A fixed height, not content-driven: the tray window resizes itself to
8102
8222
  match rendered content, so a taller tab would otherwise grow the window
8103
8223
  out from under the user. Tabs scroll inside this frame instead. */
8104
- className="flex h-[560px] max-h-[calc(100vh-48px)] w-full flex-col overflow-hidden rounded-[14px] bg-background text-foreground"
8224
+ className="flex h-[560px] max-h-screen w-full flex-col overflow-hidden rounded-[14px] bg-background text-foreground"
8105
8225
  >
8106
8226
  <div className="grid min-h-0 flex-1 grid-cols-[176px_minmax(0,1fr)]">
8107
8227
  <nav
@@ -0,0 +1,111 @@
1
+ /**
2
+ * How the recording pill's completion card reads a `clips:native-upload-finished`
3
+ * payload.
4
+ *
5
+ * The card is fed from three places — the recorder's event, a localStorage
6
+ * hand-off for a pill that was still mounting, and a Rust-side replay of the
7
+ * last result — and the pill's window is reused across a restart, so a payload
8
+ * can arrive for a recording the window has already moved past.
9
+ */
10
+
11
+ export type PillDoneStage = "finishing" | "uploading" | "uploaded" | "failed";
12
+
13
+ export type NativeUploadFinished = {
14
+ recordingId?: string;
15
+ ok?: boolean;
16
+ viewUrl?: string;
17
+ error?: string | null;
18
+ localFilePath?: string | null;
19
+ };
20
+
21
+ export type PillCompletion = {
22
+ stage: "uploaded" | "failed";
23
+ savedLocally: boolean;
24
+ viewUrl: string | null;
25
+ };
26
+
27
+ /**
28
+ * The completion state the card should show, or `null` when the payload
29
+ * belongs to a different recording than the one on screen and must be ignored.
30
+ *
31
+ * Identity is checked through `isCompletionForSession`: a late completion from
32
+ * an earlier take would otherwise repaint the current card with the old clip's
33
+ * link and status.
34
+ */
35
+ /**
36
+ * Whether an upload event belongs to the take the card is showing.
37
+ *
38
+ * Shared by the completion and progress paths so one rule governs both. When
39
+ * both sides name an id and they differ, the event is a leftover from an
40
+ * earlier take — this window is reused, so that happens. When either side has
41
+ * no id there is nothing to compare and the event is taken, which is the
42
+ * contract the pill has always had.
43
+ */
44
+ export function isCompletionForSession(
45
+ sessionRecordingId: string | null | undefined,
46
+ payload: { recordingId?: string },
47
+ ): boolean {
48
+ if (!sessionRecordingId || !payload.recordingId) return true;
49
+ return payload.recordingId === sessionRecordingId;
50
+ }
51
+
52
+ export function resolveCompletion(
53
+ sessionRecordingId: string | null | undefined,
54
+ payload: NativeUploadFinished,
55
+ ): PillCompletion | null {
56
+ if (!isCompletionForSession(sessionRecordingId, payload)) {
57
+ return null;
58
+ }
59
+ return {
60
+ // A local-only stop succeeds with no view URL — the file on disk is the
61
+ // whole result — so success is `ok`, never the presence of a link.
62
+ stage: payload.ok ? "uploaded" : "failed",
63
+ savedLocally: Boolean(payload.localFilePath),
64
+ viewUrl: payload.viewUrl ?? null,
65
+ };
66
+ }
67
+
68
+ export type PillCardTone = "pending" | "ok" | "warn";
69
+
70
+ export type PillCardState = {
71
+ title: string;
72
+ /** Sub-line detail, appended after the take's duration. Empty when the
73
+ * title already says everything there is to say. */
74
+ detail: string;
75
+ tone: PillCardTone;
76
+ };
77
+
78
+ /**
79
+ * The completion card's headline and tone.
80
+ *
81
+ * The card goes up the instant Stop is pressed, before the export or upload
82
+ * has returned anything, so the headline tracks `stage` rather than asserting
83
+ * success. A stop that fails must not leave a green check and "Recording
84
+ * saved" on screen with only a small caption disagreeing — the headline is
85
+ * what a glance and a screen reader both take away.
86
+ */
87
+ export function completionCardState(
88
+ stage: PillDoneStage,
89
+ session: { hasLink: boolean; savedLocally: boolean },
90
+ ): PillCardState {
91
+ switch (stage) {
92
+ case "uploaded":
93
+ return {
94
+ title: "Recording saved",
95
+ // With a link the row below already shows it; without one the file on
96
+ // disk is the only place the take exists, so say where it went.
97
+ detail: session.hasLink ? "" : "saved on this device",
98
+ tone: "ok",
99
+ };
100
+ case "failed":
101
+ return {
102
+ title: "Upload paused",
103
+ detail: session.savedLocally ? "saved on this device" : "",
104
+ tone: "warn",
105
+ };
106
+ case "uploading":
107
+ return { title: "Uploading", detail: "", tone: "pending" };
108
+ case "finishing":
109
+ return { title: "Finishing up", detail: "", tone: "pending" };
110
+ }
111
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What the recording pill does when the recorder flips `clips:toolbar-enabled`.
3
+ *
4
+ * The pill's window outlives a single take: a restart reuses it for the
5
+ * replacement session, and the completion card keeps it open after a stop. So
6
+ * "enabled" is not simply "show the controls" — it also decides whether the
7
+ * previous session's card is retired.
8
+ */
9
+
10
+ export type PillMode = "recording" | "confirm" | "done";
11
+
12
+ export type ToolbarEnabledEffect =
13
+ /** A live session is taking a window still showing the last take's card. */
14
+ | "adopt-new-session"
15
+ /** No session owns the pill: snap the segments back to their rest state. */
16
+ | "reset-to-rest"
17
+ /** Leave the pill exactly as it is. */
18
+ | "keep";
19
+
20
+ export function toolbarEnabledEffect(
21
+ enabled: boolean,
22
+ mode: PillMode,
23
+ ): ToolbarEnabledEffect {
24
+ if (enabled) {
25
+ // A confirm strip is deliberately NOT rescued here. The recorder re-emits
26
+ // `toolbar-enabled(true)` on every `toolbar-ready` handshake, so resetting
27
+ // on enable would close a question the user is still reading. Answering a
28
+ // confirm therefore has to leave confirm mode at the point of the answer.
29
+ return mode === "done" ? "adopt-new-session" : "keep";
30
+ }
31
+ // The completion card owns the window after a stop; disabling the controls
32
+ // must not wipe it out from under the user.
33
+ return mode === "done" ? "keep" : "reset-to-rest";
34
+ }