@agent-native/core 0.70.2 → 0.71.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 (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
@@ -7,6 +7,13 @@ type ExtensionSettings = {
7
7
  includeCamera: boolean;
8
8
  includeMicrophone: boolean;
9
9
  includeDeveloperLogs: boolean;
10
+ videoDeviceId: string;
11
+ audioDeviceId: string;
12
+ };
13
+
14
+ type InputDevice = {
15
+ deviceId: string;
16
+ label: string;
10
17
  };
11
18
 
12
19
  type PopupStartResponse = {
@@ -67,6 +74,8 @@ const DEFAULT_SETTINGS: ExtensionSettings = {
67
74
  includeCamera: true,
68
75
  includeMicrophone: true,
69
76
  includeDeveloperLogs: true,
77
+ videoDeviceId: "",
78
+ audioDeviceId: "",
70
79
  };
71
80
 
72
81
  const SOURCE_LABELS: Record<Exclude<CaptureSurface, "camera">, string> = {
@@ -167,6 +176,37 @@ async function loadFeedbackSchema(
167
176
  return pending;
168
177
  }
169
178
 
179
+ // Enumerate the user's input devices for the camera/mic pickers. Labels only
180
+ // populate after camera/mic permission is granted (the extension's permission
181
+ // onboarding page handles that), so fall back to a generic label otherwise.
182
+ async function enumerateInputDevices(): Promise<{
183
+ cameras: InputDevice[];
184
+ microphones: InputDevice[];
185
+ }> {
186
+ try {
187
+ const devices = await navigator.mediaDevices.enumerateDevices();
188
+ const cameras: InputDevice[] = [];
189
+ const microphones: InputDevice[] = [];
190
+ for (const device of devices) {
191
+ if (!device.deviceId) continue;
192
+ if (device.kind === "videoinput") {
193
+ cameras.push({
194
+ deviceId: device.deviceId,
195
+ label: device.label.trim() || `Camera ${cameras.length + 1}`,
196
+ });
197
+ } else if (device.kind === "audioinput") {
198
+ microphones.push({
199
+ deviceId: device.deviceId,
200
+ label: device.label.trim() || `Microphone ${microphones.length + 1}`,
201
+ });
202
+ }
203
+ }
204
+ return { cameras, microphones };
205
+ } catch {
206
+ return { cameras: [], microphones: [] };
207
+ }
208
+ }
209
+
170
210
  function readSettings(): Promise<ExtensionSettings> {
171
211
  return new Promise((resolve) => {
172
212
  chrome.storage.sync.get(DEFAULT_SETTINGS, (value) => {
@@ -188,6 +228,14 @@ function readSettings(): Promise<ExtensionSettings> {
188
228
  typeof value.includeDeveloperLogs === "boolean"
189
229
  ? value.includeDeveloperLogs
190
230
  : DEFAULT_SETTINGS.includeDeveloperLogs,
231
+ videoDeviceId:
232
+ typeof value.videoDeviceId === "string"
233
+ ? value.videoDeviceId
234
+ : DEFAULT_SETTINGS.videoDeviceId,
235
+ audioDeviceId:
236
+ typeof value.audioDeviceId === "string"
237
+ ? value.audioDeviceId
238
+ : DEFAULT_SETTINGS.audioDeviceId,
191
239
  });
192
240
  });
193
241
  });
@@ -401,6 +449,28 @@ function comparableLabel(value: string): string {
401
449
  .replace(/[^a-z0-9]+/g, "");
402
450
  }
403
451
 
452
+ // Pages where Chrome forbids content-script / overlay injection, so the on-page
453
+ // countdown + controls can't render. Recording the screen still works; we just
454
+ // warn the user in the popup instead of letting it fail silently.
455
+ function isUnsupportedPage(url: string | undefined | null): boolean {
456
+ if (!url) return true;
457
+ const u = url.toLowerCase();
458
+ return (
459
+ u.startsWith("chrome://") ||
460
+ u.startsWith("chrome-extension://") ||
461
+ u.startsWith("edge://") ||
462
+ u.startsWith("brave://") ||
463
+ u.startsWith("arc://") ||
464
+ u.startsWith("about:") ||
465
+ u.startsWith("view-source:") ||
466
+ u.startsWith("devtools://") ||
467
+ u.startsWith("chrome-search://") ||
468
+ u.startsWith("chrome-untrusted://") ||
469
+ u.startsWith("https://chromewebstore.google.com") ||
470
+ u.startsWith("https://chrome.google.com/webstore")
471
+ );
472
+ }
473
+
404
474
  function targetCopy(tab: chrome.tabs.Tab | null): {
405
475
  title: string;
406
476
  subtitle: string;
@@ -464,9 +534,111 @@ function renderSource(settings: ExtensionSettings): void {
464
534
  }
465
535
  }
466
536
 
537
+ // Holds the most recent device enumeration so the menus and labels can render
538
+ // without re-querying on every paint. Refreshed by refreshDevices().
539
+ let inputDevices: { cameras: InputDevice[]; microphones: InputDevice[] } = {
540
+ cameras: [],
541
+ microphones: [],
542
+ };
543
+
544
+ function deviceLabel(
545
+ devices: InputDevice[],
546
+ deviceId: string,
547
+ fallback: string,
548
+ ): string {
549
+ if (!deviceId) return fallback;
550
+ const match = devices.find((device) => device.deviceId === deviceId);
551
+ return match ? match.label : fallback;
552
+ }
553
+
554
+ function renderDeviceMenu(
555
+ menu: HTMLDivElement,
556
+ devices: InputDevice[],
557
+ selectedId: string,
558
+ defaultLabel: string,
559
+ ): void {
560
+ menu.replaceChildren();
561
+ const options: InputDevice[] = [
562
+ { deviceId: "", label: defaultLabel },
563
+ ...devices,
564
+ ];
565
+ for (const option of options) {
566
+ const item = document.createElement("button");
567
+ item.type = "button";
568
+ item.className = "row-menu-item";
569
+ item.dataset.deviceId = option.deviceId;
570
+ const selected = option.deviceId === selectedId;
571
+ if (selected) {
572
+ item.classList.add("selected");
573
+ item.setAttribute("aria-checked", "true");
574
+ } else {
575
+ item.setAttribute("aria-checked", "false");
576
+ }
577
+
578
+ const check = document.createElement("span");
579
+ check.className = "row-menu-check";
580
+ check.setAttribute("aria-hidden", "true");
581
+ check.textContent = selected ? "✓" : "";
582
+
583
+ const label = document.createElement("span");
584
+ label.className = "row-menu-label";
585
+ label.textContent = option.label;
586
+
587
+ item.append(check, label);
588
+ menu.append(item);
589
+ }
590
+ }
591
+
592
+ function renderDevicePickers(settings: ExtensionSettings): void {
593
+ const cameraButton = byId<HTMLButtonElement>("camera-device-button");
594
+ const cameraLabel = byId<HTMLSpanElement>("camera-device-label");
595
+ const cameraMenu = byId<HTMLDivElement>("camera-device-menu");
596
+ const showCamera =
597
+ settings.captureSurface === "camera" || settings.includeCamera;
598
+ cameraButton.hidden = !showCamera;
599
+ if (showCamera) {
600
+ cameraLabel.textContent = deviceLabel(
601
+ inputDevices.cameras,
602
+ settings.videoDeviceId,
603
+ "Default camera",
604
+ );
605
+ renderDeviceMenu(
606
+ cameraMenu,
607
+ inputDevices.cameras,
608
+ settings.videoDeviceId,
609
+ "Default camera",
610
+ );
611
+ } else {
612
+ cameraMenu.hidden = true;
613
+ }
614
+
615
+ const micRow = byId<HTMLDivElement>("microphone-row");
616
+ const micButton = byId<HTMLButtonElement>("microphone-device-button");
617
+ const micLabel = byId<HTMLSpanElement>("microphone-device-label");
618
+ const micMenu = byId<HTMLDivElement>("microphone-device-menu");
619
+ micButton.hidden = !settings.includeMicrophone;
620
+ if (settings.includeMicrophone) {
621
+ micLabel.textContent = deviceLabel(
622
+ inputDevices.microphones,
623
+ settings.audioDeviceId,
624
+ "Default mic",
625
+ );
626
+ renderDeviceMenu(
627
+ micMenu,
628
+ inputDevices.microphones,
629
+ settings.audioDeviceId,
630
+ "Default mic",
631
+ );
632
+ } else {
633
+ micMenu.hidden = true;
634
+ }
635
+ micRow.hidden = false;
636
+ }
637
+
467
638
  function render(settings: ExtensionSettings): void {
468
639
  renderMode(settings);
469
640
  renderSource(settings);
641
+ renderDevicePickers(settings);
470
642
  const includeCamera = byId<HTMLButtonElement>("include-camera");
471
643
  const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
472
644
  const cameraRow = byId<HTMLDivElement>("camera-row");
@@ -550,6 +722,12 @@ async function init(): Promise<void> {
550
722
  }
551
723
  const sourceButton = byId<HTMLButtonElement>("source-button");
552
724
  const sourceMenu = byId<HTMLDivElement>("source-menu");
725
+ const cameraDeviceButton = byId<HTMLButtonElement>("camera-device-button");
726
+ const cameraDeviceMenu = byId<HTMLDivElement>("camera-device-menu");
727
+ const microphoneDeviceButton = byId<HTMLButtonElement>(
728
+ "microphone-device-button",
729
+ );
730
+ const microphoneDeviceMenu = byId<HTMLDivElement>("microphone-device-menu");
553
731
  const includeCamera = byId<HTMLButtonElement>("include-camera");
554
732
  const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
555
733
  const start = byId<HTMLButtonElement>("start");
@@ -624,8 +802,30 @@ async function init(): Promise<void> {
624
802
  window.setTimeout(() => feedbackTextarea.focus(), 30);
625
803
  };
626
804
 
627
- await queryActiveTab();
805
+ // Re-enumerate devices and repaint the pickers. Labels only appear once the
806
+ // user has granted camera/mic access (via the permission onboarding page), so
807
+ // this is also re-run when the device list changes.
808
+ const refreshDevices = async (): Promise<void> => {
809
+ inputDevices = await enumerateInputDevices();
810
+ renderDevicePickers(settings);
811
+ };
812
+
813
+ const closeDeviceMenus = (): void => {
814
+ cameraDeviceMenu.hidden = true;
815
+ microphoneDeviceMenu.hidden = true;
816
+ };
817
+
818
+ const activeTab = await queryActiveTab();
819
+ byId<HTMLDivElement>("unsupported-notice").hidden = !isUnsupportedPage(
820
+ activeTab?.url,
821
+ );
628
822
  render(settings);
823
+ void refreshDevices();
824
+ if (navigator.mediaDevices) {
825
+ navigator.mediaDevices.addEventListener("devicechange", () => {
826
+ void refreshDevices();
827
+ });
828
+ }
629
829
  const status =
630
830
  await sendSimpleMessage<PopupStatusResponse>("CLIPS_POPUP_STATUS");
631
831
  activeRecording = status.activeRecording ?? null;
@@ -634,17 +834,42 @@ async function init(): Promise<void> {
634
834
  window.setInterval(() => renderActiveRecording(activeRecording), 1000);
635
835
  }
636
836
 
837
+ // Pre-record preview: while this popup is open and idle, show the live face
838
+ // bubble on the active tab (desktop-app style). The port stays open for the
839
+ // popup's lifetime; its disconnect (popup closed) tells the worker to remove
840
+ // the preview. Keep the reference so the port isn't garbage-collected early.
841
+ const previewPort = chrome.runtime.connect({ name: "clips-preview" });
842
+ const syncPreview = (): void => {
843
+ const wantsCamera =
844
+ !activeRecording &&
845
+ !isUnsupportedPage(activeTab?.url) &&
846
+ (settings.captureSurface === "camera" || settings.includeCamera);
847
+ try {
848
+ chrome.runtime.sendMessage(
849
+ { type: "CLIPS_PREVIEW", camera: wantsCamera },
850
+ () => void chrome.runtime.lastError,
851
+ );
852
+ } catch {
853
+ /* worker asleep; will re-sync on next interaction */
854
+ }
855
+ };
856
+ void previewPort;
857
+ syncPreview();
858
+
637
859
  for (const button of document.querySelectorAll<HTMLButtonElement>(
638
860
  ".mode-option",
639
861
  )) {
640
862
  button.addEventListener("click", () => {
641
863
  applyMode(settings, button.dataset.mode as RecordingModeChoice);
864
+ closeDeviceMenus();
642
865
  render(settings);
643
866
  void saveSettings(settings);
867
+ syncPreview();
644
868
  });
645
869
  }
646
870
 
647
871
  sourceButton.addEventListener("click", () => {
872
+ closeDeviceMenus();
648
873
  sourceMenu.hidden = !sourceMenu.hidden;
649
874
  });
650
875
 
@@ -656,9 +881,70 @@ async function init(): Promise<void> {
656
881
  sourceMenu.hidden = true;
657
882
  render(settings);
658
883
  void saveSettings(settings);
884
+ syncPreview();
659
885
  });
660
886
  }
661
887
 
888
+ cameraDeviceButton.addEventListener("click", () => {
889
+ sourceMenu.hidden = true;
890
+ microphoneDeviceMenu.hidden = true;
891
+ cameraDeviceMenu.hidden = !cameraDeviceMenu.hidden;
892
+ });
893
+
894
+ cameraDeviceMenu.addEventListener("click", (event) => {
895
+ const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
896
+ ".row-menu-item",
897
+ );
898
+ if (!item) return;
899
+ settings.videoDeviceId = item.dataset.deviceId ?? "";
900
+ cameraDeviceMenu.hidden = true;
901
+ renderDevicePickers(settings);
902
+ void saveSettings(settings);
903
+ });
904
+
905
+ microphoneDeviceButton.addEventListener("click", () => {
906
+ sourceMenu.hidden = true;
907
+ cameraDeviceMenu.hidden = true;
908
+ microphoneDeviceMenu.hidden = !microphoneDeviceMenu.hidden;
909
+ });
910
+
911
+ microphoneDeviceMenu.addEventListener("click", (event) => {
912
+ const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
913
+ ".row-menu-item",
914
+ );
915
+ if (!item) return;
916
+ settings.audioDeviceId = item.dataset.deviceId ?? "";
917
+ microphoneDeviceMenu.hidden = true;
918
+ renderDevicePickers(settings);
919
+ void saveSettings(settings);
920
+ });
921
+
922
+ document.addEventListener("click", (event) => {
923
+ const target = event.target;
924
+ if (!(target instanceof Node)) return;
925
+ if (
926
+ !sourceMenu.hidden &&
927
+ !sourceMenu.contains(target) &&
928
+ !sourceButton.contains(target)
929
+ ) {
930
+ sourceMenu.hidden = true;
931
+ }
932
+ if (
933
+ !cameraDeviceMenu.hidden &&
934
+ !cameraDeviceMenu.contains(target) &&
935
+ !cameraDeviceButton.contains(target)
936
+ ) {
937
+ cameraDeviceMenu.hidden = true;
938
+ }
939
+ if (
940
+ !microphoneDeviceMenu.hidden &&
941
+ !microphoneDeviceMenu.contains(target) &&
942
+ !microphoneDeviceButton.contains(target)
943
+ ) {
944
+ microphoneDeviceMenu.hidden = true;
945
+ }
946
+ });
947
+
662
948
  includeCamera.addEventListener("click", () => {
663
949
  settings.includeCamera = !settings.includeCamera;
664
950
  if (settings.includeCamera && settings.captureSurface === "monitor") {
@@ -667,12 +953,15 @@ async function init(): Promise<void> {
667
953
  if (!settings.includeCamera && settings.captureSurface === "camera") {
668
954
  settings.captureSurface = "browser";
669
955
  }
956
+ closeDeviceMenus();
670
957
  render(settings);
671
958
  void saveSettings(settings);
959
+ syncPreview();
672
960
  });
673
961
 
674
962
  includeMicrophone.addEventListener("click", () => {
675
963
  settings.includeMicrophone = !settings.includeMicrophone;
964
+ closeDeviceMenus();
676
965
  render(settings);
677
966
  void saveSettings(settings);
678
967
  });
@@ -855,3 +855,37 @@ h1 {
855
855
  .primary-action:hover {
856
856
  opacity: 0.92;
857
857
  }
858
+
859
+ /* Unsupported-page notice (shown in the popup when the active tab can't host
860
+ the on-page overlay, e.g. chrome:// / New Tab / Web Store pages). */
861
+ .notice {
862
+ display: flex;
863
+ gap: 10px;
864
+ align-items: flex-start;
865
+ margin: 0 0 12px;
866
+ padding: 11px 12px;
867
+ border: 1px solid var(--border);
868
+ border-radius: var(--radius-sm);
869
+ background: var(--surface);
870
+ }
871
+
872
+ .notice-icon {
873
+ width: 18px;
874
+ height: 18px;
875
+ flex: none;
876
+ margin-top: 1px;
877
+ color: var(--status-warn);
878
+ }
879
+
880
+ .notice-title {
881
+ font-size: 12.5px;
882
+ font-weight: 600;
883
+ color: var(--fg);
884
+ margin-bottom: 2px;
885
+ }
886
+
887
+ .notice-text {
888
+ font-size: 11.5px;
889
+ line-height: 1.45;
890
+ color: var(--fg-muted);
891
+ }
@@ -14,12 +14,11 @@
14
14
  */
15
15
 
16
16
  export interface AudioCue {
17
- /** Play the cue during the countdown once recording is about one second out. */
18
- playCountdownCue(): Promise<void>;
19
17
  /**
20
18
  * Play the cue (bounded by a timeout) and wait a short settle so the tone
21
- * sits just before capture rather than inside the recording. Safe to await
22
- * directly before starting the recorder.
19
+ * sits just before capture rather than inside the recording. Call this at the
20
+ * exact moment recording starts (right before the recorder/native capture is
21
+ * kicked off) so the chime lines up with the real start, not the countdown.
23
22
  */
24
23
  playBeforeCapture(): Promise<void>;
25
24
  cleanup(): void;
@@ -38,7 +37,6 @@ function wait(ms: number): Promise<void> {
38
37
 
39
38
  /** A cue that does nothing — used when Web Audio is unavailable. */
40
39
  const noopAudioCue: AudioCue = {
41
- async playCountdownCue() {},
42
40
  async playBeforeCapture() {},
43
41
  cleanup() {},
44
42
  };
@@ -68,19 +66,26 @@ async function playBeforeCapture(
68
66
  }
69
67
 
70
68
  /**
71
- * Schedule the recording-start chime on an already-running context: a soft
72
- * rising two-note (D5 → A5) with a faint high shimmer. Fast attack, smooth
73
- * exponential tail a gentle confirmation rather than a harsh beep. Kept under
74
- * ~380ms so it fits inside `CUE_PLAY_TIMEOUT_MS` and lands just before capture.
69
+ * Schedule the recording-start chime on an already-running context.
70
+ *
71
+ * A warm, bell-like rising two-note "ding-dong" (G5 C6, a perfect fourth)
72
+ * voiced on triangle oscillators for a rounder, less electronic timbre, with a
73
+ * quiet octave overtone (C7) sparkling on top of the second note. Each note has
74
+ * a soft 18ms attack and a long exponential decay so it rings out gently — a
75
+ * confident "you're rolling" confirmation that sounds nothing like a flat
76
+ * countdown blip. Kept under ~420ms so it still fits inside
77
+ * `CUE_PLAY_TIMEOUT_MS` and lands right as capture begins.
75
78
  */
76
79
  function scheduleTone(ctx: AudioContext): Promise<void> {
77
80
  return new Promise<void>((resolve) => {
78
81
  const t0 = ctx.currentTime + 0.005;
79
82
  const voices = [
80
- { freq: 587.33, at: 0.0, dur: 0.22, peak: 0.06 }, // D5
81
- { freq: 880.0, at: 0.06, dur: 0.26, peak: 0.075 }, // A5
82
- { freq: 1760.0, at: 0.065, dur: 0.14, peak: 0.02 }, // A6 shimmer
83
- ];
83
+ // Rising perfect fourth: a gentle "ding dong".
84
+ { freq: 783.99, at: 0.0, dur: 0.26, peak: 0.07, type: "triangle" }, // G5
85
+ { freq: 1046.5, at: 0.11, dur: 0.34, peak: 0.085, type: "triangle" }, // C6
86
+ // Faint octave overtone gives the second note a soft bell shimmer.
87
+ { freq: 2093.0, at: 0.115, dur: 0.18, peak: 0.022, type: "sine" }, // C7
88
+ ] as const;
84
89
 
85
90
  let lastStop = t0;
86
91
  for (const voice of voices) {
@@ -89,12 +94,13 @@ function scheduleTone(ctx: AudioContext): Promise<void> {
89
94
  lastStop = Math.max(lastStop, stopAt);
90
95
 
91
96
  const oscillator = ctx.createOscillator();
92
- oscillator.type = "sine";
97
+ oscillator.type = voice.type;
93
98
  oscillator.frequency.setValueAtTime(voice.freq, startAt);
94
99
 
95
100
  const gain = ctx.createGain();
101
+ // Smooth attack/decay envelope — ramps avoid the click of a hard gate.
96
102
  gain.gain.setValueAtTime(0.0001, startAt);
97
- gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.012);
103
+ gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.018);
98
104
  gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
99
105
 
100
106
  oscillator.connect(gain);
@@ -165,7 +171,6 @@ export function createAudioCue(): AudioCue {
165
171
  idleTimer = window.setTimeout(cleanup, CUE_IDLE_CLEANUP_MS);
166
172
 
167
173
  return {
168
- playCountdownCue: play,
169
174
  playBeforeCapture: () => playBeforeCapture(play, cleanup),
170
175
  cleanup,
171
176
  };
@@ -88,9 +88,13 @@ const LIVE_UPLOAD_CHUNK_MS = 1_000;
88
88
  const CLOUD_CAPTURE_FRAME_RATE = 24;
89
89
  const CLOUD_CAPTURE_MAX_WIDTH = 1920;
90
90
  const CLOUD_CAPTURE_MAX_HEIGHT = 1080;
91
- const CLOUD_RECORDING_MAX_LONG_EDGE = 1280;
92
- const CLOUD_RECORDING_VIDEO_BITRATE_BPS = 900_000;
93
- const CLOUD_RECORDING_AUDIO_BITRATE_BPS = 96_000;
91
+ // Crisp capture for the desktop browser MediaRecorder fallback. Files are no
92
+ // longer shrunk client-side and the upload provider streams large files, so we
93
+ // keep full 1080p (was downscaled to a 1280 long edge) and a sharp bitrate (was
94
+ // 900 kbps, which left UI and text fuzzy). Dial down if file size matters.
95
+ const CLOUD_RECORDING_MAX_LONG_EDGE = 1920;
96
+ const CLOUD_RECORDING_VIDEO_BITRATE_BPS = 8_000_000;
97
+ const CLOUD_RECORDING_AUDIO_BITRATE_BPS = 128_000;
94
98
 
95
99
  function isMacPlatform(): boolean {
96
100
  if (typeof navigator === "undefined") return false;
@@ -1394,10 +1398,7 @@ async function selectRegionForRecording(): Promise<RegionCaptureRect> {
1394
1398
  }
1395
1399
  }
1396
1400
 
1397
- function waitForCountdownEvent(
1398
- timeoutMs = 4000,
1399
- onOneSecond?: () => void,
1400
- ): Promise<void> {
1401
+ function waitForCountdownEvent(timeoutMs = 4000): Promise<void> {
1401
1402
  return new Promise((resolve, reject) => {
1402
1403
  let timer: ReturnType<typeof setTimeout> | null = null;
1403
1404
  const unlistens: UnlistenFn[] = [];
@@ -1453,9 +1454,6 @@ function waitForCountdownEvent(
1453
1454
 
1454
1455
  track(listen("clips:countdown-done", () => finish("done")));
1455
1456
  track(listen("clips:countdown-cancel", () => finish("cancel")));
1456
- if (onOneSecond) {
1457
- track(listen("clips:countdown-one", () => onOneSecond()));
1458
- }
1459
1457
 
1460
1458
  timer = setTimeout(() => {
1461
1459
  if (done) return;
@@ -1489,13 +1487,12 @@ async function showRegionRecordBorder(region: RegionCaptureRect | null) {
1489
1487
  });
1490
1488
  }
1491
1489
 
1492
- async function runRecordingCountdown(wantsScreen: boolean, audioCue: AudioCue) {
1493
- const countdownEvent = waitForCountdownEvent(
1494
- COUNTDOWN_EVENT_TIMEOUT_MS,
1495
- () => {
1496
- void audioCue.playCountdownCue();
1497
- },
1498
- );
1490
+ async function runRecordingCountdown(wantsScreen: boolean) {
1491
+ // The recording-start chime is intentionally NOT played here. It fires from
1492
+ // `audioCue.playBeforeCapture()` at the real capture-start (right before the
1493
+ // recorder/native capture is kicked off) so the beep lines up with the moment
1494
+ // recording actually begins — not one second early on the countdown's "1".
1495
+ const countdownEvent = waitForCountdownEvent(COUNTDOWN_EVENT_TIMEOUT_MS);
1499
1496
  await showRegionGuidesForRecording(wantsScreen);
1500
1497
  try {
1501
1498
  await invoke("show_countdown");
@@ -1676,7 +1673,7 @@ async function startNativeFullscreenRecording(
1676
1673
  }).catch((err) => {
1677
1674
  console.warn("[clips-recorder] mic warm failed:", err);
1678
1675
  });
1679
- const countdownPromise = runRecordingCountdown(true, audioCue);
1676
+ const countdownPromise = runRecordingCountdown(true);
1680
1677
  if (localOnly) {
1681
1678
  id = localFolderName;
1682
1679
  await Promise.all([countdownPromise, warmMic(id)]);
@@ -2456,7 +2453,7 @@ async function startRecordingInner(
2456
2453
  combined,
2457
2454
  });
2458
2455
 
2459
- const countdownPromise = runRecordingCountdown(wantsScreen, audioCue);
2456
+ const countdownPromise = runRecordingCountdown(wantsScreen);
2460
2457
  const localExportPromise = prepareLocalRecordingExport(targets);
2461
2458
  let localExport: Awaited<ReturnType<typeof prepareLocalRecordingExport>>;
2462
2459
  try {
@@ -2632,7 +2629,7 @@ async function startRecordingInner(
2632
2629
  console.log(
2633
2630
  "[clips-recorder] invoking show_countdown + createServerRecording",
2634
2631
  );
2635
- const countdownPromise = runRecordingCountdown(wantsScreen, audioCue);
2632
+ const countdownPromise = runRecordingCountdown(wantsScreen);
2636
2633
  console.time("[clips-recorder] createServerRecording duration");
2637
2634
  const recordingPromise = createServerRecording(
2638
2635
  params.serverUrl,