@agent-native/core 0.161.23 → 0.163.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 (206) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +1 -1
  3. package/corpus/templates/analytics/.agents/skills/sharing/SKILL.md +27 -2
  4. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +18 -2
  5. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +1 -1
  6. package/corpus/templates/assets/.agents/skills/sharing/SKILL.md +27 -2
  7. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +1 -1
  8. package/corpus/templates/brain/.agents/skills/sharing/SKILL.md +27 -2
  9. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +1 -1
  10. package/corpus/templates/calendar/.agents/skills/sharing/SKILL.md +27 -2
  11. package/corpus/templates/calendar/server/handlers/google-auth.ts +1 -8
  12. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  13. package/corpus/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  14. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +88 -35
  15. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +36 -1
  16. package/corpus/templates/clips/app/i18n/en-US.ts +4 -3
  17. package/corpus/templates/clips/app/lib/camera-composite.ts +15 -1
  18. package/corpus/templates/clips/changelog/2026-08-18-clips-no-longer-crashes-when-a-camera-bubble-starts-at-a-tin.md +6 -0
  19. package/corpus/templates/clips/desktop/src/app.tsx +30 -40
  20. package/corpus/templates/clips/desktop/src/components/MicOffConfirmation.tsx +47 -0
  21. package/corpus/templates/clips/desktop/src/styles.css +110 -0
  22. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +114 -7
  23. package/corpus/templates/clips/shared/recording-capture.ts +18 -13
  24. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +1 -1
  25. package/corpus/templates/content/.agents/skills/sharing/SKILL.md +27 -2
  26. package/corpus/templates/content/actions/create-content-database.ts +1 -1
  27. package/corpus/templates/content/actions/create-document.ts +1 -1
  28. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +3 -5
  29. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +0 -3
  30. package/corpus/templates/content/app/components/editor/NotionConflictBanner.tsx +0 -4
  31. package/corpus/templates/content/app/components/editor/NotionSyncBar.tsx +4 -1
  32. package/corpus/templates/content/app/hooks/use-notion.ts +17 -15
  33. package/corpus/templates/crm/.agents/skills/real-time-sync/SKILL.md +1 -1
  34. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +27 -2
  35. package/corpus/templates/design/.agents/skills/real-time-sync/SKILL.md +1 -1
  36. package/corpus/templates/design/.agents/skills/sharing/SKILL.md +27 -2
  37. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +6 -1
  38. package/corpus/templates/dispatch/.agents/skills/real-time-sync/SKILL.md +1 -1
  39. package/corpus/templates/dispatch/.agents/skills/sharing/SKILL.md +27 -2
  40. package/corpus/templates/factory/.agents/skills/real-time-sync/SKILL.md +1 -1
  41. package/corpus/templates/factory/.agents/skills/sharing/SKILL.md +27 -2
  42. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +1 -1
  43. package/corpus/templates/forms/.agents/skills/sharing/SKILL.md +27 -2
  44. package/corpus/templates/macros/.agents/skills/real-time-sync/SKILL.md +1 -1
  45. package/corpus/templates/macros/.agents/skills/sharing/SKILL.md +27 -2
  46. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +1 -1
  47. package/corpus/templates/mail/.agents/skills/sharing/SKILL.md +27 -2
  48. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +18 -3
  49. package/corpus/templates/mail/server/handlers/google-auth.ts +1 -8
  50. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +1 -1
  51. package/corpus/templates/plan/.agents/skills/sharing/SKILL.md +27 -2
  52. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +4 -0
  53. package/corpus/templates/slides/.agents/skills/real-time-sync/SKILL.md +1 -1
  54. package/corpus/templates/slides/.agents/skills/sharing/SKILL.md +27 -2
  55. package/corpus/templates/slides/actions/_await-fit-check.ts +30 -5
  56. package/corpus/templates/slides/actions/add-slide.ts +6 -2
  57. package/corpus/templates/slides/actions/create-deck.ts +2 -1
  58. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +3 -3
  59. package/corpus/templates/slides/app/components/editor/GoogleSlidesReferenceImport.tsx +3 -3
  60. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +3 -1
  61. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -1
  62. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +1 -1
  63. package/corpus/templates/slides/app/lib/create-deck-generation.ts +1 -0
  64. package/corpus/templates/slides/app/pages/DeckEditor.tsx +35 -2
  65. package/corpus/templates/slides/app/routes/p.$id.tsx +3 -1
  66. package/corpus/templates/slides/changelog/2026-08-18-generated-slides-preserve-speaker-notes.md +6 -0
  67. package/corpus/templates/slides/server/handlers/assets.ts +23 -12
  68. package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +15 -6
  69. package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +15 -6
  70. package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +34 -19
  71. package/corpus/templates/slides/server/handlers/request-auth-context.ts +1 -0
  72. package/corpus/templates/slides/server/handlers/share.ts +1 -1
  73. package/corpus/templates/slides/server/plugins/agent-chat.ts +1 -1
  74. package/corpus/templates/slides/server/routes/api/deck-agent-context.json.get.ts +1 -1
  75. package/corpus/templates/slides/server/routes/api/exports/[filename].get.ts +9 -3
  76. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -5
  77. package/corpus/templates/slides/server/routes/api/exports/html.post.ts +9 -7
  78. package/corpus/templates/slides/server/routes/api/exports/pptx.post.ts +9 -7
  79. package/corpus/templates/slides/shared/api.ts +7 -1
  80. package/corpus/templates/tasks/.agents/skills/real-time-sync/SKILL.md +1 -1
  81. package/corpus/templates/tasks/.agents/skills/sharing/SKILL.md +27 -2
  82. package/dist/agent/engine/translate-anthropic.js +27 -2
  83. package/dist/agent/production-agent.d.ts +2 -2
  84. package/dist/agent/production-agent.js +16 -8
  85. package/dist/agent/thread-data-builder.d.ts +1 -0
  86. package/dist/agent/thread-data-builder.js +6 -4
  87. package/dist/agent/tool-approval-store.d.ts +10 -1
  88. package/dist/agent/tool-approval-store.js +19 -2
  89. package/dist/agent/types.d.ts +9 -0
  90. package/dist/app-config/schema.d.ts +1 -0
  91. package/dist/app-config/workspace.d.ts +1 -0
  92. package/dist/app-config/workspace.js +8 -0
  93. package/dist/chat-threads/schema.d.ts +2 -2
  94. package/dist/client/AssistantChat.js +14 -6
  95. package/dist/client/EnvironmentBadge.d.ts +15 -0
  96. package/dist/client/EnvironmentBadge.js +79 -0
  97. package/dist/client/MultiTabAssistantChat.js +73 -17
  98. package/dist/client/agent-chat-adapter.js +11 -1
  99. package/dist/client/api-path.js +14 -1
  100. package/dist/client/app-providers.js +2 -1
  101. package/dist/client/chat/tool-call-display.d.ts +12 -2
  102. package/dist/client/chat/tool-call-display.js +17 -7
  103. package/dist/client/chat/widgets/WorkspaceFileWidget.js +1 -1
  104. package/dist/client/chat/widgets/builtin-tool-renderers.d.ts +6 -0
  105. package/dist/client/chat/widgets/builtin-tool-renderers.js +19 -0
  106. package/dist/client/onboarding/FirstRunOnboarding.js +46 -32
  107. package/dist/client/onboarding/use-onboarding.d.ts +8 -1
  108. package/dist/client/onboarding/use-onboarding.js +22 -7
  109. package/dist/client/org/TeamPage.js +15 -2
  110. package/dist/client/org/hooks.d.ts +2 -0
  111. package/dist/client/org/hooks.js +12 -0
  112. package/dist/client/org/index.d.ts +2 -2
  113. package/dist/client/org/index.js +1 -1
  114. package/dist/client/session-replay.js +58 -15
  115. package/dist/client/settings/SettingsTabsPage.js +19 -6
  116. package/dist/client/sharing/ShareButton.js +43 -18
  117. package/dist/client/sharing/useShareButtonController.d.ts +12 -2
  118. package/dist/client/sharing/useShareButtonController.js +38 -8
  119. package/dist/client/sse-event-processor.d.ts +7 -0
  120. package/dist/client/sse-event-processor.js +4 -1
  121. package/dist/client/ui/index.d.ts +1 -0
  122. package/dist/client/ui/index.js +1 -0
  123. package/dist/client/use-action.js +26 -0
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/config.d.ts +15 -0
  126. package/dist/config.js +53 -0
  127. package/dist/dashboard-storage/schema.d.ts +2 -2
  128. package/dist/data-programs/schema.d.ts +2 -2
  129. package/dist/extensions/fetch-tool.js +8 -2
  130. package/dist/extensions/schema.d.ts +2 -2
  131. package/dist/extensions/store.js +3 -0
  132. package/dist/localization/core-messages/ar-SA.js +1 -0
  133. package/dist/localization/core-messages/de-DE.js +1 -0
  134. package/dist/localization/core-messages/en-US.d.ts +1 -0
  135. package/dist/localization/core-messages/en-US.js +1 -0
  136. package/dist/localization/core-messages/es-ES.js +1 -0
  137. package/dist/localization/core-messages/fr-FR.js +1 -0
  138. package/dist/localization/core-messages/hi-IN.js +1 -0
  139. package/dist/localization/core-messages/ja-JP.js +1 -0
  140. package/dist/localization/core-messages/ko-KR.js +1 -0
  141. package/dist/localization/core-messages/pt-BR.js +1 -0
  142. package/dist/localization/core-messages/zh-CN.js +1 -0
  143. package/dist/localization/core-messages/zh-TW.js +1 -0
  144. package/dist/localization/core-messages.d.ts +1 -0
  145. package/dist/localization/default-messages.d.ts +4 -0
  146. package/dist/localization/default-messages.js +4 -0
  147. package/dist/mcp/embed-app.js +1 -1
  148. package/dist/notifications/routes.d.ts +1 -1
  149. package/dist/org/handlers.d.ts +6 -1
  150. package/dist/org/handlers.js +43 -0
  151. package/dist/org/index.d.ts +3 -3
  152. package/dist/org/index.js +2 -2
  153. package/dist/org/migrations.js +33 -0
  154. package/dist/org/plugin.js +10 -1
  155. package/dist/org/schema.d.ts +315 -0
  156. package/dist/org/schema.js +20 -1
  157. package/dist/org/types.d.ts +3 -0
  158. package/dist/org/workspace-app-access.d.ts +10 -0
  159. package/dist/org/workspace-app-access.js +179 -0
  160. package/dist/progress/routes.d.ts +1 -1
  161. package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
  162. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  163. package/dist/provider-api/index.d.ts +10 -0
  164. package/dist/provider-api/index.js +45 -9
  165. package/dist/resources/handlers.d.ts +1 -1
  166. package/dist/secrets/routes.d.ts +9 -9
  167. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  168. package/dist/server/auth.js +24 -4
  169. package/dist/server/poll.js +13 -3
  170. package/dist/server/sentry.d.ts +1 -1
  171. package/dist/sharing/access.d.ts +2 -2
  172. package/dist/sharing/access.js +56 -3
  173. package/dist/sharing/actions/list-resource-shares.d.ts +2 -0
  174. package/dist/sharing/actions/list-resource-shares.js +26 -1
  175. package/dist/sharing/actions/share-resource.d.ts +1 -1
  176. package/dist/sharing/actions/share-resource.js +23 -4
  177. package/dist/sharing/actions/unshare-resource.d.ts +1 -1
  178. package/dist/sharing/actions/unshare-resource.js +1 -1
  179. package/dist/sharing/registry.d.ts +12 -0
  180. package/dist/sharing/schema.d.ts +4 -4
  181. package/dist/sharing/schema.js +2 -2
  182. package/dist/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  183. package/dist/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  184. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  185. package/dist/templates/default/.agents/skills/sharing/SKILL.md +27 -2
  186. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  187. package/dist/templates/workspace-core/.agents/skills/sharing/SKILL.md +27 -2
  188. package/dist/vite/client.d.ts +5 -0
  189. package/dist/vite/client.js +64 -2
  190. package/dist/workspace-connections/actions/list-workspace-user-groups.d.ts +9 -1
  191. package/dist/workspace-connections/actions/list-workspace-user-groups.js +9 -4
  192. package/dist/workspace-connections/groups.d.ts +2 -0
  193. package/dist/workspace-connections/groups.js +16 -5
  194. package/dist/workspace-files/actions.d.ts +1 -8
  195. package/dist/workspace-files/actions.js +5 -21
  196. package/dist/workspace-files/index.d.ts +1 -1
  197. package/dist/workspace-files/index.js +1 -1
  198. package/dist/workspace-files/store.d.ts +18 -0
  199. package/dist/workspace-files/store.js +24 -3
  200. package/package.json +3 -3
  201. package/src/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  202. package/src/templates/chat/.agents/skills/sharing/SKILL.md +27 -2
  203. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  204. package/src/templates/default/.agents/skills/sharing/SKILL.md +27 -2
  205. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  206. package/src/templates/workspace-core/.agents/skills/sharing/SKILL.md +27 -2
@@ -505,6 +505,8 @@ export class RecorderEngine {
505
505
  private cameraComposite: CameraCompositeHandle | null = null;
506
506
  private audioMixCtx: AudioContext | null = null;
507
507
  private audioMixSources: MediaStreamAudioSourceNode[] = [];
508
+ private wakeLock: WakeLockSentinel | null = null;
509
+ private wakeLockGeneration = 0;
508
510
  private recorder: MediaRecorder | null = null;
509
511
  private mimeType: string = "video/webm";
510
512
 
@@ -890,7 +892,7 @@ export class RecorderEngine {
890
892
  this.opts.displaySurface ?? "window",
891
893
  );
892
894
  const displayOptions: ExtendedDisplayMediaOptions =
893
- screenCaptureDisplayOptions(displaySurface);
895
+ screenCaptureDisplayOptions(displaySurface, wantsMic);
894
896
 
895
897
  if (wantsMic || wantsDisplay) {
896
898
  this.audioMixCtx?.close().catch(() => {});
@@ -904,6 +906,14 @@ export class RecorderEngine {
904
906
  } catch (err) {
905
907
  throw this.friendlyError(err, "screen");
906
908
  }
909
+ // The OS is as free to sleep/lock the display during a pause as
910
+ // during active recording, which ends getDisplayMedia's video track
911
+ // outright (unlike the mic/camera, display capture requires an
912
+ // active compositor). Without this, a paused recording left idle
913
+ // finalizes early with no warning — see onDisplayTrackEnded below.
914
+ // Best-effort: unsupported browsers or a denied request must never
915
+ // block capture.
916
+ void this.acquireWakeLock();
907
917
  }
908
918
 
909
919
  if (wantsCamera) {
@@ -2360,9 +2370,34 @@ export class RecorderEngine {
2360
2370
  );
2361
2371
  }
2362
2372
 
2373
+ /** Best-effort — unsupported browsers or a denied request must never block capture. */
2374
+ private async acquireWakeLock(): Promise<void> {
2375
+ const generation = ++this.wakeLockGeneration;
2376
+ try {
2377
+ // coercion-ok: optional chaining yields undefined only when the Wake
2378
+ // Lock API itself is absent (older/other browsers) — a real rejection
2379
+ // (denied request) is caught below, not coerced here.
2380
+ const wakeLock = (await navigator.wakeLock?.request?.("screen")) ?? null;
2381
+ if (generation !== this.wakeLockGeneration || !this.displayStream) {
2382
+ wakeLock?.release().catch(() => {});
2383
+ return;
2384
+ }
2385
+ this.wakeLock = wakeLock;
2386
+ } catch {
2387
+ if (generation === this.wakeLockGeneration) this.wakeLock = null;
2388
+ }
2389
+ }
2390
+
2391
+ private releaseWakeLock(): void {
2392
+ this.wakeLockGeneration += 1;
2393
+ this.wakeLock?.release().catch(() => {});
2394
+ this.wakeLock = null;
2395
+ }
2396
+
2363
2397
  private cleanupTracks(): void {
2364
2398
  // Clear before stopping tracks so the `ended` events our own stop() fires
2365
2399
  // aren't mistaken for a disconnect.
2400
+ this.releaseWakeLock();
2366
2401
  this.cameraLive = false;
2367
2402
  this.audioMixSources = [];
2368
2403
  this.audioMixCtx?.close().catch(() => {});
@@ -1335,10 +1335,11 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1335
1335
  cameraOff: "Camera off",
1336
1336
  includeCameraAria: "Include camera in this recording",
1337
1337
  startRecording: "Start recording",
1338
- micOffConfirmTitle: "Record without a microphone?",
1338
+ micOffConfirmTitle: "Your mic is muted",
1339
1339
  micOffConfirmDescription:
1340
- "Your mic is off, so this recording won't capture any audio. Turn it on before starting if you want narration.",
1341
- startWithoutMic: "Start anyway",
1340
+ "To have sound in your video, you’ll need to unmute your microphone.",
1341
+ startWithoutMic: "Continue",
1342
+ unmuteMicrophone: "Unmute",
1342
1343
  uploadVideo: "Upload video",
1343
1344
  importLoom: "Import Loom",
1344
1345
  importing: "Importing...",
@@ -149,6 +149,13 @@ export function clampToMaxDimension(
149
149
  };
150
150
  }
151
151
 
152
+ export function cameraBubbleStrokeRadius(
153
+ size: number,
154
+ lineWidth: number,
155
+ ): number {
156
+ return Math.max(0, size / 2 - lineWidth / 2);
157
+ }
158
+
152
159
  function resizeCanvasToDisplay(
153
160
  canvas: HTMLCanvasElement,
154
161
  displayVideo: HTMLVideoElement,
@@ -225,6 +232,7 @@ function drawCameraBubble(
225
232
  maxSize,
226
233
  ),
227
234
  );
235
+ if (size <= 0) return;
228
236
  const margin = Math.round(
229
237
  clamp(minDimension * options.bubbleMarginRatio, 24, 80),
230
238
  );
@@ -255,7 +263,13 @@ function drawCameraBubble(
255
263
  ctx.strokeStyle = "rgba(255, 255, 255, 0.86)";
256
264
  ctx.lineWidth = Math.max(4, Math.round(size * 0.025));
257
265
  ctx.beginPath();
258
- ctx.arc(centerX, centerY, radius - ctx.lineWidth / 2, 0, Math.PI * 2);
266
+ ctx.arc(
267
+ centerX,
268
+ centerY,
269
+ cameraBubbleStrokeRadius(size, ctx.lineWidth),
270
+ 0,
271
+ Math.PI * 2,
272
+ );
259
273
  ctx.stroke();
260
274
  ctx.restore();
261
275
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Clips no longer crashes when a camera bubble starts at a tiny size.
@@ -37,16 +37,6 @@ import {
37
37
  useState,
38
38
  } from "react";
39
39
 
40
- import {
41
- AlertDialog,
42
- AlertDialogAction,
43
- AlertDialogCancel,
44
- AlertDialogContent,
45
- AlertDialogDescription,
46
- AlertDialogFooter,
47
- AlertDialogHeader,
48
- AlertDialogTitle,
49
- } from "./components/AlertDialog";
50
40
  import { FeedbackButton } from "./components/FeedbackButton";
51
41
  import {
52
42
  CamIcon,
@@ -58,6 +48,7 @@ import {
58
48
  SettingsIcon,
59
49
  } from "./components/Icons";
60
50
  import { MediaDeviceRow } from "./components/MediaDeviceRow";
51
+ import { MicOffConfirmation } from "./components/MicOffConfirmation";
61
52
  import { ReadinessPanel } from "./components/ReadinessPanel";
62
53
  import { SourceRow, type CaptureSource } from "./components/SourceRow";
63
54
  import { Switch } from "./components/Switch";
@@ -3333,6 +3324,22 @@ export function App() {
3333
3324
  void handleStartRecording(options);
3334
3325
  }
3335
3326
 
3327
+ function closeMicOffConfirmation() {
3328
+ pendingStartOptionsRef.current = undefined;
3329
+ setMicOffConfirmOpen(false);
3330
+ }
3331
+
3332
+ function unmuteFromConfirmation() {
3333
+ setMicOn(true);
3334
+ closeMicOffConfirmation();
3335
+ }
3336
+
3337
+ function continueWithoutMic() {
3338
+ const options = pendingStartOptionsRef.current;
3339
+ closeMicOffConfirmation();
3340
+ void handleStartRecording(options);
3341
+ }
3342
+
3336
3343
  recordShortcutHandlerRef.current = () => {
3337
3344
  if (recorder) {
3338
3345
  emit("clips:recorder-stop").catch(() => {});
@@ -3960,7 +3967,19 @@ export function App() {
3960
3967
 
3961
3968
  return (
3962
3969
  <div className="app app-recorder" ref={appRef}>
3963
- <div className="recorder-home-content">
3970
+ {micOffConfirmOpen ? (
3971
+ <MicOffConfirmation
3972
+ onBack={closeMicOffConfirmation}
3973
+ onUnmute={unmuteFromConfirmation}
3974
+ onContinue={continueWithoutMic}
3975
+ />
3976
+ ) : null}
3977
+
3978
+ <div
3979
+ className="recorder-home-content"
3980
+ hidden={micOffConfirmOpen}
3981
+ aria-hidden={micOffConfirmOpen}
3982
+ >
3964
3983
  <Header
3965
3984
  mode={mode}
3966
3985
  onModeChange={setMode}
@@ -4077,35 +4096,6 @@ export function App() {
4077
4096
  </button>
4078
4097
  ) : null}
4079
4098
 
4080
- <AlertDialog
4081
- open={micOffConfirmOpen}
4082
- onOpenChange={(open) => {
4083
- setMicOffConfirmOpen(open);
4084
- if (!open) pendingStartOptionsRef.current = undefined;
4085
- }}
4086
- >
4087
- <AlertDialogContent>
4088
- <AlertDialogHeader>
4089
- <AlertDialogTitle>Record without a microphone?</AlertDialogTitle>
4090
- <AlertDialogDescription>
4091
- Your mic is off, so this recording won&apos;t capture any audio.
4092
- Turn it on before starting if you want narration.
4093
- </AlertDialogDescription>
4094
- </AlertDialogHeader>
4095
- <AlertDialogFooter>
4096
- <AlertDialogAction
4097
- onClick={() => {
4098
- const options = pendingStartOptionsRef.current;
4099
- pendingStartOptionsRef.current = undefined;
4100
- void handleStartRecording(options);
4101
- }}
4102
- >
4103
- Start anyway
4104
- </AlertDialogAction>
4105
- <AlertDialogCancel>Cancel</AlertDialogCancel>
4106
- </AlertDialogFooter>
4107
- </AlertDialogContent>
4108
- </AlertDialog>
4109
4099
  {recError ? (
4110
4100
  recError === MACOS_UPDATE_RESTART_MESSAGE ? (
4111
4101
  <UpdateRestartBanner message={recError} />
@@ -0,0 +1,47 @@
1
+ import { IconArrowLeft, IconMicrophone2 } from "@tabler/icons-react";
2
+
3
+ export function MicOffConfirmation({
4
+ onBack,
5
+ onUnmute,
6
+ onContinue,
7
+ }: {
8
+ onBack: () => void;
9
+ onUnmute: () => void;
10
+ onContinue: () => void;
11
+ }) {
12
+ return (
13
+ <section className="mic-off-confirmation" aria-labelledby="mic-off-title">
14
+ <header className="mic-off-confirmation-header">
15
+ <button
16
+ type="button"
17
+ className="mic-off-confirmation-back"
18
+ onClick={onBack}
19
+ >
20
+ <IconArrowLeft size={18} aria-hidden="true" />
21
+ Back
22
+ </button>
23
+ </header>
24
+
25
+ <div className="mic-off-confirmation-body">
26
+ <div className="mic-off-confirmation-visual" aria-hidden="true">
27
+ <IconMicrophone2 size={42} strokeWidth={1.7} />
28
+ <span className="mic-off-confirmation-slash" />
29
+ </div>
30
+ <h2 id="mic-off-title">Your mic is muted</h2>
31
+ <p>
32
+ To have sound in your video, you&apos;ll need to unmute your
33
+ microphone.
34
+ </p>
35
+ </div>
36
+
37
+ <footer className="mic-off-confirmation-actions">
38
+ <button type="button" className="secondary" onClick={onUnmute}>
39
+ Unmute
40
+ </button>
41
+ <button type="button" className="primary" onClick={onContinue}>
42
+ Continue
43
+ </button>
44
+ </footer>
45
+ </section>
46
+ );
47
+ }
@@ -3150,6 +3150,116 @@ body[data-clips-route="recording-pill"] #root {
3150
3150
  }
3151
3151
  }
3152
3152
 
3153
+ /* ------------------------------------------------------------------------- */
3154
+ /* Microphone-off confirmation */
3155
+ /* ------------------------------------------------------------------------- */
3156
+
3157
+ .mic-off-confirmation {
3158
+ min-height: 430px;
3159
+ display: flex;
3160
+ flex-direction: column;
3161
+ background: var(--bg);
3162
+ }
3163
+
3164
+ .mic-off-confirmation-header {
3165
+ display: flex;
3166
+ align-items: center;
3167
+ padding: 16px 18px 8px;
3168
+ }
3169
+
3170
+ .mic-off-confirmation-back {
3171
+ display: inline-flex;
3172
+ align-items: center;
3173
+ gap: 6px;
3174
+ min-height: 30px;
3175
+ padding: 4px 6px 4px 2px;
3176
+ border: 0;
3177
+ border-radius: var(--radius-sm);
3178
+ background: transparent;
3179
+ color: var(--fg);
3180
+ cursor: pointer;
3181
+ font-size: 15px;
3182
+ font-weight: 650;
3183
+ transition:
3184
+ background 120ms,
3185
+ color 120ms;
3186
+ }
3187
+
3188
+ .mic-off-confirmation-back:hover {
3189
+ background: var(--surface-hover);
3190
+ }
3191
+
3192
+ .mic-off-confirmation-back:focus-visible {
3193
+ outline: none;
3194
+ box-shadow: 0 0 0 3px var(--brand-ring);
3195
+ }
3196
+
3197
+ .mic-off-confirmation-body {
3198
+ display: flex;
3199
+ flex: 1 1 auto;
3200
+ flex-direction: column;
3201
+ align-items: center;
3202
+ justify-content: center;
3203
+ padding: 24px 28px 30px;
3204
+ text-align: center;
3205
+ }
3206
+
3207
+ .mic-off-confirmation-visual {
3208
+ position: relative;
3209
+ display: flex;
3210
+ align-items: center;
3211
+ justify-content: center;
3212
+ width: 88px;
3213
+ height: 88px;
3214
+ border-radius: 50%;
3215
+ background: var(--surface-strong);
3216
+ color: var(--status-warn);
3217
+ }
3218
+
3219
+ .mic-off-confirmation-slash {
3220
+ position: absolute;
3221
+ width: 58px;
3222
+ height: 3px;
3223
+ border-radius: var(--radius-pill);
3224
+ background: var(--status-warn);
3225
+ transform: rotate(45deg);
3226
+ }
3227
+
3228
+ .mic-off-confirmation-body h2 {
3229
+ margin: 24px 0 0;
3230
+ font-size: 22px;
3231
+ font-weight: 700;
3232
+ letter-spacing: -0.02em;
3233
+ }
3234
+
3235
+ .mic-off-confirmation-body p {
3236
+ max-width: 300px;
3237
+ margin: 8px 0 0;
3238
+ color: var(--fg-muted);
3239
+ font-size: 14px;
3240
+ line-height: 1.55;
3241
+ }
3242
+
3243
+ .mic-off-confirmation-actions {
3244
+ display: grid;
3245
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3246
+ gap: 10px;
3247
+ padding: 16px 18px 18px;
3248
+ border-top: 1px solid var(--border);
3249
+ background: var(--surface);
3250
+ }
3251
+
3252
+ .mic-off-confirmation-actions .primary,
3253
+ .mic-off-confirmation-actions .secondary {
3254
+ width: auto;
3255
+ min-width: 0;
3256
+ height: 42px;
3257
+ padding: 0 12px;
3258
+ border-radius: var(--radius);
3259
+ font-size: 13px;
3260
+ white-space: nowrap;
3261
+ }
3262
+
3153
3263
  /* ------------------------------------------------------------------------- */
3154
3264
  /* Alert dialog */
3155
3265
  /* ------------------------------------------------------------------------- */
@@ -630,6 +630,60 @@ mod macos {
630
630
  }
631
631
  }
632
632
 
633
+ /// RNNoise's voice-activity output is more useful than a fixed amplitude
634
+ /// threshold for the meeting microphone. A muted call still leaves Clips
635
+ /// with the local microphone, and fans, keyboard noise, or room noise can
636
+ /// be loud enough to cross the old RMS-only gate. Keep this state on the
637
+ /// worker thread so the realtime capture callback stays allocation-free.
638
+ struct MicVoiceActivity {
639
+ state: Box<nnnoiseless::DenoiseState<'static>>,
640
+ pending: Vec<f32>,
641
+ }
642
+
643
+ impl MicVoiceActivity {
644
+ const SAMPLE_RATE: f32 = 48_000.0;
645
+ const INPUT_SCALE: f32 = 32_768.0;
646
+
647
+ fn new() -> Self {
648
+ Self {
649
+ state: nnnoiseless::DenoiseState::new(),
650
+ pending: Vec::new(),
651
+ }
652
+ }
653
+
654
+ fn reset(&mut self) {
655
+ self.state = nnnoiseless::DenoiseState::new();
656
+ self.pending.clear();
657
+ }
658
+
659
+ /// Return the highest RNNoise speech probability in `samples`.
660
+ /// `None` means this capture format is not the 48 kHz PCM that
661
+ /// RNNoise expects; callers retain the existing RMS fallback for
662
+ /// those legacy devices.
663
+ fn observe(&mut self, samples: &[f32], sample_rate: f32) -> Option<f32> {
664
+ if (sample_rate - Self::SAMPLE_RATE).abs() > 1.0 {
665
+ self.reset();
666
+ return None;
667
+ }
668
+
669
+ self.pending.extend(samples.iter().map(|sample| {
670
+ (sample * Self::INPUT_SCALE).clamp(-Self::INPUT_SCALE, Self::INPUT_SCALE)
671
+ }));
672
+
673
+ let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE;
674
+ let mut highest_probability: f32 = 0.0;
675
+ while self.pending.len() >= frame_size {
676
+ let mut denoised = [0.0_f32; nnnoiseless::DenoiseState::FRAME_SIZE];
677
+ let probability = self
678
+ .state
679
+ .process_frame(&mut denoised, &self.pending[..frame_size]);
680
+ highest_probability = highest_probability.max(probability);
681
+ self.pending.drain(..frame_size);
682
+ }
683
+ Some(highest_probability)
684
+ }
685
+ }
686
+
633
687
  /// Run whisper over `samples` (16 kHz mono f32), returning each speech
634
688
  /// segment as `(start_ms, end_ms, text)` with buffer-relative timestamps.
635
689
  /// `language` is the forced language code (e.g. "en"); `None` lets whisper
@@ -698,6 +752,10 @@ mod macos {
698
752
  const VOICE_RMS_THRESHOLD: f32 = 0.006;
699
753
  /// A second, model-level gate for ambient/no-speech Whisper segments.
700
754
  const MAX_NO_SPEECH_PROBABILITY: f32 = 0.72;
755
+ /// RNNoise speech probability required for a 48 kHz microphone frame to
756
+ /// count as voice. System audio and non-48 kHz legacy mic captures keep
757
+ /// the RMS path because this VAD is trained for 48 kHz microphone PCM.
758
+ const MIN_MIC_VAD_PROBABILITY: f32 = 0.6;
701
759
  /// Minimum average per-token probability for a segment to count as real
702
760
  /// speech. On noisy/near-silent audio whisper mis-detects the language and
703
761
  /// decodes fluent-looking gibberish in another language — but those tokens
@@ -733,6 +791,11 @@ mod macos {
733
791
  sum / count as f32
734
792
  }
735
793
 
794
+ fn mic_voice_detected(rms: f32, vad_probability: Option<f32>) -> bool {
795
+ rms > VOICE_RMS_THRESHOLD
796
+ && vad_probability.is_none_or(|probability| probability >= MIN_MIC_VAD_PROBABILITY)
797
+ }
798
+
736
799
  fn buffer_start_after_drain(now: Instant, pending: Duration) -> Instant {
737
800
  now.checked_sub(pending).unwrap_or(now)
738
801
  }
@@ -788,6 +851,10 @@ mod macos {
788
851
  let mut seen_reset_generation = stream.reset_generation.load(Ordering::SeqCst);
789
852
  // Growing-utterance resample cache — see `IncrementalResample`.
790
853
  let mut resample_state = IncrementalResample::new();
854
+ // Only the microphone stream needs a speech/noise classifier. The
855
+ // system stream must preserve remote speech even when it is mixed
856
+ // with music or other non-voice audio.
857
+ let mut mic_voice_activity = (stream.source == "mic").then(MicVoiceActivity::new);
791
858
 
792
859
  while stream.running.load(Ordering::SeqCst) {
793
860
  std::thread::sleep(Duration::from_millis(250));
@@ -800,6 +867,9 @@ mod macos {
800
867
  last_infer = Instant::now() - Duration::from_secs(10);
801
868
  had_voice = false;
802
869
  resample_state.drop_all();
870
+ if let Some(vad) = mic_voice_activity.as_mut() {
871
+ vad.reset();
872
+ }
803
873
  continue;
804
874
  }
805
875
 
@@ -807,31 +877,40 @@ mod macos {
807
877
  // poll. Inspect that tail in place instead of cloning + resampling
808
878
  // the entire growing utterance four times per second. Full snapshots
809
879
  // are reserved for an inference that is actually due.
810
- let (raw_len, new_rms) = match stream.buf.lock() {
880
+ let (raw_len, new_rms, new_mic_samples) = match stream.buf.lock() {
811
881
  Ok(b) => {
812
882
  let raw_len = b.len();
813
- let new_rms = if raw_len > last_raw_len {
883
+ let (new_rms, new_mic_samples) = if raw_len > last_raw_len {
814
884
  let new = &b[last_raw_len..];
815
- Some((new.iter().map(|x| x * x).sum::<f32>() / new.len() as f32).sqrt())
885
+ (
886
+ Some(
887
+ (new.iter().map(|x| x * x).sum::<f32>() / new.len() as f32).sqrt(),
888
+ ),
889
+ (stream.source == "mic").then(|| new.to_vec()),
890
+ )
816
891
  } else {
817
- None
892
+ (None, None)
818
893
  };
819
- (raw_len, new_rms)
894
+ (raw_len, new_rms, new_mic_samples)
820
895
  }
821
896
  Err(_) => continue,
822
897
  };
823
898
  if stream.reset_generation.load(Ordering::SeqCst) != seen_reset_generation {
824
899
  continue;
825
900
  }
901
+ let src_rate = (stream.src_rate.load(Ordering::SeqCst) as f32).max(1.0);
826
902
  if let Some(rms) = new_rms {
827
- if rms > VOICE_RMS_THRESHOLD {
903
+ let vad_probability = mic_voice_activity
904
+ .as_mut()
905
+ .zip(new_mic_samples.as_deref())
906
+ .and_then(|(vad, samples)| vad.observe(samples, src_rate));
907
+ if mic_voice_detected(rms, vad_probability) {
828
908
  last_voice = Instant::now();
829
909
  had_voice = true;
830
910
  }
831
911
  }
832
912
  last_raw_len = raw_len;
833
913
 
834
- let src_rate = (stream.src_rate.load(Ordering::SeqCst) as f32).max(1.0);
835
914
  let have_secs = raw_len as f32 / src_rate;
836
915
  let silence = last_voice.elapsed();
837
916
 
@@ -878,6 +957,9 @@ mod macos {
878
957
  // so the resample cache is invalid regardless of whether this
879
958
  // utterance ran inference — rebuild fresh from whatever's left.
880
959
  resample_state.drop_all();
960
+ if let Some(vad) = mic_voice_activity.as_mut() {
961
+ vad.reset();
962
+ }
881
963
  // Advance the timeline offset so the next utterance's whisper
882
964
  // timestamps map correctly. Inference can take seconds, and
883
965
  // audio kept arriving throughout it, so the new buffer starts
@@ -1568,6 +1650,31 @@ mod macos {
1568
1650
  );
1569
1651
  }
1570
1652
 
1653
+ #[test]
1654
+ fn mic_voice_gate_requires_speech_probability_on_supported_captures() {
1655
+ assert!(!super::mic_voice_detected(0.02, Some(0.59)));
1656
+ assert!(!super::mic_voice_detected(0.004, Some(0.99)));
1657
+ assert!(super::mic_voice_detected(0.02, Some(0.6)));
1658
+ assert!(super::mic_voice_detected(0.02, None));
1659
+ }
1660
+
1661
+ #[test]
1662
+ fn rnnoise_vad_rejects_steady_ambient_noise() {
1663
+ let mut vad = super::MicVoiceActivity::new();
1664
+ let samples: Vec<f32> = (0..(nnnoiseless::DenoiseState::FRAME_SIZE * 8))
1665
+ .map(|index| {
1666
+ let value = index.wrapping_mul(1_103_515_245usize).wrapping_add(12_345);
1667
+ (((value >> 16) & 0x7fff) as f32 / 16_384.0 - 1.0) * 0.012
1668
+ })
1669
+ .collect();
1670
+
1671
+ let probability = vad.observe(&samples, 48_000.0).unwrap();
1672
+ assert!(
1673
+ probability < super::MIN_MIC_VAD_PROBABILITY,
1674
+ "ambient noise was classified as speech: {probability:.3}"
1675
+ );
1676
+ }
1677
+
1571
1678
  #[test]
1572
1679
  fn filters_multilingual_caption_hallucinations() {
1573
1680
  assert_eq!(clean_transcript("ご視聴ありがとうございました"), None);
@@ -47,29 +47,34 @@ export type ScreenCaptureDisplayOptions = {
47
47
  /**
48
48
  * The `getDisplayMedia` options for a screen recording.
49
49
  *
50
- * Screen/tab audio is requested ALWAYS, and deliberately does not depend on
51
- * whether the microphone is on. They are two different sources, and one toggle
52
- * used to govern both: with the mic off, Chrome was never even offered the
53
- * "share tab audio" checkbox, so a screen recording of a call or a demo
54
- * captured zero audio tracks. Measured in production, 345 recordings landed
55
- * with `has_audio = false` — 95 over a minute, 17 over five — and each was told
56
- * "No speech was detected because this recording was saved without audio",
57
- * blaming the recording for a capture setting. That was 64% of recent
58
- * transcript failures. The Chrome extension always requested display audio and
59
- * never had the problem.
50
+ * Screen/tab audio is requested whenever the microphone is enabled, and
51
+ * withheld when it's off. The mic toggle is the only audio control the
52
+ * recorder UI shows, so a user who turns it off reasonably expects the
53
+ * recording to capture no audio at all — not just their voice. Requesting
54
+ * system audio anyway relies on the user separately noticing and unchecking
55
+ * the browser's native "share tab audio" checkbox, which they may not even
56
+ * know exists. See Slack thread 1786086902028429: a user turned the mic off
57
+ * and still got system audio in the recording; the agreed fix is to gate
58
+ * system audio on the same toggle.
60
59
  *
61
- * Declining the checkbox is still respected; the user simply gets the choice.
60
+ * When the mic IS on, audio is still requested unconditionally (not made
61
+ * conditional on some separate "include system audio" flag) — that used to be
62
+ * one toggle governing both, and with the mic off Chrome never even offered
63
+ * the checkbox, so 345 production recordings landed with `has_audio = false`
64
+ * while recording a call/demo. Declining the checkbox is still respected; the
65
+ * user simply gets the choice whenever the mic is on.
62
66
  */
63
67
  export function screenCaptureDisplayOptions(
64
68
  displaySurface: ScreenCaptureSurface,
69
+ wantsMic: boolean,
65
70
  ): ScreenCaptureDisplayOptions {
66
71
  return {
67
72
  video: screenCaptureVideoConstraints(displaySurface),
68
- audio: true,
73
+ audio: wantsMic,
69
74
  // Let "Browser tab" open the tab picker. preferCurrentTab turns it into a
70
75
  // current-tab shortcut, which makes choosing another tab harder.
71
76
  selfBrowserSurface: displaySurface === "browser" ? "include" : "exclude",
72
77
  surfaceSwitching: "include",
73
- systemAudio: "include",
78
+ systemAudio: wantsMic ? "include" : "exclude",
74
79
  };
75
80
  }
@@ -50,7 +50,7 @@ The agent modifies data in SQL, but the UI runs in the browser. SSE bridges same
50
50
 
51
51
  For list/sidebar queries, use the same pattern — pass the counter into the queryKey of every list query you want to keep fresh.
52
52
 
53
- 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds (`SSE_FALLBACK_INTERVAL_MS`). If SSE is disabled or unavailable (e.g., edge/serverless deployments), polling continues at the 2 s cadence. Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
53
+ 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It polls every 2 seconds only while an agent run is in flight; otherwise it idles at 60 seconds whether or not SSE is connected (`SSE_FALLBACK_INTERVAL_MS` and `IDLE_POLL_INTERVAL_MS`, both 60 s). A hidden tab polls no faster than every 10 seconds (`HIDDEN_POLL_INTERVAL_MS`). Polling is the universal serverless fallback: new framework writes are read from the durable `sync_events` log, while the older DB timestamp scan remains as a slower safety net for direct SQL writes and older processes.
54
54
 
55
55
  5. When the agent writes to the database, the version increments, SSE/polling detects it, and React Query refetches the affected queries.
56
56