@agent-native/core 0.153.10 → 0.154.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 (128) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/AGENTS.md +20 -30
  3. package/corpus/templates/analytics/actions/search-dashboard-references.ts +58 -0
  4. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
  5. package/corpus/templates/analytics/app/lib/utils.ts +1 -1
  6. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/ViewSqlPopover.tsx +1 -1
  7. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +2 -0
  8. package/corpus/templates/analytics/server/lib/dashboards-store.ts +366 -3
  9. package/corpus/templates/analytics/server/lib/real-data-actions.ts +3 -1
  10. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -2
  11. package/corpus/templates/calendar/app/components/calendar/EventDialog.tsx +1 -1
  12. package/corpus/templates/calendar/app/lib/utils.ts +1 -1
  13. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +32 -5
  14. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +15 -15
  15. package/corpus/templates/clips/app/i18n/en-US.ts +3 -3
  16. package/corpus/templates/clips/app/lib/utils.ts +5 -6
  17. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +43 -8
  18. package/corpus/templates/clips/desktop/src/app.tsx +26 -12
  19. package/corpus/templates/clips/desktop/src/lib/recorder.ts +23 -9
  20. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +261 -11
  21. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +21 -3
  22. package/corpus/templates/clips/shared/mic-audio-cleanup.ts +350 -0
  23. package/corpus/templates/content/app/i18n-data.ts +10 -10
  24. package/corpus/templates/content/scripts/check-native-deps.mjs +181 -15
  25. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +79 -7
  26. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +14 -2
  27. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +2 -2
  28. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +35 -11
  29. package/corpus/templates/design/actions/get-design-system.ts +103 -3
  30. package/corpus/templates/design/actions/import-design-tokens.ts +37 -8
  31. package/corpus/templates/design/actions/import-github.ts +32 -138
  32. package/corpus/templates/design/actions/index-design-system-with-builder.ts +49 -16
  33. package/corpus/templates/design/actions/present-design-variants.ts +93 -4
  34. package/corpus/templates/design/actions/run-design-audit.ts +229 -0
  35. package/corpus/templates/design/actions/sync-design-system-with-builder.ts +64 -0
  36. package/corpus/templates/design/app/components/design/code-workbench/commands.ts +2 -2
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +17 -4
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +5 -1
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +5 -1
  40. package/corpus/templates/design/app/hooks/use-question-flow.ts +81 -5
  41. package/corpus/templates/design/app/i18n/en-US.ts +1 -1
  42. package/corpus/templates/design/app/i18n-data.ts +32 -1
  43. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +10 -10
  44. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +83 -11
  45. package/corpus/templates/design/app/pages/DesignSystems.tsx +14 -0
  46. package/corpus/templates/design/app/pages/design-editor/generation-prompt-directives.ts +22 -0
  47. package/corpus/templates/design/changelog/2026-08-13-a-screen-that-would-render-behind-an-invisible-alpine-overla.md +6 -0
  48. package/corpus/templates/design/changelog/2026-08-13-attach-a-ui-screenshot-and-the-design-is-reproduced-not-rein.md +6 -0
  49. package/corpus/templates/design/changelog/2026-08-13-linking-a-design-system-means-you-never-get-generic-directio.md +6 -0
  50. package/corpus/templates/design/changelog/2026-08-13-the-design-audit-now-reports-when-a-screen-ignores-its-linke.md +6 -0
  51. package/corpus/templates/design/changelog/2026-08-13-the-design-system-preview-now-shows-how-many-named-tokens-we.md +6 -0
  52. package/corpus/templates/design/changelog/2026-08-13-your-design-system-prompt-and-reference-screenshots-now-reac.md +6 -0
  53. package/corpus/templates/design/server/lib/builder-design-system-proxy.ts +18 -2
  54. package/corpus/templates/design/shared/design-review.ts +1 -0
  55. package/corpus/templates/design/shared/html-integrity.ts +171 -0
  56. package/corpus/templates/mail/app/lib/utils.ts +1 -1
  57. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +10 -6
  58. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +41 -3
  59. package/corpus/templates/slides/actions/sync-design-system-with-builder.ts +64 -0
  60. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  61. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +193 -20
  62. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +58 -33
  63. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +28 -41
  64. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +119 -91
  65. package/corpus/templates/slides/app/components/editor/ImageOverlay.tsx +16 -0
  66. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +158 -31
  67. package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +30 -14
  68. package/corpus/templates/slides/app/components/editor/SpeakerNotesPanel.tsx +2 -2
  69. package/corpus/templates/slides/app/components/editor/canvas-interactions/slides-canvas-adapter.ts +4 -1
  70. package/corpus/templates/slides/app/components/editor/rich-text-selection.ts +14 -0
  71. package/corpus/templates/slides/app/components/editor/slide-text-targets.ts +24 -0
  72. package/corpus/templates/slides/app/global.css +24 -0
  73. package/corpus/templates/slides/app/hooks/use-navigation-state.ts +5 -1
  74. package/corpus/templates/slides/app/i18n/en-US.ts +19 -10
  75. package/corpus/templates/slides/app/lib/deck-filter.ts +47 -0
  76. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +15 -4
  77. package/corpus/templates/slides/app/lib/export-pptx-client.ts +637 -1
  78. package/corpus/templates/slides/app/lib/image-download.ts +84 -0
  79. package/corpus/templates/slides/app/lib/utils.ts +1 -1
  80. package/corpus/templates/slides/app/pages/Index.tsx +25 -9
  81. package/corpus/templates/slides/netlify.toml +1 -0
  82. package/corpus/templates/slides/server/lib/builder-design-system-proxy.ts +14 -2
  83. package/dist/agent/production-agent.js +1 -1
  84. package/dist/brand-kit/types.d.ts +10 -2
  85. package/dist/cli/code.js +1 -1
  86. package/dist/cli/index.js +30 -1
  87. package/dist/cli/native-dependencies.d.ts +24 -0
  88. package/dist/cli/native-dependencies.js +264 -0
  89. package/dist/cli/plan-local.js +1 -1
  90. package/dist/cli/skills-content/design-visual-edit-skill.d.ts +1 -1
  91. package/dist/cli/skills-content/design-visual-edit-skill.js +2 -2
  92. package/dist/cli/visualize-repo.js +1 -1
  93. package/dist/client/FeedbackButton.js +12 -12
  94. package/dist/client/agent-chat-adapter.js +3 -4
  95. package/dist/client/assistant-ui-recovery.d.ts +1 -1
  96. package/dist/client/assistant-ui-recovery.js +4 -0
  97. package/dist/client/chat/message-components.d.ts +11 -3
  98. package/dist/client/chat/message-components.js +31 -5
  99. package/dist/client/chat/tool-call-display.d.ts +2 -1
  100. package/dist/client/chat/tool-call-display.js +33 -15
  101. package/dist/client/db-admin/SqlEditor.js +1 -1
  102. package/dist/client/dev-overlay/DevOverlay.js +2 -2
  103. package/dist/client/extensions/portable-extension.js +4 -0
  104. package/dist/client/guided-questions.d.ts +8 -0
  105. package/dist/client/guided-questions.js +12 -3
  106. package/dist/client/sharing/ShareButton.js +16 -24
  107. package/dist/client/sse-event-processor.js +35 -15
  108. package/dist/client/tool-display.d.ts +9 -0
  109. package/dist/client/tool-display.js +39 -0
  110. package/dist/client/vite-dev-recovery-script.js +1 -1
  111. package/dist/collab/awareness.d.ts +2 -2
  112. package/dist/collab/routes.d.ts +1 -1
  113. package/dist/collab/struct-routes.d.ts +1 -1
  114. package/dist/extensions/html-shell.js +5 -0
  115. package/dist/notifications/routes.d.ts +1 -1
  116. package/dist/observability/routes.d.ts +3 -3
  117. package/dist/resources/handlers.d.ts +1 -1
  118. package/dist/secrets/routes.d.ts +3 -3
  119. package/dist/server/agent-chat/prompt-resources.js +17 -16
  120. package/dist/server/agent-chat-plugin.d.ts +9 -0
  121. package/dist/server/agent-chat-plugin.js +29 -7
  122. package/dist/server/builder-design-systems.d.ts +33 -1
  123. package/dist/server/builder-design-systems.js +293 -11
  124. package/dist/server/design-token-utils.d.ts +11 -0
  125. package/dist/server/design-token-utils.js +68 -16
  126. package/dist/server/index.d.ts +1 -1
  127. package/dist/server/index.js +1 -1
  128. package/package.json +3 -3
@@ -105,6 +105,9 @@ function hasPartialDashboardBuild(
105
105
  export const BOUNDED_STRUCTURED_LOOKUP_GUIDANCE =
106
106
  "BOUNDED STRUCTURED LOOKUP FAST PATH — Treat existing analytics work like an engineer treats existing code: grep before writing. For an ordinary count, aggregate, grouped metric, trend, or record lookup, first call `search-analytics-query-catalog` once with focused metric/entity terms. It searches accessible dashboard names, chart titles/descriptions/saved queries, shipped dashboard patterns, and data-dictionary definitions together. Prefer the strongest approved dictionary or saved-chart match, preserve its source and business logic, adapt only the requested filters and explicit time window, then run one bounded query against that source. A user-named source wins, but still use a matching saved definition when it supplies the source's proven query shape. If there is no useful match, inspect only the most likely source schema or ask one clarification; do not fan out across providers. Do not separately list every dashboard, call data-source status, browse the whole dictionary, load provider catalogs/corpus tools, or query a second source after a strong match. Once the query succeeds, answer immediately with its source, time window, filters, row count, and only necessary caveats. Do not enrich, cross-check, retry, or add breakdowns unless the user requested them, the first query failed, or its result conflicts with the known definition. The words `all`, `total`, or `exact` in a structured aggregate do not by themselves make it a corpus investigation. Never repeat an identical invalid or failed tool call; correct its arguments once or surface the error. This does not waive the real-data requirement: never answer from a guess, stale value, or unverified result. ";
107
107
 
108
+ export const DASHBOARD_REFERENCE_GUIDANCE =
109
+ "DASHBOARD REFERENCE DISCOVERY — When the user asks to replicate, clone, or adapt an existing dashboard, this branch takes precedence over the ordinary metric fast path: call `search-dashboard-references` with focused terms before creating, editing, or querying anything. It searches accessible active saved dashboard ids, names, descriptions, and serialized config with bounded SQL wildcard matches, including legacy saved dashboards. Treat each result as a reference to inspect with `get-sql-dashboard` when `kind` is `sql` or `get-explorer-dashboard` when `kind` is `explorer`, not as proof that its source is authoritative for the new request. Do not automatically route a replication request to first-party Analytics or copy its source semantics without checking the user's requested provider and scope. ";
110
+
108
111
  export const BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE =
109
112
  "BUILT-IN FIRST-PARTY SOURCE — Analytics always provides one built-in first-party source alongside connected external providers such as BigQuery, HubSpot, Gong, Slack, and the other configured integrations. This does not replace or restrict external sources. When `search-analytics-query-catalog` identifies a first-party dashboard/chart definition, preserve its event semantics and use `query-agent-native-analytics` over `analytics_events` or `session_recordings` as appropriate. When the user names an external provider, or the catalog identifies one as authoritative, query that provider instead. Do not report the first-party source as disconnected merely because an external provider is not configured. If the authoritative query returns no rows, report that grounded result with its scope and time window. ";
110
113
 
@@ -146,11 +149,12 @@ export function analyticsSourceGuidanceOpening(): string {
146
149
  // Measured in production: this ran in under 1% of data threads while the
147
150
  // equivalent instruction sat ~7000 words deep. Threads that did call it used
148
151
  // roughly a third the tool calls. Keep it first and keep it imperative.
149
- "START HERE — For any question about a metric, cohort, list, count, or trend, your FIRST tool call is `search-analytics-query-catalog`. This is the analytics equivalent of grepping a codebase before writing new code: someone has very likely already built and saved the query you need, and its saved SQL tells you the exact source, table, and column names so you do not have to discover them. Adapt the closest saved query to the requested filters and time window, run it once, and stop. Only fall back to schema discovery or provider catalogs when the catalog search returns nothing usable — and never run more than one schema-discovery pass before querying. " +
152
+ "START HERE — For an ordinary metric, cohort, list, count, or trend question, your FIRST tool call is `search-analytics-query-catalog`. For a request to replicate, clone, or adapt an existing dashboard, your FIRST tool call is `search-dashboard-references` instead; inspect the returned dashboard before choosing a source or writing anything. The catalog path is the analytics equivalent of grepping a codebase before writing new code: someone has very likely already built and saved the query you need, and its saved SQL tells you the exact source, table, and column names so you do not have to discover them. Adapt the closest saved query to the requested filters and time window, run it once, and stop. Only fall back to schema discovery or provider catalogs when the catalog search returns nothing usable — and never run more than one schema-discovery pass before querying. " +
150
153
  ANALYTICS_CROSS_APP_ROUTING_GUIDANCE +
151
154
  'ONE BOUNDED CALL — List, filter, count, and cohort questions ("which X, excluding Y") are a single query, not a loop. Express the include filter, the exclude filter, and the aggregation in one SQL statement or one `run-code` script that filters server-side. Never page through a cohort across separate tool calls and never fan out per item to apply a filter; that is what turns a ten-second answer into a twenty-minute one. ' +
152
155
  "Apply real-data requirements only when presenting analytics results, source records, or derived metrics. Do not call data-source tools for workflow migration, recurring-job setup, UI/code fixes, settings help, conceptual planning, or other non-data tasks unless the user explicitly asks for data. " +
153
156
  NON_ANALYTICS_REQUEST_GUIDANCE +
157
+ DASHBOARD_REFERENCE_GUIDANCE +
154
158
  BOUNDED_STRUCTURED_LOOKUP_GUIDANCE +
155
159
  BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE +
156
160
  ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE +
@@ -761,7 +765,7 @@ export function realDataFinalGuard(
761
765
  }
762
766
  return {
763
767
  retryMessage:
764
- 'This is a dashboard construction/template-clone request. Resolve the named template\'s id (use `list-sql-dashboards` if you only have a title) and call `get-sql-dashboard` with `includeConfig: true` first. If its panels are `chartType: "extension"`, use `get-extension` then `create-extension` to clone/adapt it, then `update-dashboard` to save the new dashboard. Do not invent SQL panels for an extension-backed template. Ask one clarifying filter question if needed. Only run a data-source query before presenting numbers or authoring invented SQL.',
768
+ 'This is a dashboard construction/template-clone request. First call `search-dashboard-references` with the named template terms. Inspect the matching result with `get-sql-dashboard` when `kind` is `sql` or `get-explorer-dashboard` when `kind` is `explorer`, using full config only when needed. If its panels are `chartType: "extension"`, use `get-extension` then `create-extension` to clone/adapt it, then `update-dashboard` to save the new dashboard. Do not invent SQL panels for an extension-backed template. Ask one clarifying filter question if needed. Only run a data-source query before presenting numbers or authoring invented SQL.',
765
769
  fallbackMessage:
766
770
  "I need to inspect the template dashboard (and its extension, if it uses one) before creating the new one. Tell me the template dashboard name, or confirm the org/account filter, and I'll clone it without inventing metrics.",
767
771
  // Expand the tool surface so a corrective retry can always reach the
@@ -244,7 +244,7 @@ export function EventDialog({
244
244
  </div>
245
245
  <p className="text-xs text-muted-foreground">
246
246
  {t("eventDialog.pressToSave", {
247
- shortcut: `${shortcutModifierLabel()}+↵`,
247
+ shortcut: `${shortcutModifierLabel()} Enter`,
248
248
  })}
249
249
  </p>
250
250
  </div>
@@ -8,5 +8,5 @@ export function isMacPlatform(): boolean {
8
8
  }
9
9
 
10
10
  export function shortcutModifierLabel(): string {
11
- return isMacPlatform() ? "\u2318" : "Ctrl";
11
+ return isMacPlatform() ? "Cmd" : "Ctrl";
12
12
  }
@@ -8,6 +8,10 @@ import {
8
8
  isLikelyPhoneMicLabel,
9
9
  type AudioInputFallback,
10
10
  } from "@shared/media-device-selection";
11
+ import {
12
+ createMicAudioCleanup,
13
+ type MicAudioCleanupHandle,
14
+ } from "@shared/mic-audio-cleanup";
11
15
  import {
12
16
  SCREEN_CAPTURE_FRAME_RATE,
13
17
  screenCaptureDisplayOptions,
@@ -505,6 +509,7 @@ export class RecorderEngine {
505
509
  private cameraComposite: CameraCompositeHandle | null = null;
506
510
  private audioMixCtx: AudioContext | null = null;
507
511
  private audioMixSources: MediaStreamAudioSourceNode[] = [];
512
+ private micAudioCleanup: MicAudioCleanupHandle[] = [];
508
513
  private recorder: MediaRecorder | null = null;
509
514
  private mimeType: string = "video/webm";
510
515
 
@@ -1355,6 +1360,7 @@ export class RecorderEngine {
1355
1360
  // Full stream teardown happens in the finally block below.
1356
1361
  this.cameraLive = false;
1357
1362
  this.audioMixSources = [];
1363
+ this.stopMicAudioCleanup();
1358
1364
  this.audioMixCtx?.close().catch(() => {});
1359
1365
  this.audioMixCtx = null;
1360
1366
  for (const s of [this.cameraStream, this.rawCameraStream, this.micStream]) {
@@ -1801,11 +1807,18 @@ export class RecorderEngine {
1801
1807
  private buildMixedAudioTrack(
1802
1808
  streams: (MediaStream | null | undefined)[],
1803
1809
  ): MediaStreamTrack | null {
1804
- const audioTracks = streams
1810
+ const audioInputs = streams
1805
1811
  .filter((s): s is MediaStream => s != null)
1806
- .flatMap((s) => s.getAudioTracks());
1807
- if (audioTracks.length === 0) return null;
1808
- if (audioTracks.length === 1) return audioTracks[0];
1812
+ .flatMap((stream) =>
1813
+ stream.getAudioTracks().map((track) => ({
1814
+ track,
1815
+ isMicrophone: stream === this.micStream,
1816
+ })),
1817
+ );
1818
+ if (audioInputs.length === 0) return null;
1819
+ if (audioInputs.length === 1 && !audioInputs[0].isMicrophone) {
1820
+ return audioInputs[0].track;
1821
+ }
1809
1822
 
1810
1823
  const ctx = this.audioMixCtx ?? new AudioContext();
1811
1824
  this.audioMixCtx = ctx;
@@ -1814,7 +1827,15 @@ export class RecorderEngine {
1814
1827
  }
1815
1828
  this.audioMixSources = [];
1816
1829
  const dest = ctx.createMediaStreamDestination();
1817
- for (const track of audioTracks) {
1830
+ for (const input of audioInputs) {
1831
+ let track = input.track;
1832
+ if (input.isMicrophone) {
1833
+ const cleanup = createMicAudioCleanup(new MediaStream([track]), {
1834
+ audioContext: ctx,
1835
+ });
1836
+ this.micAudioCleanup.push(cleanup);
1837
+ track = cleanup.stream.getAudioTracks()[0] ?? track;
1838
+ }
1818
1839
  const source = ctx.createMediaStreamSource(new MediaStream([track]));
1819
1840
  source.connect(dest);
1820
1841
  this.audioMixSources.push(source);
@@ -1822,6 +1843,11 @@ export class RecorderEngine {
1822
1843
  return dest.stream.getAudioTracks()[0];
1823
1844
  }
1824
1845
 
1846
+ private stopMicAudioCleanup(): void {
1847
+ for (const cleanup of this.micAudioCleanup) cleanup.stop();
1848
+ this.micAudioCleanup = [];
1849
+ }
1850
+
1825
1851
  private buildCombinedStream(): MediaStream {
1826
1852
  if (this.opts.mode === "screen") {
1827
1853
  return this.buildDisplayRecordingStream();
@@ -2356,6 +2382,7 @@ export class RecorderEngine {
2356
2382
  // aren't mistaken for a disconnect.
2357
2383
  this.cameraLive = false;
2358
2384
  this.audioMixSources = [];
2385
+ this.stopMicAudioCleanup();
2359
2386
  this.audioMixCtx?.close().catch(() => {});
2360
2387
  this.audioMixCtx = null;
2361
2388
  this.cameraComposite?.cleanup();
@@ -6,7 +6,7 @@ import {
6
6
  import { useT } from "@agent-native/core/client/i18n";
7
7
  import {
8
8
  IconCheck,
9
- IconCopy,
9
+ IconLink,
10
10
  IconLock,
11
11
  IconSend2,
12
12
  IconTrash,
@@ -273,10 +273,12 @@ export function CopyField({
273
273
  label,
274
274
  value,
275
275
  disabled,
276
+ description,
276
277
  }: {
277
278
  label: string;
278
279
  value: string;
279
280
  disabled?: boolean;
281
+ description?: string;
280
282
  }) {
281
283
  const t = useT();
282
284
  const [copied, setCopied] = useState(false);
@@ -287,26 +289,24 @@ export function CopyField({
287
289
  setTimeout(() => setCopied(false), 1400);
288
290
  };
289
291
  return (
290
- <div>
291
- {label ? (
292
- <ShareSectionLabel className="mb-1">{label}</ShareSectionLabel>
293
- ) : null}
294
- <div className="flex items-stretch gap-2">
295
- <Input
296
- readOnly
297
- value={value}
298
- className="flex-1 h-9 font-mono text-xs"
299
- />
292
+ <div className="flex items-center justify-between gap-3">
293
+ <div className="min-w-0">
294
+ {label ? <div className="text-sm font-medium">{label}</div> : null}
295
+ {description ? (
296
+ <div className="text-xs text-muted-foreground">{description}</div>
297
+ ) : null}
298
+ </div>
299
+ <div aria-live="polite" className="shrink-0">
300
300
  <Button
301
301
  type="button"
302
302
  variant="outline"
303
- size="icon"
303
+ size="sm"
304
304
  onClick={copy}
305
- aria-label={t("shareUi.copy")}
306
305
  disabled={disabled}
307
- className="h-9 w-9"
306
+ className="h-9"
308
307
  >
309
- {copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
308
+ {copied ? <IconCheck size={14} /> : <IconLink size={14} />}
309
+ {copied ? t("recordRoute.linkCopied") : t("shareUi.copy")}
310
310
  </Button>
311
311
  </div>
312
312
  </div>
@@ -647,13 +647,13 @@ const messages = {
647
647
  collapse: "Collapse",
648
648
  changelogMarkdown: `# Changelog
649
649
 
650
- All notable user-facing changes to Clips are documented here. Open it any time from the command menu (Cmd+K -> "What's new") or from Settings.
650
+ All notable user-facing changes to Clips are documented here. Open it any time from the command menu (Cmd K -> "What's new") or from Settings.
651
651
 
652
652
  ## 2026-06-23
653
653
 
654
654
  ### Added
655
655
 
656
- - See what's new right inside Clips. A changelog now lives in the command menu (Cmd+K) and in Settings.`,
656
+ - See what's new right inside Clips. A changelog now lives in the command menu (Cmd K) and in Settings.`,
657
657
  playback: "Playback",
658
658
  defaultPlaybackSpeed: "Default playback speed",
659
659
  playbackDescription: "Applied automatically when you open a recording.",
@@ -1193,7 +1193,7 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1193
1193
  seekTo: "Seek to {{time}}",
1194
1194
  },
1195
1195
  editorToolbar: {
1196
- undoTooltip: "Undo (Cmd/Ctrl+Z)",
1196
+ undoTooltip: "Undo (Cmd/Ctrl Z)",
1197
1197
  playPauseTooltip: "Play / Pause (Space)",
1198
1198
  sourceDuration: "({{duration}} src)",
1199
1199
  previewSpeed: "Preview speed",
@@ -8,7 +8,7 @@ export function isMacPlatform(): boolean {
8
8
  }
9
9
 
10
10
  export function shortcutModifierLabel(): string {
11
- return isMacPlatform() ? "\u2318" : "Ctrl";
11
+ return isMacPlatform() ? "Cmd" : "Ctrl";
12
12
  }
13
13
 
14
14
  export function shortcutLabel(shortcut: string): string {
@@ -18,14 +18,13 @@ export function shortcutLabel(shortcut: string): string {
18
18
  .map((part) => {
19
19
  const token = part.trim();
20
20
  const lower = token.toLowerCase();
21
- if (lower === "cmd" || lower === "meta") return isMac ? "\u2318" : "Ctrl";
21
+ if (lower === "cmd" || lower === "meta") return isMac ? "Cmd" : "Ctrl";
22
22
  if (lower === "ctrl" || lower === "control") return "Ctrl";
23
- if (lower === "alt" || lower === "option")
24
- return isMac ? "\u2325" : "Alt";
25
- if (lower === "shift") return isMac ? "\u21e7" : "Shift";
23
+ if (lower === "alt" || lower === "option") return "Alt";
24
+ if (lower === "shift") return "Shift";
26
25
  if (lower === "enter") return "Enter";
27
26
  if (lower === "space") return "Space";
28
27
  return token.length === 1 ? token.toUpperCase() : token;
29
28
  })
30
- .join("+");
29
+ .join(" ");
31
30
  }
@@ -22,6 +22,7 @@ import {
22
22
  isLikelyPhoneMicLabel,
23
23
  type AudioInputFallback,
24
24
  } from "@shared/media-device-selection";
25
+ import { createMicAudioCleanup } from "@shared/mic-audio-cleanup";
25
26
  import { scheduleReadyChime } from "@shared/recording-audio";
26
27
  import {
27
28
  SCREEN_CAPTURE_FRAME_RATE,
@@ -210,6 +211,7 @@ type ActiveRecording = {
210
211
  outputStream: MediaStream;
211
212
  sourceStreams: MediaStream[];
212
213
  audioContext: AudioContext | null;
214
+ cleanupAudio: () => void;
213
215
  chunkIndex: number;
214
216
  uploadChain: Promise<void>;
215
217
  uploadPromises: Promise<unknown>[];
@@ -787,17 +789,38 @@ async function buildCompositor(
787
789
 
788
790
  async function createMixedAudio(
789
791
  streams: MediaStream[],
790
- ): Promise<{ audioContext: AudioContext | null; tracks: MediaStreamTrack[] }> {
791
- const audioTracks = streams.flatMap((stream) => stream.getAudioTracks());
792
- if (!audioTracks.length) return { audioContext: null, tracks: [] };
793
- if (audioTracks.length === 1) {
794
- return { audioContext: null, tracks: audioTracks };
792
+ microphoneStream: MediaStream | null,
793
+ ): Promise<{
794
+ audioContext: AudioContext | null;
795
+ tracks: MediaStreamTrack[];
796
+ cleanup: () => void;
797
+ }> {
798
+ const audioInputs = streams.flatMap((stream) =>
799
+ stream.getAudioTracks().map((track) => ({
800
+ track,
801
+ isMicrophone: stream === microphoneStream,
802
+ })),
803
+ );
804
+ if (!audioInputs.length) {
805
+ return { audioContext: null, tracks: [], cleanup() {} };
806
+ }
807
+ if (audioInputs.length === 1 && !audioInputs[0].isMicrophone) {
808
+ return { audioContext: null, tracks: [audioInputs[0].track], cleanup() {} };
795
809
  }
796
810
 
797
811
  const audioContext = new AudioContext();
798
812
  await audioContext.resume().catch(() => undefined);
799
813
  const destination = audioContext.createMediaStreamDestination();
800
- for (const track of audioTracks) {
814
+ const micCleanup: Array<{ stop: () => void }> = [];
815
+ for (const input of audioInputs) {
816
+ let track = input.track;
817
+ if (input.isMicrophone) {
818
+ const cleanup = createMicAudioCleanup(new MediaStream([track]), {
819
+ audioContext,
820
+ });
821
+ micCleanup.push(cleanup);
822
+ track = cleanup.stream.getAudioTracks()[0] ?? track;
823
+ }
801
824
  // One source per track (not per stream) so each input is isolated in the
802
825
  // mix graph and can be detached independently.
803
826
  const source = audioContext.createMediaStreamSource(
@@ -817,7 +840,16 @@ async function createMixedAudio(
817
840
  }
818
841
  });
819
842
  }
820
- return { audioContext, tracks: destination.stream.getAudioTracks() };
843
+ let cleanedUp = false;
844
+ return {
845
+ audioContext,
846
+ tracks: destination.stream.getAudioTracks(),
847
+ cleanup() {
848
+ if (cleanedUp) return;
849
+ cleanedUp = true;
850
+ for (const cleanup of micCleanup) cleanup.stop();
851
+ },
852
+ };
821
853
  }
822
854
 
823
855
  async function uploadChunk(
@@ -1081,6 +1113,7 @@ function disposePrepared(): void {
1081
1113
 
1082
1114
  function cleanup(recording: ActiveRecording): void {
1083
1115
  recording.stopCompositor?.();
1116
+ recording.cleanupAudio();
1084
1117
  stopStreams([recording.outputStream, ...recording.sourceStreams]);
1085
1118
  void recording.audioContext?.close().catch(() => undefined);
1086
1119
  }
@@ -1215,7 +1248,7 @@ async function begin(message: BeginMessage): Promise<{
1215
1248
  ? [ready.cameraStream]
1216
1249
  : []),
1217
1250
  ];
1218
- const mixedAudio = await createMixedAudio(audioInputs);
1251
+ const mixedAudio = await createMixedAudio(audioInputs, ready.micStream);
1219
1252
 
1220
1253
  const outputStream = new MediaStream([videoTrack, ...mixedAudio.tracks]);
1221
1254
  const mimeType = pickMimeType() || "video/webm";
@@ -1257,6 +1290,7 @@ async function begin(message: BeginMessage): Promise<{
1257
1290
  ...(compositor ? [compositor.canvasStream] : []),
1258
1291
  ],
1259
1292
  audioContext: mixedAudio.audioContext,
1293
+ cleanupAudio: mixedAudio.cleanup,
1260
1294
  chunkIndex: 0,
1261
1295
  uploadChain: Promise.resolve(),
1262
1296
  uploadPromises: [],
@@ -1802,6 +1836,7 @@ async function restart(
1802
1836
  // Tear down the old compositor + mixing context; keep the capture tracks alive
1803
1837
  // so the next BEGIN can build a fresh compositor/recorder on the same streams.
1804
1838
  recording.stopCompositor?.();
1839
+ recording.cleanupAudio();
1805
1840
  void recording.audioContext?.close().catch(() => undefined);
1806
1841
  activeRecording = null;
1807
1842
 
@@ -739,6 +739,14 @@ function meetingCanStartNotes(meeting: PopoverMeeting): boolean {
739
739
  );
740
740
  }
741
741
 
742
+ function humanReadableShortcutLabel(shortcut: string): string {
743
+ return shortcut
744
+ .split("+")
745
+ .map((token) => token.trim())
746
+ .filter(Boolean)
747
+ .join(" ");
748
+ }
749
+
742
750
  function voiceShortcutLabel(
743
751
  shortcut: VoiceShortcutPreference,
744
752
  customShortcut: string,
@@ -747,13 +755,15 @@ function voiceShortcutLabel(
747
755
  case "fn":
748
756
  return "Fn";
749
757
  case "cmd-shift-space":
750
- return "Cmd+Shift+Space";
758
+ return "Cmd Shift Space";
751
759
  case "ctrl-shift-space":
752
- return "Ctrl+Shift+Space";
760
+ return "Ctrl Shift Space";
753
761
  case "custom":
754
- return customShortcut || "Custom shortcut";
762
+ return customShortcut
763
+ ? humanReadableShortcutLabel(customShortcut)
764
+ : "Custom shortcut";
755
765
  case "both":
756
- return "Fn, Cmd+Shift+Space, or Ctrl+Shift+Space";
766
+ return "Fn, Cmd Shift Space, or Ctrl Shift Space";
757
767
  }
758
768
  }
759
769
 
@@ -5983,10 +5993,10 @@ function Setup({
5983
5993
  const shortcutHint: Record<VoiceShortcutPreference, string> = {
5984
5994
  fn: "Press the Fn / globe key to dictate. macOS requires Input Monitoring for this one shortcut.",
5985
5995
  "cmd-shift-space":
5986
- "Press Cmd+Shift+Space to dictate. This does not need Input Monitoring.",
5987
- "ctrl-shift-space": "Press Ctrl+Shift+Space to dictate.",
5996
+ "Press Cmd Shift Space to dictate. This does not need Input Monitoring.",
5997
+ "ctrl-shift-space": "Press Ctrl Shift Space to dictate.",
5988
5998
  custom: `Press ${voiceCustomShortcut || "your recorded shortcut"} to dictate.`,
5989
- both: "Any of Fn, Cmd+Shift+Space, or Ctrl+Shift+Space. Includes Fn, so macOS may ask for Input Monitoring.",
5999
+ both: "Any of Fn, Cmd Shift Space, or Ctrl Shift Space. Includes Fn, so macOS may ask for Input Monitoring.",
5990
6000
  };
5991
6001
  const fnShortcutSelected = voiceShortcut === "fn" || voiceShortcut === "both";
5992
6002
  const modeHint: Record<VoiceMode, string> = {
@@ -7373,7 +7383,7 @@ function Setup({
7373
7383
  />
7374
7384
  <DesktopSettingsRow
7375
7385
  label="Open Clips"
7376
- description="Open the tray popover; Cmd+Shift+L remains available."
7386
+ description="Open the tray popover; Cmd Shift L remains available."
7377
7387
  control={
7378
7388
  <ShortcutRecorder
7379
7389
  value={popoverCustomShortcut}
@@ -7383,7 +7393,7 @@ function Setup({
7383
7393
  }
7384
7394
  >
7385
7395
  <>
7386
- <span>Leave this empty to use only Cmd+Shift+L.</span>
7396
+ <span>Leave this empty to use only Cmd Shift L.</span>
7387
7397
  {shortcutRegistrationError ? (
7388
7398
  <span className="setup-warning">
7389
7399
  {shortcutRegistrationError}
@@ -7707,8 +7717,8 @@ function Setup({
7707
7717
  )
7708
7718
  }
7709
7719
  >
7710
- <option value="cmd-shift-space">Cmd+Shift+Space</option>
7711
- <option value="ctrl-shift-space">Ctrl+Shift+Space</option>
7720
+ <option value="cmd-shift-space">Cmd Shift Space</option>
7721
+ <option value="ctrl-shift-space">Ctrl Shift Space</option>
7712
7722
  <option value="custom">Custom shortcut</option>
7713
7723
  <option value="fn">
7714
7724
  Fn (globe, needs Input Monitoring)
@@ -8239,7 +8249,11 @@ function ShortcutRecorder({
8239
8249
  event.stopPropagation();
8240
8250
  }}
8241
8251
  >
8242
- {recording ? "Press shortcut..." : value || placeholder}
8252
+ {recording
8253
+ ? "Press shortcut..."
8254
+ : value
8255
+ ? humanReadableShortcutLabel(value)
8256
+ : placeholder}
8243
8257
  </button>
8244
8258
  {value ? (
8245
8259
  <button
@@ -55,6 +55,10 @@ import {
55
55
  waitForAcceptedRecordingAfterFinalizeError,
56
56
  waitForReadyRecordingAfterFinalizeError,
57
57
  } from "../../../shared/finalize-recovery";
58
+ import {
59
+ createMicAudioCleanup,
60
+ type MicAudioCleanupHandle,
61
+ } from "../../../shared/mic-audio-cleanup";
58
62
  import type { LocalRecordingMode } from "../shared/config";
59
63
  import { createAudioCue, type AudioCue } from "./audio-cue";
60
64
  import { createCameraCompositeStream } from "./camera-composite";
@@ -541,35 +545,45 @@ interface RecordingAudio {
541
545
  }
542
546
 
543
547
  /**
544
- * Build the audio track(s) for the recording. When BOTH a mic track and a
545
- * system/display-audio track are present they're mixed into a single track via
546
- * WebAudio (one audio track keeps players + our finalize step happy). With only
547
- * one source we pass it through untouched.
548
+ * Build the audio track(s) for the recording. Microphone tracks pass through
549
+ * the cleanup graph whether or not system/display audio is also present, then
550
+ * both sources are mixed into one track when needed.
548
551
  */
549
552
  function buildRecordingAudio(
550
553
  micTracks: MediaStreamTrack[],
551
554
  systemTracks: MediaStreamTrack[],
552
555
  ): RecordingAudio {
553
- if (!micTracks.length || !systemTracks.length) {
554
- // 0 or 1 source — no mixing needed (prefer mic when it's the only one).
556
+ if (!micTracks.length) {
557
+ // System-only audio keeps its original stereo signal; there is no
558
+ // microphone path to clean in this branch.
555
559
  return {
556
- tracks: micTracks.length ? micTracks : systemTracks,
560
+ tracks: systemTracks,
557
561
  cleanup() {},
558
562
  };
559
563
  }
560
564
  const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
561
565
  if (!AudioCtx) {
562
- // No WebAudio — fall back to mic only.
566
+ // No WebAudio — keep the capture alive with the raw microphone signal.
563
567
  return { tracks: micTracks, cleanup() {} };
564
568
  }
565
569
  const ctx: AudioContext = new AudioCtx();
566
570
  const destination = ctx.createMediaStreamDestination();
567
- for (const tracks of [micTracks, systemTracks]) {
571
+ const micCleanup: MicAudioCleanupHandle[] = [];
572
+ const cleanedMicTracks = micTracks.map((track) => {
573
+ const cleanup = createMicAudioCleanup(new MediaStream([track]), {
574
+ audioContext: ctx,
575
+ });
576
+ micCleanup.push(cleanup);
577
+ return cleanup.stream.getAudioTracks()[0] ?? track;
578
+ });
579
+ for (const tracks of [cleanedMicTracks, systemTracks]) {
580
+ if (!tracks.length) continue;
568
581
  ctx.createMediaStreamSource(new MediaStream(tracks)).connect(destination);
569
582
  }
570
583
  return {
571
584
  tracks: destination.stream.getAudioTracks(),
572
585
  cleanup() {
586
+ for (const cleanup of micCleanup) cleanup.stop();
573
587
  ctx.close().catch(() => {});
574
588
  },
575
589
  };