@agent-native/core 0.175.2-nightly-20260826150235 → 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 (64) 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/app/components/player/video-player.tsx +5 -3
  4. package/corpus/templates/clips/desktop/src/app.tsx +171 -51
  5. package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
  6. package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
  8. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
  9. package/corpus/templates/clips/desktop/src/main.tsx +4 -4
  10. package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
  11. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
  12. package/corpus/templates/clips/desktop/src/styles.css +20 -308
  13. package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
  15. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
  16. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
  17. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
  18. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
  19. package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
  20. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
  21. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
  22. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
  23. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
  24. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
  25. package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
  26. package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
  28. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
  29. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
  30. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
  31. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
  32. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
  33. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
  34. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
  35. package/corpus/templates/design/shared/canvas-math.ts +11 -1
  36. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
  37. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
  38. package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
  40. package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
  41. package/dist/client/hooks/index.d.ts +1 -0
  42. package/dist/client/hooks/index.js +1 -0
  43. package/dist/client/use-pinch-zoom.js +17 -4
  44. package/dist/client/zoom-gesture.d.ts +74 -0
  45. package/dist/client/zoom-gesture.js +100 -0
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/observability/routes.d.ts +3 -3
  49. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  50. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-browser.d.ts +13 -13
  53. package/dist/server/builder-browser.js +46 -15
  54. package/dist/server/builder-oauth.d.ts +12 -0
  55. package/dist/server/builder-oauth.js +13 -2
  56. package/dist/server/core-routes-plugin.js +54 -19
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/client.js +2 -0
  60. package/docs/content/template-chat-developers.mdx +100 -13
  61. package/docs/content/template-chat-first-edits.mdx +5 -5
  62. package/docs/content/template-chat.mdx +84 -36
  63. package/package.json +4 -3
  64. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
@@ -49,7 +49,6 @@
49
49
  */
50
50
  import { invoke } from "@tauri-apps/api/core";
51
51
  import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
52
- import { open as openExternal } from "@tauri-apps/plugin-shell";
53
52
 
54
53
  import {
55
54
  waitForAcceptedRecordingAfterFinalizeError,
@@ -269,6 +268,12 @@ export interface StartParams {
269
268
  preAcquiredDisplayStream?: MediaStream | null;
270
269
  /** Live microphone capture handed over on restart. See `preAcquiredDisplayStream`. */
271
270
  preAcquiredAudioStream?: MediaStream | null;
271
+ /**
272
+ * The replaced take's transcription teardown, handed over on restart. See
273
+ * `RestartHandoff.transcriptionTornDown` — the engine is process-global, so
274
+ * this session must await it immediately before starting transcription.
275
+ */
276
+ pendingTranscriptionTeardown?: Promise<void> | null;
272
277
  }
273
278
 
274
279
  const REWIND_CLIP_ORIGINS_KEY = "clips.rewindClipOrigins.v1";
@@ -338,6 +343,16 @@ export interface RestartHandoff {
338
343
  /** Null when the backend re-acquires capture natively (no user gesture needed). */
339
344
  displayStream: MediaStream | null;
340
345
  audioStream: MediaStream | null;
346
+ /**
347
+ * Settles when the discarded take's transcription engine has fully stopped.
348
+ * The engine is process-global (one session slot), so the retake must await
349
+ * this immediately before its own `startTranscriptionCapture` — and no
350
+ * earlier, so Whisper's final flush hides under the retake's countdown
351
+ * instead of delaying it. Every creation site attaches its own `.catch`,
352
+ * so this promise never rejects. Absent/null when the discarded take had
353
+ * no transcription running.
354
+ */
355
+ transcriptionTornDown?: Promise<void> | null;
341
356
  }
342
357
 
343
358
  export interface RecorderHandle {
@@ -2463,16 +2478,43 @@ async function runRecordingCountdown(
2463
2478
  }
2464
2479
 
2465
2480
  function showFinalizingFeedback() {
2466
- // The finalizing window is created asynchronously. Clear the previous
2467
- // completion record before showing it so a new stop cannot consume an old
2468
- // result while its event listener is still mounting.
2481
+ // Clear the previous completion record so a new stop cannot consume an old
2482
+ // result while listeners are still mounting.
2469
2483
  try {
2470
2484
  window.localStorage.removeItem(FINALIZING_RESULT_STORAGE_KEY);
2471
2485
  } catch {
2472
2486
  // Storage is a best-effort event-race fallback only.
2473
2487
  }
2474
- invoke("show_finalizing").catch((err) =>
2475
- console.error("[clips-recorder] show_finalizing failed:", err),
2488
+ // The recording pill renders the completion card in place — it holds its
2489
+ // window open via `set_toolbar_finishing` before emitting stop and consumes
2490
+ // the same upload progress/finished events — so the separate finalizing
2491
+ // window is no longer shown here.
2492
+ }
2493
+
2494
+ /**
2495
+ * Tell the recording pill which clip this session will become, so Stop can
2496
+ * copy the share link the instant it's clicked instead of waiting for the
2497
+ * upload to finish. Re-emitted from every `clips:toolbar-ready` handshake so
2498
+ * a pill that mounts late still learns its session.
2499
+ *
2500
+ * `recordingId` is also the pill's session identity: its window is reused
2501
+ * across a restart, so it needs this to tell its own completion event from a
2502
+ * previous take's late one. It is sent for local-only takes too, where it is
2503
+ * the export folder name — the same value the save command echoes back as
2504
+ * `recordingId`, so the comparison holds on both sides. Only `serverUrl` is
2505
+ * withheld for local, which is what keeps `viewUrl` null.
2506
+ */
2507
+ function emitRecorderSession(
2508
+ serverUrl: string | null,
2509
+ recordingId: string | null,
2510
+ localOnly: boolean,
2511
+ ) {
2512
+ const viewUrl =
2513
+ serverUrl && recordingId
2514
+ ? `${serverUrl.replace(/\/+$/, "")}/r/${recordingId}`
2515
+ : null;
2516
+ emit("clips:recorder-session", { viewUrl, recordingId, localOnly }).catch(
2517
+ () => {},
2476
2518
  );
2477
2519
  }
2478
2520
 
@@ -2515,24 +2557,6 @@ async function publishFinalizingResult(params: {
2515
2557
  });
2516
2558
  }
2517
2559
 
2518
- async function claimNativeUploadOpen(recordingId: string): Promise<boolean> {
2519
- return invoke<boolean>("native_fullscreen_claim_upload_open", {
2520
- recordingId,
2521
- }).catch(() => true);
2522
- }
2523
-
2524
- async function openNativeUploadUrl(
2525
- recordingId: string,
2526
- url: string,
2527
- ): Promise<void> {
2528
- if (!(await claimNativeUploadOpen(recordingId))) return;
2529
- try {
2530
- await openExternal(url);
2531
- } catch (err) {
2532
- console.error("[clips-recorder] openExternal failed:", err);
2533
- }
2534
- }
2535
-
2536
2560
  /**
2537
2561
  * Hosted native start sequencing helper: overlap Whisper start, create-recording,
2538
2562
  * and deferred SCK warm so Skip no longer waits serially on Whisper then warm.
@@ -2731,11 +2755,17 @@ async function tryStartRewindFullscreenRecording(
2731
2755
  const canTranscribeLocally = !localOnly && includeMic;
2732
2756
  const startRewindTranscription = async () => {
2733
2757
  if (!canTranscribeLocally || transcriptionCapture) return;
2758
+ // The engine is process-global: on a restart the replaced take's stop must
2759
+ // land before this start, or it attaches to the dying capture. The wait
2760
+ // runs here, under the countdown, instead of inside the discard.
2761
+ await params.pendingTranscriptionTeardown;
2762
+ if (transcriptionAborted) return;
2734
2763
  // Runs after `rewind_clip_prepare`, so the Rewind producer already carries
2735
2764
  // mic (+system) and whisper attaches to it instead of opening its own
2736
2765
  // ScreenCaptureKit stream — which would mute the clip's own audio legs.
2737
- // This runs inside the countdown's Promise.all: a transcription failure
2738
- // must never reject there, or it would abort the recording itself.
2766
+ // This runs at the end of prepare(), under the live countdown: a
2767
+ // transcription failure must never reject here, or it would cancel the
2768
+ // countdown and abort the recording itself.
2739
2769
  const capture = await startTranscriptionCapture(
2740
2770
  { deviceId: params.micId, label: params.micLabel },
2741
2771
  includeSystemAudio,
@@ -2756,9 +2786,6 @@ async function tryStartRewindFullscreenRecording(
2756
2786
  void saveTranscriptFailure(TRANSCRIPTION_START_FAILURE);
2757
2787
  }
2758
2788
  };
2759
- await guardRecordingStart(invoke("show_preparing"), {
2760
- signal: params.signal,
2761
- });
2762
2789
  const recordingPromise = localOnly
2763
2790
  ? Promise.resolve<{ id: string; uploadMode: UploadMode }>({
2764
2791
  id: folderName,
@@ -2786,6 +2813,17 @@ async function tryStartRewindFullscreenRecording(
2786
2813
  },
2787
2814
  );
2788
2815
  })();
2816
+ // Prepare and the countdown start concurrently, and `runRecordingCountdown`
2817
+ // installs its Tauri listeners asynchronously. A prepare that fails inside
2818
+ // that window has nothing listening for `clips:countdown-cancel`, and the
2819
+ // dropped event leaves the countdown on screen for its full timeout before
2820
+ // the real error surfaces. An abort signal is durable where an event is not
2821
+ // — the waiter checks `aborted` as it registers — so the controller is
2822
+ // created before either phase starts.
2823
+ const countdownAbort = new AbortController();
2824
+ const abortCountdown = () => countdownAbort.abort();
2825
+ if (params.signal?.aborted) abortCountdown();
2826
+ else params.signal?.addEventListener("abort", abortCountdown, { once: true });
2789
2827
  try {
2790
2828
  const recording = await prepareRewindRecordingStart({
2791
2829
  async prepare() {
@@ -2806,19 +2844,25 @@ async function tryStartRewindFullscreenRecording(
2806
2844
  }),
2807
2845
  { signal: params.signal },
2808
2846
  );
2847
+ // Whisper stays after `rewind_clip_prepare` (it attaches to the
2848
+ // producer that call registers) and inside prepare so it has settled
2849
+ // before activation, while the countdown runs over all of it.
2850
+ await startRewindTranscription();
2809
2851
  return preparedRecording;
2810
2852
  },
2811
2853
  async countdown() {
2812
- console.log("[rewind-latency] countdown shown after preparation");
2813
- await boundedCleanup(invoke("hide_preparing"));
2814
- // Overlap whisper startup with the countdown so the capture boundary
2815
- // never waits on it, but have both settled before activation.
2816
- await Promise.all([
2817
- runRecordingCountdown(true, params.signal),
2818
- startRewindTranscription(),
2819
- ]);
2854
+ console.log("[rewind-latency] countdown shown; preparation overlapped");
2855
+ await runRecordingCountdown(true, countdownAbort.signal);
2820
2856
  console.log("[rewind-latency] countdown completed");
2821
2857
  },
2858
+ cancelCountdown() {
2859
+ // Abort first: it is the half that cannot be lost to a countdown that
2860
+ // has not finished registering. The event stays so a countdown that
2861
+ // IS listening tears its chrome down through the same path Escape
2862
+ // uses, and so any other listener still sees the cancel.
2863
+ abortCountdown();
2864
+ void emit("clips:countdown-cancel", { cause: "prepare-failed" });
2865
+ },
2822
2866
  async activate(preparedRecording) {
2823
2867
  const activationStarted = performance.now();
2824
2868
  await guardRecordingStart(
@@ -2842,6 +2886,7 @@ async function tryStartRewindFullscreenRecording(
2842
2886
  void audioCue.playBeforeCapture();
2843
2887
  },
2844
2888
  });
2889
+ params.signal?.removeEventListener("abort", abortCountdown);
2845
2890
  id = recording.id;
2846
2891
  const originalStartedAt = new Date().toISOString();
2847
2892
  if (!localOnly) {
@@ -2854,7 +2899,7 @@ async function tryStartRewindFullscreenRecording(
2854
2899
  });
2855
2900
  }
2856
2901
  } catch (err) {
2857
- await boundedCleanup(invoke("hide_preparing"));
2902
+ params.signal?.removeEventListener("abort", abortCountdown);
2858
2903
  transcriptionAborted = true;
2859
2904
  // Cast: `transcriptionCapture` is only assigned inside the
2860
2905
  // `startRewindTranscription` closure, which TS's control-flow analysis
@@ -2927,7 +2972,7 @@ async function tryStartRewindFullscreenRecording(
2927
2972
  await clearRecordingState();
2928
2973
  };
2929
2974
 
2930
- const discardTake = async (forRestart: boolean) => {
2975
+ const discardTake = async (forRestart: boolean): Promise<RestartHandoff> => {
2931
2976
  stopped = true;
2932
2977
  cleanupUi();
2933
2978
  transcriptionAborted = true;
@@ -2945,13 +2990,27 @@ async function tryStartRewindFullscreenRecording(
2945
2990
  }
2946
2991
  if (!localOnly && id) {
2947
2992
  forgetRewindClipOrigin(id);
2948
- await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
2949
- () => {},
2993
+ void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
2994
+ (err) => {
2995
+ console.warn(
2996
+ "[clips-recorder] cancelled recording cleanup failed:",
2997
+ err,
2998
+ );
2999
+ },
2950
3000
  );
2951
3001
  }
2952
3002
  // The transcription engine is process-global, so this stop must land
2953
- // before the replacement session starts it again.
2954
- if (forRestart) await transcriptionTornDown;
3003
+ // before the replacement session starts it again. Hand the pending
3004
+ // teardown to the retake — it awaits it right before its own
3005
+ // transcription start, hiding the flush under the new countdown instead
3006
+ // of delaying it here.
3007
+ return {
3008
+ displayStream: null,
3009
+ audioStream: null,
3010
+ transcriptionTornDown: forRestart
3011
+ ? (transcriptionTornDown ?? null)
3012
+ : null,
3013
+ };
2955
3014
  };
2956
3015
 
2957
3016
  const handle: RecorderHandle = {
@@ -3037,10 +3096,6 @@ async function tryStartRewindFullscreenRecording(
3037
3096
  : Promise.resolve(true);
3038
3097
  await invoke("hide_recording_chrome").catch(() => {});
3039
3098
  if (wantsCamera) await invoke("close_bubble").catch(() => {});
3040
- await openNativeUploadUrl(
3041
- id,
3042
- `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
3043
- );
3044
3099
  try {
3045
3100
  const uploaded = await uploadPromise;
3046
3101
  if (uploaded.verificationPending) {
@@ -3099,7 +3154,7 @@ async function tryStartRewindFullscreenRecording(
3099
3154
  return cancelPromise;
3100
3155
  }
3101
3156
  if (stopped) return;
3102
- cancelPromise = discardTake(false);
3157
+ cancelPromise = discardTake(false).then(() => {});
3103
3158
  return cancelPromise;
3104
3159
  },
3105
3160
  async discardForRestart() {
@@ -3112,11 +3167,8 @@ async function tryStartRewindFullscreenRecording(
3112
3167
  throw new Error("Recording already finished — nothing to restart");
3113
3168
  }
3114
3169
  // The Rewind producer re-acquires capture natively, so the retake needs
3115
- // nothing handed to it.
3116
- discardPromise = discardTake(true).then(() => ({
3117
- displayStream: null,
3118
- audioStream: null,
3119
- }));
3170
+ // no streams handed to it — only the pending transcription teardown.
3171
+ discardPromise = discardTake(true);
3120
3172
  return discardPromise;
3121
3173
  },
3122
3174
  };
@@ -3163,12 +3215,22 @@ async function tryStartRewindFullscreenRecording(
3163
3215
  }),
3164
3216
  listen("clips:toolbar-ready", () => {
3165
3217
  emit("clips:toolbar-enabled", !stopped).catch(() => {});
3218
+ emitRecorderSession(
3219
+ localOnly ? null : params.serverUrl,
3220
+ id || null,
3221
+ localOnly,
3222
+ );
3166
3223
  emitState();
3167
3224
  }),
3168
3225
  ]);
3169
3226
  tickHandle = window.setInterval(emitState, 500);
3170
3227
  emit("clips:toolbar-sync").catch(() => {});
3171
3228
  emit("clips:toolbar-enabled", true).catch(() => {});
3229
+ emitRecorderSession(
3230
+ localOnly ? null : params.serverUrl,
3231
+ id || null,
3232
+ localOnly,
3233
+ );
3172
3234
  emitState();
3173
3235
  return handle;
3174
3236
  }
@@ -3218,6 +3280,10 @@ async function startNativeFullscreenRecording(
3218
3280
  };
3219
3281
  const startNativeTranscriptionBeforeRecording = async () => {
3220
3282
  if (localOnly || !canTranscribeLocally || transcriptionCapture) return;
3283
+ // The engine is process-global: on a restart the replaced take's stop must
3284
+ // land before this start, or it attaches to the dying capture. The wait
3285
+ // runs here, under the countdown, instead of inside the discard.
3286
+ await params.pendingTranscriptionTeardown;
3221
3287
  throwIfRecordingStartAborted(params.signal);
3222
3288
  transcriptionCapture = await guardRecordingStart(
3223
3289
  startTranscriptionCapture(
@@ -3503,6 +3569,11 @@ async function startNativeFullscreenRecording(
3503
3569
  // timer baseline so the toolbar clock lines up with the real start.
3504
3570
  startedAt = Date.now();
3505
3571
  emit("clips:toolbar-enabled", true).catch(() => {});
3572
+ emitRecorderSession(
3573
+ localOnly ? null : params.serverUrl,
3574
+ id || null,
3575
+ localOnly,
3576
+ );
3506
3577
  emit("clips:recorder-state", {
3507
3578
  paused: false,
3508
3579
  elapsedMs: 0,
@@ -3625,7 +3696,7 @@ async function startNativeFullscreenRecording(
3625
3696
  await invoke("hide_overlays").catch(() => {});
3626
3697
  };
3627
3698
 
3628
- const discardTake = async (forRestart: boolean) => {
3699
+ const discardTake = async (forRestart: boolean): Promise<RestartHandoff> => {
3629
3700
  stopped = true;
3630
3701
  clearSegmentRotator();
3631
3702
  pauseQueue?.dispose();
@@ -3684,8 +3755,17 @@ async function startNativeFullscreenRecording(
3684
3755
  );
3685
3756
  }
3686
3757
  // The transcription engine is process-global, so this stop must land
3687
- // before the replacement session starts it again.
3688
- if (forRestart) await transcriptionTornDown;
3758
+ // before the replacement session starts it again. Hand the pending
3759
+ // teardown to the retake — it awaits it right before its own
3760
+ // transcription start, hiding the flush under the new countdown instead
3761
+ // of delaying it here.
3762
+ return {
3763
+ displayStream: null,
3764
+ audioStream: null,
3765
+ transcriptionTornDown: forRestart
3766
+ ? (transcriptionTornDown ?? null)
3767
+ : null,
3768
+ };
3689
3769
  };
3690
3770
 
3691
3771
  const handle: RecorderHandle = {
@@ -3822,13 +3902,6 @@ async function startNativeFullscreenRecording(
3822
3902
  },
3823
3903
  );
3824
3904
  uploadPromise.catch(() => {});
3825
- // The recording row already exists, so open its page as soon as the
3826
- // native stop command has started. Upload/finalize continues in this
3827
- // webview while the page polls from `uploading` to `ready`.
3828
- await openNativeUploadUrl(
3829
- id,
3830
- `${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
3831
- );
3832
3905
  try {
3833
3906
  await Promise.race([
3834
3907
  recorderFinalized,
@@ -3926,7 +3999,7 @@ async function startNativeFullscreenRecording(
3926
3999
  return cancelPromise;
3927
4000
  }
3928
4001
  if (stopped) return;
3929
- cancelPromise = discardTake(false);
4002
+ cancelPromise = discardTake(false).then(() => {});
3930
4003
  return cancelPromise;
3931
4004
  },
3932
4005
 
@@ -3940,11 +4013,9 @@ async function startNativeFullscreenRecording(
3940
4013
  throw new Error("Recording already finished — nothing to restart");
3941
4014
  }
3942
4015
  // ScreenCaptureKit is driven from Rust, so the retake re-acquires
3943
- // capture natively without needing a user gesture.
3944
- discardPromise = discardTake(true).then(() => ({
3945
- displayStream: null,
3946
- audioStream: null,
3947
- }));
4016
+ // capture natively without needing a user gesture — only the pending
4017
+ // transcription teardown is handed over.
4018
+ discardPromise = discardTake(true);
3948
4019
  return discardPromise;
3949
4020
  },
3950
4021
  };
@@ -4015,6 +4086,11 @@ async function startNativeFullscreenRecording(
4015
4086
  }),
4016
4087
  listen("clips:toolbar-ready", () => {
4017
4088
  emit("clips:toolbar-enabled", !stopped).catch(() => {});
4089
+ emitRecorderSession(
4090
+ localOnly ? null : params.serverUrl,
4091
+ id || null,
4092
+ localOnly,
4093
+ );
4018
4094
  emitState();
4019
4095
  }),
4020
4096
  ]);
@@ -4023,6 +4099,11 @@ async function startNativeFullscreenRecording(
4023
4099
  startSegmentRotator();
4024
4100
  emit("clips:toolbar-sync").catch(() => {});
4025
4101
  emit("clips:toolbar-enabled", true).catch(() => {});
4102
+ emitRecorderSession(
4103
+ localOnly ? null : params.serverUrl,
4104
+ id || null,
4105
+ localOnly,
4106
+ );
4026
4107
  emitState();
4027
4108
 
4028
4109
  if (!localOnly) {
@@ -4204,7 +4285,11 @@ export function resolveRestartHandoff(
4204
4285
  stopStream(audioStream);
4205
4286
  throw new Error(RESTART_CAPTURE_ENDED_MESSAGE);
4206
4287
  }
4207
- return { displayStream, audioStream };
4288
+ return {
4289
+ displayStream,
4290
+ audioStream,
4291
+ transcriptionTornDown: params.pendingTranscriptionTeardown,
4292
+ };
4208
4293
  }
4209
4294
 
4210
4295
  async function startRecordingInner(
@@ -4690,6 +4775,7 @@ async function startRecordingInner(
4690
4775
  emit("clips:toolbar-enabled", startedAt > 0 && !stopped).catch(
4691
4776
  () => {},
4692
4777
  );
4778
+ emitRecorderSession(null, null, true);
4693
4779
  emitState(pausedAt != null);
4694
4780
  }),
4695
4781
  ]);
@@ -4702,6 +4788,7 @@ async function startRecordingInner(
4702
4788
  startedAt = Date.now();
4703
4789
  tickHandle = setInterval(() => emitState(pausedAt != null), 500);
4704
4790
  emit("clips:toolbar-enabled", true).catch(() => {});
4791
+ emitRecorderSession(null, null, true);
4705
4792
  emitState(false);
4706
4793
 
4707
4794
  const detachCombinedStream = () => {
@@ -5124,6 +5211,7 @@ async function startRecordingInner(
5124
5211
  emit("clips:toolbar-enabled", startedAt > 0 && !stopped).catch(
5125
5212
  () => {},
5126
5213
  );
5214
+ emitRecorderSession(params.serverUrl, id, false);
5127
5215
  emitState(pausedAt != null);
5128
5216
  }),
5129
5217
  ]);
@@ -5140,6 +5228,7 @@ async function startRecordingInner(
5140
5228
  // Now that MediaRecorder is actually ticking, flip the toolbar's
5141
5229
  // Stop / Pause buttons to enabled so the user can drive the recorder.
5142
5230
  emit("clips:toolbar-enabled", true).catch(() => {});
5231
+ emitRecorderSession(params.serverUrl, id, false);
5143
5232
  // Seed the initial recorder-state so the time / paused styling match
5144
5233
  // MediaRecorder's real state (before the first 500ms tick).
5145
5234
  emitState(false);
@@ -5149,6 +5238,10 @@ async function startRecordingInner(
5149
5238
  // delaying capture, so the first ~1s the user expected to record was lost
5150
5239
  // (and the recording felt cut at the end). It's a separate capture from the
5151
5240
  // recorded audio tracks, so starting it slightly late is safe.
5241
+ //
5242
+ // The engine is process-global: a restart's handed-off teardown must land
5243
+ // before this start, or it attaches to the dying capture.
5244
+ if (canTranscribeLocally) await restartHandoff.transcriptionTornDown;
5152
5245
  transcriptionCapture = canTranscribeLocally
5153
5246
  ? await startTranscriptionCapture(
5154
5247
  {
@@ -5243,11 +5336,6 @@ async function startRecordingInner(
5243
5336
  // fixed even if launching the browser takes a moment. Open the existing
5244
5337
  // recording row now and let its page poll while upload/finalize continues.
5245
5338
  //
5246
- // This must claim the native upload-open slot before launching. The
5247
- // Finalizing overlay receives the completion event later and uses the
5248
- // same claim; without it, browser recordings opened once here and then a
5249
- // second time when finalization completed.
5250
- await openNativeUploadUrl(id, absoluteViewUrl);
5251
5339
  const recorderStopTimedOut = await Promise.race([
5252
5340
  recorderStopped.then(() => false),
5253
5341
  wait(MEDIA_RECORDER_STOP_TIMEOUT_MS).then(() => true),
@@ -5613,11 +5701,25 @@ async function startRecordingInner(
5613
5701
  console.warn("[clips-recorder] local backup cleanup failed:", err);
5614
5702
  });
5615
5703
  // The transcription engine is process-global, so this stop must land
5616
- // before the replacement session starts it again.
5617
- if (keepCaptureStreams) await transcriptionTornDown;
5704
+ // before the replacement session starts it again. Hand the pending
5705
+ // teardown to the retake — it awaits it right before its own
5706
+ // transcription start, hiding the flush under the new countdown instead
5707
+ // of delaying it here. Gated on `keepCaptureStreams`, not `handsOff`:
5708
+ // a synthetic-capture restart hands over no streams but still restarts.
5709
+ const handedTranscriptionTeardown = keepCaptureStreams
5710
+ ? (transcriptionTornDown ?? null)
5711
+ : null;
5618
5712
  return handsOff
5619
- ? { displayStream, audioStream }
5620
- : { displayStream: null, audioStream: null };
5713
+ ? {
5714
+ displayStream,
5715
+ audioStream,
5716
+ transcriptionTornDown: handedTranscriptionTeardown,
5717
+ }
5718
+ : {
5719
+ displayStream: null,
5720
+ audioStream: null,
5721
+ transcriptionTornDown: handedTranscriptionTeardown,
5722
+ };
5621
5723
  };
5622
5724
 
5623
5725
  const handle: RecorderHandle = {
@@ -1,19 +1,43 @@
1
1
  export interface RewindRecordingStartPhases<TPrepared, TStarted> {
2
2
  prepare(): Promise<TPrepared>;
3
3
  countdown(): Promise<void>;
4
+ /**
5
+ * Tear down a countdown that is still running when `prepare` fails under
6
+ * it. Must make the pending `countdown()` promise settle — it is awaited
7
+ * afterwards so the teardown finishes before the prepare failure surfaces.
8
+ */
9
+ cancelCountdown(): void;
4
10
  activate(prepared: TPrepared): Promise<TStarted>;
5
11
  onActivated?(): void;
6
12
  }
7
13
 
8
14
  /**
9
- * Keep setup work ahead of the numeric countdown so zero is a media boundary,
10
- * not the beginning of another asynchronous setup pipeline.
15
+ * Show the countdown immediately and run setup work underneath it, so the
16
+ * user never waits on a blank pre-countdown stall. Activation still waits for
17
+ * both: zero is a media boundary only after preparation has succeeded, and a
18
+ * prepare that outlasts the countdown extends the wait rather than starting
19
+ * capture early.
11
20
  */
12
21
  export async function prepareRewindRecordingStart<TPrepared, TStarted>(
13
22
  phases: RewindRecordingStartPhases<TPrepared, TStarted>,
14
23
  ): Promise<TStarted> {
15
- const prepared = await phases.prepare();
16
- await phases.countdown();
24
+ const preparedPromise = phases.prepare();
25
+ const countdownPromise = phases.countdown();
26
+ // Observe the countdown early: a cancel while prepare is still settling
27
+ // below must not fire as an unhandled rejection. It is re-awaited with real
28
+ // handling once prepare's outcome is known.
29
+ void countdownPromise.catch(() => {});
30
+ let prepared: TPrepared;
31
+ try {
32
+ prepared = await preparedPromise;
33
+ } catch (err) {
34
+ phases.cancelCountdown();
35
+ // Wait for the countdown teardown so no orphaned window outlives the
36
+ // failure, but surface the prepare error, not the induced cancel.
37
+ await countdownPromise.catch(() => {});
38
+ throw err;
39
+ }
40
+ await countdownPromise;
17
41
  const started = await phases.activate(prepared);
18
42
  phases.onActivated?.();
19
43
  return started;
@@ -14,10 +14,10 @@ import { MeetingNotification } from "./overlays/meeting-notification";
14
14
  import { MeetingNub } from "./overlays/meeting-nub";
15
15
  import { MonitorPicker } from "./overlays/monitor-picker";
16
16
  import { Preparing } from "./overlays/preparing";
17
- import { RecordingPill } from "./overlays/recording-pill";
17
+ import { RecordingPill } from "./overlays/record-pill";
18
+ import { MeetingPill } from "./overlays/recording-pill";
18
19
  import { RegionGuideEditor, RegionGuides } from "./overlays/region-guides";
19
20
  import { RegionRecordBorder } from "./overlays/region-record-border";
20
- import { Toolbar } from "./overlays/toolbar";
21
21
 
22
22
  // Imports styles.css itself, into a lower cascade layer — see tailwind.css.
23
23
  import "./tailwind.css";
@@ -49,7 +49,7 @@ function pickRoute(route: string): React.ReactElement {
49
49
  case "countdown":
50
50
  return <Countdown />;
51
51
  case "toolbar":
52
- return <Toolbar />;
52
+ return <RecordingPill />;
53
53
  case "bubble":
54
54
  return <Bubble />;
55
55
  case "finalizing":
@@ -63,7 +63,7 @@ function pickRoute(route: string): React.ReactElement {
63
63
  case "flow-bar":
64
64
  return <FlowBar />;
65
65
  case "recording-pill":
66
- return <RecordingPill />;
66
+ return <MeetingPill />;
67
67
  case "region-guides":
68
68
  return <RegionGuides />;
69
69
  case "region-guides-editor":