@agent-native/core 0.136.4 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
  5. package/corpus/templates/clips/app/components/import-menu.tsx +109 -0
  6. package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
  7. package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
  8. package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
  9. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
  10. package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
  11. package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
  13. package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
  14. package/corpus/templates/clips/desktop/src/app.tsx +81 -30
  15. package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
  16. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
  17. package/corpus/templates/clips/server/plugins/auth.ts +9 -0
  18. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
  19. package/corpus/templates/content/actions/_database-utils.ts +6 -0
  20. package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
  22. package/corpus/templates/content/actions/configure-document-property.ts +113 -32
  23. package/corpus/templates/content/actions/delete-content-database.ts +5 -7
  24. package/corpus/templates/content/actions/delete-document-property.ts +109 -62
  25. package/corpus/templates/content/actions/delete-document.ts +178 -36
  26. package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
  27. package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
  28. package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
  29. package/corpus/templates/content/actions/remove-database-items.ts +13 -0
  30. package/corpus/templates/content/actions/set-document-property.ts +69 -1
  31. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
  32. package/corpus/templates/content/parity/matrix.md +1 -1
  33. package/corpus/templates/content/parity/matrix.ts +1 -0
  34. package/corpus/templates/content/server/db/schema.ts +27 -0
  35. package/corpus/templates/content/server/plugins/db.ts +24 -0
  36. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
  37. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
  38. package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
  39. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
  40. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
  41. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
  42. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
  43. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
  44. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
  45. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  46. package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
  47. package/corpus/templates/design/app/i18n-data.ts +8 -0
  48. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
  49. package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
  50. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
  51. package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
  52. package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
  53. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
  54. package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
  55. package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
  56. package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
  57. package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
  58. package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
  59. package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
  60. package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
  61. package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
  62. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
  63. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
  64. package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
  65. package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
  66. package/corpus/templates/factory/.env.example +17 -0
  67. package/corpus/templates/factory/AGENTS.md +27 -17
  68. package/corpus/templates/factory/README.md +19 -9
  69. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
  70. package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
  71. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
  72. package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
  73. package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
  74. package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
  75. package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
  76. package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
  77. package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
  78. package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
  79. package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
  80. package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
  81. package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
  82. package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
  83. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
  84. package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
  85. package/corpus/templates/factory/app/routes/factory.tsx +462 -9
  86. package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
  87. package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
  88. package/corpus/templates/factory/netlify.toml +2 -2
  89. package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
  90. package/corpus/templates/factory/server/connectors/slack.ts +133 -5
  91. package/corpus/templates/factory/server/db/schema.ts +6 -0
  92. package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
  93. package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
  94. package/corpus/templates/factory/server/plugins/auth.ts +1 -0
  95. package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
  96. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
  97. package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
  98. package/corpus/templates/factory/server/triage/contracts.ts +14 -1
  99. package/corpus/templates/factory/server/triage/github-client.ts +404 -0
  100. package/corpus/templates/factory/server/triage/metadata.ts +44 -0
  101. package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
  102. package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
  103. package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
  104. package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
  105. package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
  106. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
  107. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
  108. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
  109. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
  110. package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
  111. package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
  112. package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
  113. package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
  114. package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
  115. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
  116. package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
  117. package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
  118. package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
  119. package/corpus/templates/plan/shared/plan-content.ts +17 -2
  120. package/dist/cli/skills-content/canvas.d.ts +3 -3
  121. package/dist/cli/skills-content/canvas.js +16 -5
  122. package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
  123. package/dist/cli/skills-content/visual-plan-skill.js +27 -0
  124. package/dist/cli/skills-content/wireframe.d.ts +2 -2
  125. package/dist/cli/skills-content/wireframe.js +7 -0
  126. package/dist/client/AgentPanel.js +23 -8
  127. package/dist/client/api-path.d.ts +6 -0
  128. package/dist/client/api-path.js +55 -0
  129. package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
  130. package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
  131. package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
  132. package/dist/client/onboarding/first-run-enabled.js +9 -0
  133. package/dist/client/onboarding/index.d.ts +2 -1
  134. package/dist/client/onboarding/index.js +1 -0
  135. package/dist/client/onboarding/use-onboarding.d.ts +6 -1
  136. package/dist/client/onboarding/use-onboarding.js +32 -1
  137. package/dist/client/route-state.js +10 -0
  138. package/dist/client/settings/SecretsSection.js +32 -8
  139. package/dist/collab/struct-routes.d.ts +1 -1
  140. package/dist/connections/catalog.d.ts +4 -4
  141. package/dist/connections/catalog.js +3 -3
  142. package/dist/db/index.d.ts +1 -1
  143. package/dist/db/index.js +1 -1
  144. package/dist/localization/default-messages.d.ts +5 -0
  145. package/dist/localization/default-messages.js +5 -0
  146. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  147. package/dist/observability/routes.d.ts +3 -3
  148. package/dist/onboarding/app-profile.d.ts +4 -0
  149. package/dist/onboarding/app-profile.js +378 -0
  150. package/dist/onboarding/index.d.ts +2 -1
  151. package/dist/onboarding/index.js +1 -0
  152. package/dist/onboarding/plugin.d.ts +2 -0
  153. package/dist/onboarding/plugin.js +21 -1
  154. package/dist/onboarding/types.d.ts +19 -0
  155. package/dist/progress/routes.d.ts +1 -1
  156. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  157. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  158. package/dist/secrets/routes.d.ts +2 -2
  159. package/dist/secrets/routes.js +41 -17
  160. package/dist/server/auth.js +21 -1
  161. package/dist/server/better-auth-instance.d.ts +3 -2
  162. package/dist/server/better-auth-instance.js +9 -7
  163. package/dist/server/email-markdown.js +3 -8
  164. package/dist/server/transcribe-voice.d.ts +1 -1
  165. package/dist/shared/first-run-onboarding.d.ts +3 -0
  166. package/dist/shared/first-run-onboarding.js +3 -0
  167. package/dist/triggers/index.d.ts +3 -0
  168. package/dist/triggers/index.js +5 -0
  169. package/docs/content/cross-app-sso.mdx +26 -0
  170. package/package.json +1 -1
@@ -229,6 +229,17 @@ export interface StartParams {
229
229
  * or turns the camera off).
230
230
  */
231
231
  preAcquiredCameraStream?: MediaStream | null;
232
+ /**
233
+ * Live screen capture handed over by the session this start is replacing
234
+ * (see `RestartHandoff`). Present only on a restart.
235
+ *
236
+ * Ownership is the OPPOSITE of `preAcquiredCameraStream`: the popover keeps
237
+ * the camera, but these streams belong to the recorder, so this session
238
+ * stops them on its own stop/cancel exactly as if it had acquired them.
239
+ */
240
+ preAcquiredDisplayStream?: MediaStream | null;
241
+ /** Live microphone capture handed over on restart. See `preAcquiredDisplayStream`. */
242
+ preAcquiredAudioStream?: MediaStream | null;
232
243
  }
233
244
 
234
245
  const REWIND_CLIP_ORIGINS_KEY = "clips.rewindClipOrigins.v1";
@@ -286,11 +297,27 @@ export function forgetRewindClipOrigin(recordingId: string): void {
286
297
  writeRewindClipOrigins(origins);
287
298
  }
288
299
 
300
+ export const RESTART_CAPTURE_ENDED_MESSAGE =
301
+ "Screen sharing ended — start a new recording.";
302
+
303
+ /**
304
+ * Live capture streams a discarded session hands to its replacement so the
305
+ * retake never calls `getDisplayMedia` again — a Tauri event listener carries
306
+ * no user activation, so re-acquiring here would always fail.
307
+ */
308
+ export interface RestartHandoff {
309
+ /** Null when the backend re-acquires capture natively (no user gesture needed). */
310
+ displayStream: MediaStream | null;
311
+ audioStream: MediaStream | null;
312
+ }
313
+
289
314
  export interface RecorderHandle {
290
315
  /** Stop the recording and resolve once the server has finalized. */
291
316
  stop(): Promise<RecorderStopResult>;
292
317
  /** Discard the recording without saving. */
293
318
  cancel(): Promise<void>;
319
+ /** Discard this take but keep gesture-bound capture alive for an immediate retake. */
320
+ discardForRestart(): Promise<RestartHandoff>;
294
321
  }
295
322
 
296
323
  export interface RecorderStopResult {
@@ -2327,7 +2354,7 @@ function captureSuspensionReleaser(
2327
2354
  };
2328
2355
  }
2329
2356
 
2330
- function recorderWithCaptureSuspension(
2357
+ export function recorderWithCaptureSuspension(
2331
2358
  handle: RecorderHandle,
2332
2359
  release: () => Promise<void>,
2333
2360
  ): RecorderHandle {
@@ -2337,14 +2364,23 @@ function recorderWithCaptureSuspension(
2337
2364
  // the suspension after the physical recorder has actually torn down.
2338
2365
  const stop = handle.stop.bind(handle);
2339
2366
  const cancel = handle.cancel.bind(handle);
2367
+ const discardForRestart = handle.discardForRestart.bind(handle);
2340
2368
  let stopPromise: Promise<RecorderStopResult> | null = null;
2341
2369
  let cancelPromise: Promise<void> | null = null;
2370
+ let discardPromise: Promise<RestartHandoff> | null = null;
2342
2371
  handle.stop = async () => {
2343
2372
  if (stopPromise) return stopPromise;
2344
2373
  if (cancelPromise) {
2345
2374
  await cancelPromise;
2346
2375
  throw new Error("Recording was already cancelled");
2347
2376
  }
2377
+ // Without this a stop racing a restart reaches the backend's `stopped`
2378
+ // short-circuit, which answers with the discarded take's id — the caller
2379
+ // would publish an aborted recording as a finished one.
2380
+ if (discardPromise) {
2381
+ await discardPromise;
2382
+ throw new Error("Recording was already discarded for a restart");
2383
+ }
2348
2384
  stopPromise = (async () => {
2349
2385
  try {
2350
2386
  return await stop();
@@ -2362,13 +2398,45 @@ function recorderWithCaptureSuspension(
2362
2398
  }
2363
2399
  cancelPromise = (async () => {
2364
2400
  try {
2401
+ // Cancelling after a restart discard means the retake will never take
2402
+ // ownership of the capture handed to it, and nothing else would stop
2403
+ // it. The backend's own cancel decides what else a late cancel owes.
2404
+ const handoff = await discardPromise;
2365
2405
  await cancel();
2406
+ [handoff?.displayStream, handoff?.audioStream].forEach((stream) =>
2407
+ stream?.getTracks().forEach((track) => track.stop()),
2408
+ );
2366
2409
  } finally {
2367
2410
  await release();
2368
2411
  }
2369
2412
  })();
2370
2413
  return cancelPromise;
2371
2414
  };
2415
+ handle.discardForRestart = async () => {
2416
+ if (discardPromise) return discardPromise;
2417
+ if (stopPromise) {
2418
+ await stopPromise;
2419
+ throw new Error("Recording was already stopped");
2420
+ }
2421
+ if (cancelPromise) {
2422
+ await cancelPromise;
2423
+ throw new Error("Recording was already cancelled");
2424
+ }
2425
+ discardPromise = (async () => {
2426
+ const handoff = await discardForRestart();
2427
+ // The discard succeeded and the caller now owns live capture. Letting a
2428
+ // failed lease release reject in its place would strand those tracks
2429
+ // with nobody holding a reference to stop them.
2430
+ await release().catch((err) =>
2431
+ console.error(
2432
+ "[clips-recorder] capture suspension release failed after restart discard:",
2433
+ err,
2434
+ ),
2435
+ );
2436
+ return handoff;
2437
+ })();
2438
+ return discardPromise;
2439
+ };
2372
2440
  return handle;
2373
2441
  }
2374
2442
 
@@ -2569,6 +2637,7 @@ async function tryStartRewindFullscreenRecording(
2569
2637
  let stopped = false;
2570
2638
  let stopPromise: Promise<RecorderStopResult> | null = null;
2571
2639
  let cancelPromise: Promise<void> | null = null;
2640
+ let discardPromise: Promise<RestartHandoff> | null = null;
2572
2641
  let stateUnlistens: UnlistenFn[] = [];
2573
2642
  let tickHandle: ReturnType<typeof setInterval> | null = null;
2574
2643
  let pausedAt: number | null = null;
@@ -2601,9 +2670,56 @@ async function tryStartRewindFullscreenRecording(
2601
2670
  stateUnlistens = [];
2602
2671
  };
2603
2672
 
2673
+ // End the whole recording session. `hide_overlays` destroys the camera bubble
2674
+ // along with the recording chrome, and clearing the recording state releases
2675
+ // the popover's blur auto-hide — both wrong between the two takes of a
2676
+ // restart, which is why `discardTake` only runs this for a real cancel.
2677
+ const endSession = async () => {
2678
+ await invoke("hide_overlays").catch(() => {});
2679
+ await clearRecordingState();
2680
+ };
2681
+
2682
+ const discardTake = async (forRestart: boolean) => {
2683
+ stopped = true;
2684
+ cleanupUi();
2685
+ transcriptionAborted = true;
2686
+ const transcriptionTornDown = transcriptionCapture
2687
+ ?.cancel()
2688
+ .catch((err) => {
2689
+ console.warn("[clips-recorder] transcription cancel failed:", err);
2690
+ });
2691
+ await invoke("rewind_clip_cancel").catch(() => {});
2692
+ audioCue.cleanup();
2693
+ if (forRestart) {
2694
+ await invoke("hide_recording_chrome").catch(() => {});
2695
+ } else {
2696
+ await endSession();
2697
+ }
2698
+ if (!localOnly && id) {
2699
+ forgetRewindClipOrigin(id);
2700
+ await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
2701
+ () => {},
2702
+ );
2703
+ }
2704
+ // The transcription engine is process-global, so this stop must land
2705
+ // before the replacement session starts it again.
2706
+ if (forRestart) await transcriptionTornDown;
2707
+ };
2708
+
2604
2709
  const handle: RecorderHandle = {
2605
2710
  async stop() {
2606
2711
  if (stopPromise) return stopPromise;
2712
+ // This handle runs unwrapped (the Rewind producer holds no capture
2713
+ // suspension lease), so the terminal-transition guards live here.
2714
+ // Answering with `id` would publish a take that was already trashed.
2715
+ if (cancelPromise) {
2716
+ await cancelPromise;
2717
+ throw new Error("Recording was already cancelled");
2718
+ }
2719
+ if (discardPromise) {
2720
+ await discardPromise;
2721
+ throw new Error("Recording was already discarded for a restart");
2722
+ }
2607
2723
  if (stopped) return { recordingId: id, viewUrl: `/r/${id}` };
2608
2724
  stopPromise = (async () => {
2609
2725
  stopped = true;
@@ -2727,27 +2843,34 @@ async function tryStartRewindFullscreenRecording(
2727
2843
  },
2728
2844
  async cancel() {
2729
2845
  if (cancelPromise) return cancelPromise;
2846
+ // A cancel landing on an already-discarded take still has to end the
2847
+ // session — the restart skipped that half deliberately, so returning
2848
+ // here would leave the bubble up and the recording state active.
2849
+ if (discardPromise) {
2850
+ cancelPromise = discardPromise.then(endSession);
2851
+ return cancelPromise;
2852
+ }
2730
2853
  if (stopped) return;
2731
- cancelPromise = (async () => {
2732
- stopped = true;
2733
- cleanupUi();
2734
- transcriptionAborted = true;
2735
- void transcriptionCapture?.cancel().catch((err) => {
2736
- console.warn("[clips-recorder] transcription cancel failed:", err);
2737
- });
2738
- await invoke("rewind_clip_cancel").catch(() => {});
2739
- audioCue.cleanup();
2740
- await invoke("hide_overlays").catch(() => {});
2741
- await clearRecordingState();
2742
- if (!localOnly && id) {
2743
- forgetRewindClipOrigin(id);
2744
- await cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
2745
- () => {},
2746
- );
2747
- }
2748
- })();
2854
+ cancelPromise = discardTake(false);
2749
2855
  return cancelPromise;
2750
2856
  },
2857
+ async discardForRestart() {
2858
+ if (discardPromise) return discardPromise;
2859
+ if (cancelPromise) {
2860
+ await cancelPromise;
2861
+ throw new Error("Recording was already cancelled");
2862
+ }
2863
+ if (stopped) {
2864
+ throw new Error("Recording already finished — nothing to restart");
2865
+ }
2866
+ // The Rewind producer re-acquires capture natively, so the retake needs
2867
+ // nothing handed to it.
2868
+ discardPromise = discardTake(true).then(() => ({
2869
+ displayStream: null,
2870
+ audioStream: null,
2871
+ }));
2872
+ return discardPromise;
2873
+ },
2751
2874
  };
2752
2875
 
2753
2876
  pauseQueue = createPauseTransitionQueue({
@@ -3125,6 +3248,7 @@ async function startNativeFullscreenRecording(
3125
3248
  let stopped = false;
3126
3249
  let stopPromise: Promise<RecorderStopResult> | null = null;
3127
3250
  let cancelPromise: Promise<void> | null = null;
3251
+ let discardPromise: Promise<RestartHandoff> | null = null;
3128
3252
  let stateUnlistens: UnlistenFn[] = [];
3129
3253
  let tickHandle: ReturnType<typeof setInterval> | null = null;
3130
3254
  let segmentRotateHandle: ReturnType<typeof setInterval> | null = null;
@@ -3197,6 +3321,65 @@ async function startNativeFullscreenRecording(
3197
3321
  }).catch(() => {});
3198
3322
  }
3199
3323
 
3324
+ // End the whole recording session. `hide_overlays` destroys the camera
3325
+ // bubble window along with the countdown and toolbar, which is wrong between
3326
+ // the two takes of a restart — hence the split with `discardTake`.
3327
+ const endSession = async () => {
3328
+ await invoke("hide_overlays").catch(() => {});
3329
+ };
3330
+
3331
+ const discardTake = async (forRestart: boolean) => {
3332
+ stopped = true;
3333
+ clearSegmentRotator();
3334
+ pauseQueue?.dispose();
3335
+ if (tickHandle) {
3336
+ clearInterval(tickHandle);
3337
+ tickHandle = null;
3338
+ }
3339
+ stateUnlistens.forEach((u) => u());
3340
+ stateUnlistens = [];
3341
+ const transcriptionTornDown = transcriptionCapture
3342
+ ?.cancel()
3343
+ .catch((err) => {
3344
+ console.warn(
3345
+ "[clips-recorder] native transcription cancel failed:",
3346
+ err,
3347
+ );
3348
+ });
3349
+ await localCameraExport?.cancel().catch(() => {});
3350
+ await invoke("native_fullscreen_recording_cancel").catch((err) =>
3351
+ console.warn("[clips-recorder] native fullscreen cancel failed:", err),
3352
+ );
3353
+ if (bubbleCaptureExcluded) {
3354
+ await invoke("set_bubble_capture_excluded", { excluded: false }).catch(
3355
+ () => {},
3356
+ );
3357
+ bubbleCaptureExcluded = false;
3358
+ }
3359
+ if (localOwnsCameraStream) {
3360
+ localCameraStream?.getTracks().forEach((track) => track.stop());
3361
+ }
3362
+ streamCleanups.forEach((cleanup) => cleanup());
3363
+ if (forRestart) {
3364
+ await invoke("hide_recording_chrome").catch(() => {});
3365
+ } else {
3366
+ await endSession();
3367
+ }
3368
+ if (!localOnly && id) {
3369
+ void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
3370
+ (err) => {
3371
+ console.warn(
3372
+ "[clips-recorder] cancelled recording cleanup failed:",
3373
+ err,
3374
+ );
3375
+ },
3376
+ );
3377
+ }
3378
+ // The transcription engine is process-global, so this stop must land
3379
+ // before the replacement session starts it again.
3380
+ if (forRestart) await transcriptionTornDown;
3381
+ };
3382
+
3200
3383
  const handle: RecorderHandle = {
3201
3384
  async stop() {
3202
3385
  if (stopPromise) return stopPromise;
@@ -3427,54 +3610,35 @@ async function startNativeFullscreenRecording(
3427
3610
 
3428
3611
  async cancel() {
3429
3612
  if (cancelPromise) return cancelPromise;
3613
+ // A cancel landing on an already-discarded take still has to end the
3614
+ // session — the restart skipped that half deliberately, so returning
3615
+ // here would leave the camera bubble up.
3616
+ if (discardPromise) {
3617
+ cancelPromise = discardPromise.then(endSession);
3618
+ return cancelPromise;
3619
+ }
3430
3620
  if (stopped) return;
3431
- cancelPromise = (async () => {
3432
- stopped = true;
3433
- clearSegmentRotator();
3434
- pauseQueue?.dispose();
3435
- if (tickHandle) {
3436
- clearInterval(tickHandle);
3437
- tickHandle = null;
3438
- }
3439
- stateUnlistens.forEach((u) => u());
3440
- stateUnlistens = [];
3441
- void transcriptionCapture?.cancel().catch((err) => {
3442
- console.warn(
3443
- "[clips-recorder] native transcription cancel failed:",
3444
- err,
3445
- );
3446
- });
3447
- await localCameraExport?.cancel().catch(() => {});
3448
- await invoke("native_fullscreen_recording_cancel").catch((err) =>
3449
- console.warn(
3450
- "[clips-recorder] native fullscreen cancel failed:",
3451
- err,
3452
- ),
3453
- );
3454
- if (bubbleCaptureExcluded) {
3455
- await invoke("set_bubble_capture_excluded", {
3456
- excluded: false,
3457
- }).catch(() => {});
3458
- bubbleCaptureExcluded = false;
3459
- }
3460
- if (localOwnsCameraStream) {
3461
- localCameraStream?.getTracks().forEach((track) => track.stop());
3462
- }
3463
- streamCleanups.forEach((cleanup) => cleanup());
3464
- await invoke("hide_overlays").catch(() => {});
3465
- if (!localOnly && id) {
3466
- void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
3467
- (err) => {
3468
- console.warn(
3469
- "[clips-recorder] cancelled recording cleanup failed:",
3470
- err,
3471
- );
3472
- },
3473
- );
3474
- }
3475
- })();
3621
+ cancelPromise = discardTake(false);
3476
3622
  return cancelPromise;
3477
3623
  },
3624
+
3625
+ async discardForRestart() {
3626
+ if (discardPromise) return discardPromise;
3627
+ if (cancelPromise) {
3628
+ await cancelPromise;
3629
+ throw new Error("Recording was already cancelled");
3630
+ }
3631
+ if (stopped) {
3632
+ throw new Error("Recording already finished — nothing to restart");
3633
+ }
3634
+ // ScreenCaptureKit is driven from Rust, so the retake re-acquires
3635
+ // capture natively without needing a user gesture.
3636
+ discardPromise = discardTake(true).then(() => ({
3637
+ displayStream: null,
3638
+ audioStream: null,
3639
+ }));
3640
+ return discardPromise;
3641
+ },
3478
3642
  };
3479
3643
 
3480
3644
  pauseQueue = createPauseTransitionQueue({
@@ -3667,12 +3831,58 @@ export async function startRecording(
3667
3831
  }
3668
3832
  }
3669
3833
 
3834
+ /**
3835
+ * Vet the capture a restart inherited. A handed-off track can die between the
3836
+ * restart click and this call — the user may have hit the OS "Stop sharing"
3837
+ * control, or unplugged the microphone.
3838
+ *
3839
+ * The two are not interchangeable. Screen capture cannot be re-acquired here
3840
+ * (this start carries no user activation), so an ended display share is fatal
3841
+ * and has to name itself; re-acquiring would surface an activation error that
3842
+ * blames the wrong thing. A microphone needs no gesture, so an ended one is
3843
+ * simply dropped and picked up again by the normal acquisition path.
3844
+ */
3845
+ export function resolveRestartHandoff(
3846
+ params: StartParams,
3847
+ wantsScreen: boolean,
3848
+ wantsAudio: boolean,
3849
+ ): RestartHandoff {
3850
+ const stopStream = (stream: MediaStream | null) =>
3851
+ stream?.getTracks().forEach((track) => track.stop());
3852
+ const isLive = (stream: MediaStream) =>
3853
+ stream.getTracks().length > 0 &&
3854
+ stream.getTracks().every((track) => track.readyState === "live");
3855
+
3856
+ let displayStream = params.preAcquiredDisplayStream ?? null;
3857
+ let audioStream = params.preAcquiredAudioStream ?? null;
3858
+
3859
+ // Nothing downstream will ever own capture this session did not ask for.
3860
+ if (displayStream && !wantsScreen) {
3861
+ stopStream(displayStream);
3862
+ displayStream = null;
3863
+ }
3864
+ if (audioStream && (!wantsAudio || !isLive(audioStream))) {
3865
+ stopStream(audioStream);
3866
+ audioStream = null;
3867
+ }
3868
+
3869
+ if (displayStream && !isLive(displayStream)) {
3870
+ stopStream(displayStream);
3871
+ stopStream(audioStream);
3872
+ throw new Error(RESTART_CAPTURE_ENDED_MESSAGE);
3873
+ }
3874
+ return { displayStream, audioStream };
3875
+ }
3876
+
3670
3877
  async function startRecordingInner(
3671
3878
  params: StartParams,
3672
3879
  ): Promise<RecorderHandle> {
3673
3880
  const wantsScreen = params.mode !== "camera";
3674
3881
  const wantsCamera = params.mode !== "screen" && params.cameraOn;
3675
3882
  const wantsAudio = params.micOn;
3883
+ // Vetted before anything is acquired so an ended display share cannot strand
3884
+ // a capture-suspension lease behind it.
3885
+ const restartHandoff = resolveRestartHandoff(params, wantsScreen, wantsAudio);
3676
3886
  const wantsSystemAudio = wantsScreen && params.systemAudioOn !== false;
3677
3887
  const wantsRecordedAudio = wantsAudio || wantsSystemAudio;
3678
3888
  const canTranscribeLocally =
@@ -3758,30 +3968,42 @@ async function startRecordingInner(
3758
3968
  requiresMicrophone: wantsAudio,
3759
3969
  });
3760
3970
 
3761
- const displayStreamPromise: Promise<MediaStream> | null = wantsScreen
3762
- ? (() => {
3763
- if (!devSyntheticCapture) {
3764
- // Do not pass displaySurface as an input constraint. Modern runtimes
3765
- // can reject it with "Invalid constraint", and it cannot reliably
3766
- // pre-filter the OS picker anyway; the selected track reports its
3767
- // actual surface through getSettings() after capture starts.
3768
- return navigator.mediaDevices.getDisplayMedia(
3769
- buildDesktopDisplayMediaOptions({
3770
- audio: wantsSystemAudio,
3771
- frameRate: CLOUD_CAPTURE_FRAME_RATE,
3772
- maxWidth: CLOUD_CAPTURE_MAX_WIDTH,
3773
- maxHeight: CLOUD_CAPTURE_MAX_HEIGHT,
3774
- }),
3971
+ // A restart inherits live capture from the take it replaces, so the two
3972
+ // gesture-bound acquisitions are skipped entirely. See `RestartHandoff`.
3973
+ const resumedDisplayStream = restartHandoff.displayStream;
3974
+ const resumedAudioStream = restartHandoff.audioStream;
3975
+ if (resumedDisplayStream || resumedAudioStream) {
3976
+ console.log("[clips-recorder] reusing handed-off capture streams", {
3977
+ display: Boolean(resumedDisplayStream),
3978
+ audio: Boolean(resumedAudioStream),
3979
+ });
3980
+ }
3981
+ const displayStreamPromise: Promise<MediaStream> | null = resumedDisplayStream
3982
+ ? Promise.resolve(resumedDisplayStream)
3983
+ : wantsScreen
3984
+ ? (() => {
3985
+ if (!devSyntheticCapture) {
3986
+ // Do not pass displaySurface as an input constraint. Modern runtimes
3987
+ // can reject it with "Invalid constraint", and it cannot reliably
3988
+ // pre-filter the OS picker anyway; the selected track reports its
3989
+ // actual surface through getSettings() after capture starts.
3990
+ return navigator.mediaDevices.getDisplayMedia(
3991
+ buildDesktopDisplayMediaOptions({
3992
+ audio: wantsSystemAudio,
3993
+ frameRate: CLOUD_CAPTURE_FRAME_RATE,
3994
+ maxWidth: CLOUD_CAPTURE_MAX_WIDTH,
3995
+ maxHeight: CLOUD_CAPTURE_MAX_HEIGHT,
3996
+ }),
3997
+ );
3998
+ }
3999
+ console.warn(
4000
+ "[clips-recorder] using opt-in dev synthetic screen capture; remove localStorage clips:dev-synthetic-capture to use the native picker",
3775
4001
  );
3776
- }
3777
- console.warn(
3778
- "[clips-recorder] using opt-in dev synthetic screen capture; remove localStorage clips:dev-synthetic-capture to use the native picker",
3779
- );
3780
- const syntheticDisplay = createSyntheticScreenStream();
3781
- streamCleanups.push(syntheticDisplay.cleanup);
3782
- return Promise.resolve(syntheticDisplay.stream);
3783
- })()
3784
- : null;
4002
+ const syntheticDisplay = createSyntheticScreenStream();
4003
+ streamCleanups.push(syntheticDisplay.cleanup);
4004
+ return Promise.resolve(syntheticDisplay.stream);
4005
+ })()
4006
+ : null;
3785
4007
  // If the popover handed us a live camera stream from the pre-record
3786
4008
  // preview we reuse it verbatim and SKIP getUserMedia — see the
3787
4009
  // `preAcquiredCameraStream` field doc for the WebKit rationale. This
@@ -3800,9 +4022,11 @@ async function startRecordingInner(
3800
4022
  wantsCamera && !reusedCameraStream
3801
4023
  ? getCameraStreamWithFallback(params.cameraId)
3802
4024
  : null;
3803
- const audioStreamPromise: Promise<MediaStream> | null = wantsAudio
3804
- ? getAudioStreamWithFallback(params.micId, params.micLabel)
3805
- : null;
4025
+ const audioStreamPromise: Promise<MediaStream> | null = resumedAudioStream
4026
+ ? Promise.resolve(resumedAudioStream)
4027
+ : wantsAudio
4028
+ ? getAudioStreamWithFallback(params.micId, params.micLabel)
4029
+ : null;
3806
4030
 
3807
4031
  let captureSuspension: RewindCaptureSuspensionLease;
3808
4032
  try {
@@ -4103,10 +4327,12 @@ async function startRecordingInner(
4103
4327
  }
4104
4328
  };
4105
4329
 
4106
- const stopOwnedStreams = () => {
4107
- [displayStream, audioStream].forEach((stream) =>
4108
- stream?.getTracks().forEach((track) => track.stop()),
4109
- );
4330
+ const stopOwnedStreams = (keepCaptureStreams = false) => {
4331
+ if (!keepCaptureStreams) {
4332
+ [displayStream, audioStream].forEach((stream) =>
4333
+ stream?.getTracks().forEach((track) => track.stop()),
4334
+ );
4335
+ }
4110
4336
  streamCleanups.forEach((cleanup) => cleanup());
4111
4337
  if (!popoverOwnsCamera) {
4112
4338
  bubbleCameraStream?.getTracks().forEach((track) => track.stop());
@@ -4119,6 +4345,25 @@ async function startRecordingInner(
4119
4345
  );
4120
4346
  };
4121
4347
 
4348
+ const discardTake = async (
4349
+ keepCaptureStreams: boolean,
4350
+ ): Promise<RestartHandoff> => {
4351
+ // Synthetic dev capture belongs to `streamCleanups`, which always runs
4352
+ // here, so it is never handed over — the retake recreates it.
4353
+ const handsOff = keepCaptureStreams && !devSyntheticCapture;
4354
+ stopped = true;
4355
+ if (tickHandle) clearInterval(tickHandle);
4356
+ stateUnlistens.forEach((unlisten) => unlisten());
4357
+ stateUnlistens = [];
4358
+ await localExport.cancel();
4359
+ detachCombinedStream();
4360
+ stopOwnedStreams(handsOff);
4361
+ await hideChrome();
4362
+ return handsOff
4363
+ ? { displayStream, audioStream }
4364
+ : { displayStream: null, audioStream: null };
4365
+ };
4366
+
4122
4367
  const handle: RecorderHandle = {
4123
4368
  async stop() {
4124
4369
  if (stopped) {
@@ -4157,14 +4402,14 @@ async function startRecordingInner(
4157
4402
 
4158
4403
  async cancel() {
4159
4404
  if (stopped) return;
4160
- stopped = true;
4161
- if (tickHandle) clearInterval(tickHandle);
4162
- stateUnlistens.forEach((unlisten) => unlisten());
4163
- stateUnlistens = [];
4164
- await localExport.cancel();
4165
- detachCombinedStream();
4166
- stopOwnedStreams();
4167
- await hideChrome();
4405
+ await discardTake(false);
4406
+ },
4407
+
4408
+ async discardForRestart() {
4409
+ if (stopped) {
4410
+ throw new Error("Recording already finished — nothing to restart");
4411
+ }
4412
+ return discardTake(true);
4168
4413
  },
4169
4414
  };
4170
4415
 
@@ -4885,68 +5130,101 @@ async function startRecordingInner(
4885
5130
  }
4886
5131
  };
4887
5132
 
4888
- const handle: RecorderHandle = {
4889
- stop: singleFlight(performStop),
4890
-
4891
- async cancel() {
4892
- if (stopped) return;
4893
- stopped = true;
4894
- if (tickHandle) clearInterval(tickHandle);
4895
- stateUnlistens.forEach((u) => u());
4896
- stateUnlistens = [];
4897
- void transcriptionCapture?.cancel().catch((err) => {
5133
+ const discardTake = async (
5134
+ keepCaptureStreams: boolean,
5135
+ ): Promise<RestartHandoff> => {
5136
+ // Synthetic dev capture is owned by `streamCleanups`, which always runs
5137
+ // here — so there is nothing to hand over, and the next session recreates
5138
+ // it. It needs no user gesture either.
5139
+ const handsOff = keepCaptureStreams && !devSyntheticCapture;
5140
+ stopped = true;
5141
+ if (tickHandle) clearInterval(tickHandle);
5142
+ stateUnlistens.forEach((u) => u());
5143
+ stateUnlistens = [];
5144
+ const transcriptionTornDown = transcriptionCapture
5145
+ ?.cancel()
5146
+ .catch((err) => {
4898
5147
  console.warn("[clips-recorder] transcription cancel failed:", err);
4899
5148
  });
4900
- // Remove MediaRecorder's data handler so any final `ondataavailable`
4901
- // from the stop() below doesn't push a new Blob into `inflight`
4902
- // after we've decided to discard everything.
4903
- recorder.ondataavailable = null;
4904
- try {
4905
- if (recorder.state !== "inactive") recorder.stop();
4906
- } catch {
4907
- // ignore
4908
- }
4909
- // Drop stream track references — same rationale as in stop(). This
4910
- // detaches only from the MediaStreams; the originating streams own the
4911
- // tracks and we stop them below.
4912
- try {
4913
- uploadCombined
4914
- .getTracks()
4915
- .forEach((t) => uploadCombined.removeTrack(t));
4916
- combined.getTracks().forEach((t) => combined.removeTrack(t));
4917
- } catch {
4918
- // ignore
4919
- }
4920
- // Stop the streams WE own. Camera stays alive when the popover
4921
- // owns it (see stop() for the same split).
5149
+ // Remove MediaRecorder's data handler so any final `ondataavailable`
5150
+ // from the stop() below doesn't push a new Blob into `inflight`
5151
+ // after we've decided to discard everything.
5152
+ recorder.ondataavailable = null;
5153
+ try {
5154
+ if (recorder.state !== "inactive") recorder.stop();
5155
+ // coercion-ok: the take is already discarded — a MediaRecorder that refuses to stop has nothing left to report
5156
+ } catch {
5157
+ // ignore
5158
+ }
5159
+ // Drop stream track references same rationale as in stop(). This
5160
+ // detaches only from the MediaStreams; the originating streams own the
5161
+ // tracks and we stop them below.
5162
+ try {
5163
+ uploadCombined
5164
+ .getTracks()
5165
+ .forEach((t) => uploadCombined.removeTrack(t));
5166
+ combined.getTracks().forEach((t) => combined.removeTrack(t));
5167
+ // coercion-ok: detaching tracks is bookkeeping; the streams below are stopped or handed over either way
5168
+ } catch {
5169
+ // ignore
5170
+ }
5171
+ // Stop the streams WE own. Camera stays alive when the popover
5172
+ // owns it (see stop() for the same split). On a restart the display and
5173
+ // mic streams also stay alive and become the next session's to own.
5174
+ if (!handsOff) {
4922
5175
  [displayStream, audioStream].forEach((s) =>
4923
5176
  s?.getTracks().forEach((t) => t.stop()),
4924
5177
  );
4925
- streamCleanups.forEach((cleanup) => cleanup());
4926
- if (!popoverOwnsCamera) {
4927
- bubbleCameraStream?.getTracks().forEach((t) => t.stop());
4928
- }
4929
- // Drop remaining in-flight chunk Blobs aggressively. Their fetches
4930
- // will still settle (we don't AbortController them — dev server is
4931
- // local and won't hang long) but we no longer hold references to the
4932
- // Blobs via this Set. Combined with the `ondataavailable = null`
4933
- // above, this guarantees no new Blobs latch on during the stop.
4934
- inflight.clear();
4935
- await invoke("hide_recording_chrome").catch(() => {});
4936
- // Tell the server to abort the partial recording (drops chunks from
4937
- // application_state, flips the recording row to 'failed'), then trash
4938
- // it. This is best-effort background cleanup: redo/cancel must release
4939
- // the desktop chrome immediately even if the server is slow or offline.
4940
- if (id) {
4941
- void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
4942
- (err) => {
4943
- console.warn("[clips-recorder] abort failed (non-fatal):", err);
4944
- },
4945
- );
5178
+ }
5179
+ streamCleanups.forEach((cleanup) => cleanup());
5180
+ if (!popoverOwnsCamera) {
5181
+ bubbleCameraStream?.getTracks().forEach((t) => t.stop());
5182
+ }
5183
+ // Drop remaining in-flight chunk Blobs aggressively. Their fetches
5184
+ // will still settle (we don't AbortController them dev server is
5185
+ // local and won't hang long) but we no longer hold references to the
5186
+ // Blobs via this Set. Combined with the `ondataavailable = null`
5187
+ // above, this guarantees no new Blobs latch on during the stop.
5188
+ inflight.clear();
5189
+ await invoke("hide_recording_chrome").catch(() => {});
5190
+ // Tell the server to abort the partial recording (drops chunks from
5191
+ // application_state, flips the recording row to 'failed'), then trash
5192
+ // it. This is best-effort background cleanup: redo/cancel must release
5193
+ // the desktop chrome immediately even if the server is slow or offline.
5194
+ if (id) {
5195
+ void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
5196
+ (err) => {
5197
+ console.warn("[clips-recorder] abort failed (non-fatal):", err);
5198
+ },
5199
+ );
5200
+ }
5201
+ await deleteBrowserRecordingBackup(id).catch((err) => {
5202
+ console.warn("[clips-recorder] local backup cleanup failed:", err);
5203
+ });
5204
+ // The transcription engine is process-global, so this stop must land
5205
+ // before the replacement session starts it again.
5206
+ if (keepCaptureStreams) await transcriptionTornDown;
5207
+ return handsOff
5208
+ ? { displayStream, audioStream }
5209
+ : { displayStream: null, audioStream: null };
5210
+ };
5211
+
5212
+ const handle: RecorderHandle = {
5213
+ stop: singleFlight(performStop),
5214
+
5215
+ async cancel() {
5216
+ if (stopped) return;
5217
+ await discardTake(false);
5218
+ },
5219
+
5220
+ // A restart reuses the same capture. `getDisplayMedia` cannot run from
5221
+ // the Tauri event that carries the toolbar click, so the streams have to
5222
+ // survive the take that is being thrown away.
5223
+ async discardForRestart() {
5224
+ if (stopped) {
5225
+ throw new Error("Recording already finished — nothing to restart");
4946
5226
  }
4947
- await deleteBrowserRecordingBackup(id).catch((err) => {
4948
- console.warn("[clips-recorder] local backup cleanup failed:", err);
4949
- });
5227
+ return discardTake(true);
4950
5228
  },
4951
5229
  };
4952
5230