@agent-native/core 0.68.3 → 0.70.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 (212) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +54 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/changelog/parse.ts +245 -0
  5. package/corpus/core/src/cli/changelog.ts +199 -0
  6. package/corpus/core/src/cli/index.ts +19 -0
  7. package/corpus/core/src/cli/recap.ts +24 -1
  8. package/corpus/core/src/cli/skills.ts +72 -45
  9. package/corpus/core/src/client/CommandMenu.tsx +183 -75
  10. package/corpus/core/src/client/agent-chat-adapter.ts +44 -7
  11. package/corpus/core/src/client/analytics.ts +181 -0
  12. package/corpus/core/src/client/changelog/Changelog.tsx +321 -0
  13. package/corpus/core/src/client/index.ts +11 -0
  14. package/corpus/core/src/client/mcp-apps/McpAppRenderer.tsx +54 -0
  15. package/corpus/core/src/client/settings/useBuilderStatus.ts +82 -20
  16. package/corpus/core/src/extensions/web-search-tool.ts +45 -4
  17. package/corpus/core/src/mcp/build-server.ts +1 -1
  18. package/corpus/core/src/mcp/embed-app.ts +112 -6
  19. package/corpus/core/src/secrets/register-framework-secrets.ts +7 -0
  20. package/corpus/core/src/server/attribution.ts +211 -0
  21. package/corpus/core/src/server/auth.ts +6 -0
  22. package/corpus/core/src/server/better-auth-instance.ts +49 -5
  23. package/corpus/core/src/server/builder-browser.ts +27 -1
  24. package/corpus/core/src/server/core-routes-plugin.ts +58 -0
  25. package/corpus/core/src/server/google-oauth.ts +19 -6
  26. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  27. package/corpus/core/src/styles/agent-conversation.css +21 -0
  28. package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  29. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  30. package/corpus/templates/analytics/CHANGELOG.md +10 -0
  31. package/corpus/templates/analytics/actions/install-dashboard-template.ts +2 -2
  32. package/corpus/templates/analytics/actions/open-traffic-dashboard.ts +1 -1
  33. package/corpus/templates/analytics/actions/update-dashboard.ts +5 -3
  34. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +180 -144
  35. package/corpus/templates/analytics/app/components/layout/Header.tsx +2 -2
  36. package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -5
  37. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +221 -213
  38. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +6 -3
  39. package/corpus/templates/analytics/app/lib/demo-dashboard-path.ts +1 -1
  40. package/corpus/templates/analytics/app/lib/last-opened.ts +2 -1
  41. package/corpus/templates/analytics/app/pages/Settings.tsx +4 -0
  42. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -3
  43. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +2 -2
  44. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +101 -99
  45. package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +1 -1
  46. package/corpus/templates/analytics/app/routes/adhoc.$id.tsx +18 -5
  47. package/corpus/templates/analytics/app/routes/catalog.tsx +1 -1
  48. package/corpus/templates/analytics/app/routes/dashboards.$id.tsx +9 -0
  49. package/corpus/templates/analytics/app/routes/traffic.tsx +1 -1
  50. package/corpus/templates/analytics/changelog/2026-06-23-the-dashboards-and-analyses-sidebar-settings-buttons-now-sta.md +6 -0
  51. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +145 -0
  52. package/corpus/templates/analytics/server/db/index.ts +1 -1
  53. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +9 -2
  54. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +1 -1
  55. package/corpus/templates/analytics/server/plugins/agent-chat.ts +2 -2
  56. package/corpus/templates/analytics/server/plugins/core-routes.ts +6 -6
  57. package/corpus/templates/assets/CHANGELOG.md +10 -0
  58. package/corpus/templates/assets/app/root.tsx +7 -1
  59. package/corpus/templates/assets/app/routes/settings.tsx +6 -0
  60. package/corpus/templates/brain/CHANGELOG.md +10 -0
  61. package/corpus/templates/brain/app/root.tsx +7 -1
  62. package/corpus/templates/brain/app/routes/settings.tsx +8 -1
  63. package/corpus/templates/calendar/CHANGELOG.md +10 -0
  64. package/corpus/templates/calendar/app/pages/Settings.tsx +4 -0
  65. package/corpus/templates/calendar/app/root.tsx +7 -1
  66. package/corpus/templates/chat/CHANGELOG.md +10 -0
  67. package/corpus/templates/chat/app/root.tsx +7 -1
  68. package/corpus/templates/clips/CHANGELOG.md +10 -0
  69. package/corpus/templates/clips/app/components/player/share-dialog.tsx +33 -7
  70. package/corpus/templates/clips/app/components/player/sign-in-prompt-dialog.tsx +8 -0
  71. package/corpus/templates/clips/app/components/player/video-player.tsx +150 -20
  72. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +72 -20
  73. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +69 -2
  74. package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +71 -0
  75. package/corpus/templates/clips/app/lib/recorder-preferences.ts +67 -0
  76. package/corpus/templates/clips/app/root.tsx +7 -1
  77. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +15 -1
  78. package/corpus/templates/clips/app/routes/_app.trash.tsx +21 -0
  79. package/corpus/templates/clips/app/routes/record.tsx +92 -8
  80. package/corpus/templates/clips/app/routes/share.$shareId.tsx +86 -5
  81. package/corpus/templates/clips/changelog/2026-06-23-clips-shared-in-slack-no-longer-show-a-could-not-start-playb.md +6 -0
  82. package/corpus/templates/clips/changelog/2026-06-23-made-the-play-button-on-shared-and-embedded-clips-scale-with.md +6 -0
  83. package/corpus/templates/clips/changelog/2026-06-23-public-clip-share-links-now-show-whether-they-ll-play-inline.md +6 -0
  84. package/corpus/templates/clips/changelog/2026-06-23-screen-camera-recordings-now-keep-the-presenter-s-face-in-th.md +6 -0
  85. package/corpus/templates/clips/desktop/src/lib/recorder.ts +20 -0
  86. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  87. package/corpus/templates/clips/desktop/src/overlays/region-record-border.tsx +76 -0
  88. package/corpus/templates/clips/desktop/src/styles.css +49 -0
  89. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
  90. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +67 -1
  91. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -0
  92. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +4 -1
  93. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +51 -1
  94. package/corpus/templates/clips/shared/share-attribution.ts +79 -0
  95. package/corpus/templates/content/CHANGELOG.md +10 -0
  96. package/corpus/templates/content/app/root.tsx +7 -1
  97. package/corpus/templates/content/vitest.config.ts +3 -0
  98. package/corpus/templates/design/CHANGELOG.md +10 -0
  99. package/corpus/templates/design/actions/generate-design.ts +4 -0
  100. package/corpus/templates/design/app/root.tsx +7 -1
  101. package/corpus/templates/design/app/routes/settings.tsx +10 -2
  102. package/corpus/templates/dispatch/CHANGELOG.md +10 -0
  103. package/corpus/templates/dispatch/app/root.tsx +7 -1
  104. package/corpus/templates/forms/CHANGELOG.md +10 -0
  105. package/corpus/templates/forms/app/root.tsx +7 -1
  106. package/corpus/templates/macros/CHANGELOG.md +10 -0
  107. package/corpus/templates/macros/app/root.tsx +7 -1
  108. package/corpus/templates/mail/CHANGELOG.md +10 -0
  109. package/corpus/templates/mail/app/components/layout/CommandPalette.tsx +3 -0
  110. package/corpus/templates/mail/app/pages/SettingsPage.tsx +27 -0
  111. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +31 -17
  112. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -4
  113. package/corpus/templates/plan/.agents/skills/visual-recap/SKILL.md +34 -24
  114. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -4
  115. package/corpus/templates/plan/AGENTS.md +4 -0
  116. package/corpus/templates/plan/CHANGELOG.md +10 -0
  117. package/corpus/templates/plan/actions/view-screen.ts +5 -0
  118. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +98 -1
  119. package/corpus/templates/plan/app/pages/PlansPage.tsx +107 -7
  120. package/corpus/templates/plan/app/root.tsx +7 -1
  121. package/corpus/templates/plan/shared/share-attribution.ts +72 -0
  122. package/corpus/templates/slides/CHANGELOG.md +10 -0
  123. package/corpus/templates/slides/app/root.tsx +7 -1
  124. package/corpus/templates/videos/CHANGELOG.md +4 -151
  125. package/corpus/templates/videos/app/root.tsx +7 -1
  126. package/dist/changelog/parse.d.ts +70 -0
  127. package/dist/changelog/parse.d.ts.map +1 -0
  128. package/dist/changelog/parse.js +200 -0
  129. package/dist/changelog/parse.js.map +1 -0
  130. package/dist/cli/changelog.d.ts +4 -0
  131. package/dist/cli/changelog.d.ts.map +1 -0
  132. package/dist/cli/changelog.js +166 -0
  133. package/dist/cli/changelog.js.map +1 -0
  134. package/dist/cli/index.js +18 -0
  135. package/dist/cli/index.js.map +1 -1
  136. package/dist/cli/recap.d.ts.map +1 -1
  137. package/dist/cli/recap.js +22 -1
  138. package/dist/cli/recap.js.map +1 -1
  139. package/dist/cli/skills.d.ts +3 -3
  140. package/dist/cli/skills.d.ts.map +1 -1
  141. package/dist/cli/skills.js +72 -45
  142. package/dist/cli/skills.js.map +1 -1
  143. package/dist/client/CommandMenu.d.ts +17 -1
  144. package/dist/client/CommandMenu.d.ts.map +1 -1
  145. package/dist/client/CommandMenu.js +49 -13
  146. package/dist/client/CommandMenu.js.map +1 -1
  147. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  148. package/dist/client/agent-chat-adapter.js +43 -6
  149. package/dist/client/agent-chat-adapter.js.map +1 -1
  150. package/dist/client/analytics.d.ts +18 -0
  151. package/dist/client/analytics.d.ts.map +1 -1
  152. package/dist/client/analytics.js +177 -0
  153. package/dist/client/analytics.js.map +1 -1
  154. package/dist/client/changelog/Changelog.d.ts +47 -0
  155. package/dist/client/changelog/Changelog.d.ts.map +1 -0
  156. package/dist/client/changelog/Changelog.js +138 -0
  157. package/dist/client/changelog/Changelog.js.map +1 -0
  158. package/dist/client/index.d.ts +2 -1
  159. package/dist/client/index.d.ts.map +1 -1
  160. package/dist/client/index.js +2 -1
  161. package/dist/client/index.js.map +1 -1
  162. package/dist/client/mcp-apps/McpAppRenderer.d.ts +1 -0
  163. package/dist/client/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  164. package/dist/client/mcp-apps/McpAppRenderer.js +42 -1
  165. package/dist/client/mcp-apps/McpAppRenderer.js.map +1 -1
  166. package/dist/client/settings/useBuilderStatus.d.ts +2 -0
  167. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  168. package/dist/client/settings/useBuilderStatus.js +58 -10
  169. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  170. package/dist/extensions/web-search-tool.d.ts +5 -3
  171. package/dist/extensions/web-search-tool.d.ts.map +1 -1
  172. package/dist/extensions/web-search-tool.js +36 -4
  173. package/dist/extensions/web-search-tool.js.map +1 -1
  174. package/dist/mcp/build-server.js +1 -1
  175. package/dist/mcp/build-server.js.map +1 -1
  176. package/dist/mcp/embed-app.d.ts.map +1 -1
  177. package/dist/mcp/embed-app.js +112 -6
  178. package/dist/mcp/embed-app.js.map +1 -1
  179. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  180. package/dist/secrets/register-framework-secrets.js +6 -0
  181. package/dist/secrets/register-framework-secrets.js.map +1 -1
  182. package/dist/server/attribution.d.ts +83 -0
  183. package/dist/server/attribution.d.ts.map +1 -0
  184. package/dist/server/attribution.js +178 -0
  185. package/dist/server/attribution.js.map +1 -0
  186. package/dist/server/auth.d.ts.map +1 -1
  187. package/dist/server/auth.js +7 -0
  188. package/dist/server/auth.js.map +1 -1
  189. package/dist/server/better-auth-instance.d.ts +9 -1
  190. package/dist/server/better-auth-instance.d.ts.map +1 -1
  191. package/dist/server/better-auth-instance.js +32 -2
  192. package/dist/server/better-auth-instance.js.map +1 -1
  193. package/dist/server/builder-browser.d.ts +4 -0
  194. package/dist/server/builder-browser.d.ts.map +1 -1
  195. package/dist/server/builder-browser.js +20 -1
  196. package/dist/server/builder-browser.js.map +1 -1
  197. package/dist/server/core-routes-plugin.d.ts +25 -0
  198. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  199. package/dist/server/core-routes-plugin.js +38 -0
  200. package/dist/server/core-routes-plugin.js.map +1 -1
  201. package/dist/server/google-oauth.d.ts.map +1 -1
  202. package/dist/server/google-oauth.js +16 -2
  203. package/dist/server/google-oauth.js.map +1 -1
  204. package/dist/shared/mcp-embed-headers.js +1 -1
  205. package/dist/shared/mcp-embed-headers.js.map +1 -1
  206. package/dist/styles/agent-conversation.css +21 -0
  207. package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  208. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  209. package/package.json +1 -1
  210. package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  211. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  212. /package/corpus/templates/analytics/app/routes/{dashboards.tsx → dashboards._index.tsx} +0 -0
@@ -186,6 +186,17 @@ function errorName(err: unknown): string {
186
186
  return (err as { name?: string } | null)?.name ?? "";
187
187
  }
188
188
 
189
+ // getUserMedia failed because the requested device is gone (unplugged / stale
190
+ // saved id), not a permission error — recoverable by retrying with the default.
191
+ function isDeviceUnavailableError(err: unknown): boolean {
192
+ const name = errorName(err);
193
+ return (
194
+ name === "OverconstrainedError" ||
195
+ name === "NotFoundError" ||
196
+ name === "DevicesNotFoundError"
197
+ );
198
+ }
199
+
189
200
  function errorMessage(err: unknown): string {
190
201
  return err instanceof Error ? err.message : String(err || "Unknown error");
191
202
  }
@@ -409,6 +420,13 @@ export class RecorderEngine {
409
420
  */
410
421
  private cameraDisconnectNotified = false;
411
422
  private micDisconnectNotified = false;
423
+ /**
424
+ * Set when a stale/unavailable `micDeviceId` forces a fallback to the system
425
+ * default mic during `acquire()`. The recording then runs on the default
426
+ * device, so live transcription (which can only ever use the default mic) is
427
+ * safe to start even though a specific mic was originally requested.
428
+ */
429
+ private micFellBackToDefault = false;
412
430
 
413
431
  private state: RecorderState = "idle";
414
432
 
@@ -437,10 +455,30 @@ export class RecorderEngine {
437
455
  return this.cameraStream;
438
456
  }
439
457
 
458
+ /**
459
+ * True when `acquire()` had to fall back from the requested `micDeviceId` to
460
+ * the system default mic. Lets the UI start live transcription for this
461
+ * session even though a specific mic was originally selected.
462
+ */
463
+ didMicFallBackToDefault(): boolean {
464
+ return this.micFellBackToDefault;
465
+ }
466
+
440
467
  getPreviewStream(): MediaStream | null {
441
468
  return this.previewStream;
442
469
  }
443
470
 
471
+ /**
472
+ * The composited screen+camera canvas stream that actually gets recorded
473
+ * (screen with the camera bubble drawn in), or `null` for screen-only /
474
+ * camera-only modes where the visible preview already matches the recording.
475
+ * Used to grab a thumbnail that includes the presenter's camera — the raw
476
+ * preview stream in screen+camera mode is screen-only and has no face.
477
+ */
478
+ getCompositeStream(): MediaStream | null {
479
+ return this.cameraComposite?.stream ?? null;
480
+ }
481
+
444
482
  getElapsedMs(): number {
445
483
  if (this.startedAtMs === null) return 0;
446
484
  const now = performance.now();
@@ -504,6 +542,7 @@ export class RecorderEngine {
504
542
  const wantsCamera =
505
543
  this.opts.mode === "camera" || this.opts.mode === "screen+camera";
506
544
  const wantsMic = this.opts.micDeviceId !== NO_MIC_DEVICE_ID;
545
+ this.micFellBackToDefault = false;
507
546
 
508
547
  try {
509
548
  if (!isBrowserSecureContext()) {
@@ -592,7 +631,20 @@ export class RecorderEngine {
592
631
  audio: false,
593
632
  });
594
633
  } catch (err) {
595
- throw this.friendlyError(err, "camera");
634
+ // Stale/unplugged cameraDeviceId — retry with the default camera
635
+ // instead of failing the recording.
636
+ if (this.opts.cameraDeviceId && isDeviceUnavailableError(err)) {
637
+ try {
638
+ this.cameraStream = await navigator.mediaDevices.getUserMedia({
639
+ video: true,
640
+ audio: false,
641
+ });
642
+ } catch (retryErr) {
643
+ throw this.friendlyError(retryErr, "camera");
644
+ }
645
+ } else {
646
+ throw this.friendlyError(err, "camera");
647
+ }
596
648
  }
597
649
  }
598
650
 
@@ -603,7 +655,22 @@ export class RecorderEngine {
603
655
  video: false,
604
656
  });
605
657
  } catch (err) {
606
- throw this.friendlyError(err, "microphone");
658
+ // Same stale-device fallback as the camera — retry default mic.
659
+ if (this.opts.micDeviceId && isDeviceUnavailableError(err)) {
660
+ try {
661
+ this.micStream = await navigator.mediaDevices.getUserMedia({
662
+ audio: voiceFocusedAudioConstraints(),
663
+ video: false,
664
+ });
665
+ // Recording is now on the system default mic, so live
666
+ // transcription can run for this session after all.
667
+ this.micFellBackToDefault = true;
668
+ } catch (retryErr) {
669
+ throw this.friendlyError(retryErr, "microphone");
670
+ }
671
+ } else {
672
+ throw this.friendlyError(err, "microphone");
673
+ }
607
674
  }
608
675
  }
609
676
 
@@ -0,0 +1,71 @@
1
+ import { IconBrandSlack } from "@tabler/icons-react";
2
+ import { appPath, useActionQuery } from "@agent-native/core/client";
3
+
4
+ interface SlackInstallation {
5
+ status: string;
6
+ }
7
+
8
+ interface SlackInstallationsResponse {
9
+ oauthConfigured: boolean;
10
+ installations: SlackInstallation[];
11
+ }
12
+
13
+ /**
14
+ * Contextual nudge shown in the share popover's Link tab for public clips.
15
+ *
16
+ * A public Clips link unfurls into a playable video when pasted in a connected
17
+ * Slack workspace, but that integration is otherwise only discoverable in
18
+ * Settings. When a workspace is connected we confirm the payoff; when none is,
19
+ * clip owners/admins get a one-click link to connect one in Settings.
20
+ *
21
+ * Only rendered for public clips (the caller gates on `isPublic`), so the
22
+ * `list-slack-installations` query never runs for private shares.
23
+ */
24
+ export function SlackShareHint({ canManage }: { canManage: boolean }) {
25
+ const slack = useActionQuery<SlackInstallationsResponse>(
26
+ "list-slack-installations",
27
+ undefined,
28
+ { retry: false },
29
+ );
30
+
31
+ const data = slack.data;
32
+ const connected =
33
+ data?.installations?.some((i) => i.status === "connected") ?? false;
34
+
35
+ if (connected) {
36
+ return (
37
+ <div className="flex items-center gap-2.5 rounded-md border border-border bg-muted/40 px-3 py-2">
38
+ <IconBrandSlack className="h-4 w-4 shrink-0 text-muted-foreground" />
39
+ <div className="min-w-0">
40
+ <div className="text-xs font-medium">Plays inline in Slack</div>
41
+ <p className="text-[11px] text-muted-foreground">
42
+ Paste this link in any connected workspace to play it inline.
43
+ </p>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ // No workspace connected yet. Connecting needs the deployment's Slack OAuth
50
+ // credentials, so only point managers at Settings when it's actually set up —
51
+ // otherwise the Connect button there is disabled and the link is a dead end.
52
+ // While the query is loading or errored, `data` is undefined and we render
53
+ // nothing rather than guess at the workspace state.
54
+ if (!data || !canManage || !data.oauthConfigured) return null;
55
+
56
+ return (
57
+ <a
58
+ href={appPath("/settings#slack")}
59
+ className="flex items-center gap-2.5 rounded-md border border-border px-3 py-2 transition-colors hover:bg-muted/50"
60
+ >
61
+ <IconBrandSlack className="h-4 w-4 shrink-0 text-muted-foreground" />
62
+ <div className="min-w-0 flex-1">
63
+ <div className="text-xs font-medium">Make it play inline in Slack</div>
64
+ <p className="text-[11px] text-muted-foreground">
65
+ Connect a workspace so this link unfurls as a video.
66
+ </p>
67
+ </div>
68
+ <span className="shrink-0 text-xs font-medium text-primary">Connect</span>
69
+ </a>
70
+ );
71
+ }
@@ -0,0 +1,67 @@
1
+ import type { CameraBubbleSize } from "@/components/recorder/camera-bubble";
2
+ import type {
3
+ DisplaySurface,
4
+ RecordingMode,
5
+ } from "@/components/recorder/recorder-engine";
6
+
7
+ /**
8
+ * Last-used recorder selections, remembered across `/record` visits via
9
+ * localStorage so the panel doesn't reset to defaults every time. Device ids
10
+ * are best-effort: a remembered mic/camera that no longer exists (machine
11
+ * changed, permission revoked) falls back to "default" in the panel, so we
12
+ * never feed a stale `deviceId: { exact }` to getUserMedia.
13
+ *
14
+ * `?mode=` / `?surface=` URL params still win over a saved preference — those
15
+ * are explicit deep-link intents (e.g. the agent navigating to a specific
16
+ * capture mode).
17
+ */
18
+ export interface RecorderPreferences {
19
+ mode: RecordingMode;
20
+ displaySurface: DisplaySurface;
21
+ micId: string;
22
+ cameraId: string;
23
+ cameraSize: CameraBubbleSize;
24
+ }
25
+
26
+ const STORAGE_KEY = "clips:recorder-preferences";
27
+
28
+ const VALID_MODES: RecordingMode[] = ["screen", "camera", "screen+camera"];
29
+ const VALID_SURFACES: DisplaySurface[] = ["monitor", "window", "browser"];
30
+ const VALID_SIZES: CameraBubbleSize[] = ["sm", "md", "lg"];
31
+
32
+ export function loadRecorderPreferences(): Partial<RecorderPreferences> {
33
+ if (typeof window === "undefined") return {};
34
+ try {
35
+ const raw = window.localStorage.getItem(STORAGE_KEY);
36
+ if (!raw) return {};
37
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
38
+ const prefs: Partial<RecorderPreferences> = {};
39
+ if (VALID_MODES.includes(parsed.mode as RecordingMode)) {
40
+ prefs.mode = parsed.mode as RecordingMode;
41
+ }
42
+ if (VALID_SURFACES.includes(parsed.displaySurface as DisplaySurface)) {
43
+ prefs.displaySurface = parsed.displaySurface as DisplaySurface;
44
+ }
45
+ if (typeof parsed.micId === "string") prefs.micId = parsed.micId;
46
+ if (typeof parsed.cameraId === "string") prefs.cameraId = parsed.cameraId;
47
+ if (VALID_SIZES.includes(parsed.cameraSize as CameraBubbleSize)) {
48
+ prefs.cameraSize = parsed.cameraSize as CameraBubbleSize;
49
+ }
50
+ return prefs;
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ export function saveRecorderPreferences(
57
+ patch: Partial<RecorderPreferences>,
58
+ ): void {
59
+ if (typeof window === "undefined") return;
60
+ try {
61
+ const next = { ...loadRecorderPreferences(), ...patch };
62
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
63
+ } catch {
64
+ // localStorage may be unavailable (private mode, quota) — preferences are
65
+ // a nicety, never block recording on a write failure.
66
+ }
67
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from "@agent-native/core/client";
22
22
  import { IconCheck, IconSun, IconMoon } from "@tabler/icons-react";
23
23
  import { useTheme } from "next-themes";
24
+ import changelog from "../CHANGELOG.md?raw";
24
25
  import { Toaster } from "@/components/ui/sonner";
25
26
  import { Button } from "@/components/ui/button";
26
27
  import {
@@ -258,7 +259,12 @@ function AppContent() {
258
259
  {standalonePublic ? null : <DbSyncSetup />}
259
260
  {standalonePublic ? null : <ClipsExtensionAuthBridge />}
260
261
  {standalonePublic ? null : (
261
- <CommandMenu open={cmdkOpen} onOpenChange={setCmdkOpen}>
262
+ <CommandMenu
263
+ open={cmdkOpen}
264
+ onOpenChange={setCmdkOpen}
265
+ changelog={changelog}
266
+ changelogKey="clips"
267
+ >
262
268
  <CommandMenu.Group heading="Actions">
263
269
  <CommandMenu.Item onSelect={() => {}}>Search</CommandMenu.Item>
264
270
  </CommandMenu.Group>
@@ -16,10 +16,18 @@ import {
16
16
  appApiPath,
17
17
  openBuilderConnectPopup,
18
18
  useActionQuery,
19
+ ChangelogSettingsCard,
19
20
  } from "@agent-native/core/client";
21
+ import changelog from "../../CHANGELOG.md?raw";
20
22
  import { toast } from "sonner";
21
23
  import { Button } from "@/components/ui/button";
22
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
24
+ import {
25
+ Card,
26
+ CardContent,
27
+ CardDescription,
28
+ CardHeader,
29
+ CardTitle,
30
+ } from "@/components/ui/card";
23
31
  import { Input } from "@/components/ui/input";
24
32
  import { Label } from "@/components/ui/label";
25
33
  import { PageHeader } from "@/components/library/page-header";
@@ -389,6 +397,8 @@ export default function SettingsIndexRoute() {
389
397
  Preferences and connected services for this Clips workspace.
390
398
  </p>
391
399
 
400
+ <ChangelogSettingsCard markdown={changelog} />
401
+
392
402
  <Card id="video-storage" className="scroll-mt-16">
393
403
  <CardHeader>
394
404
  <CardTitle className="text-base flex items-center gap-2">
@@ -462,6 +472,10 @@ export default function SettingsIndexRoute() {
462
472
  <IconBrandSlack className="size-4 text-primary" />
463
473
  Agent-Native Clips for Slack
464
474
  </CardTitle>
475
+ <CardDescription>
476
+ Share a public clip, paste the link in Slack, and it plays inline
477
+ — no extra steps for viewers. Connect each workspace once.
478
+ </CardDescription>
465
479
  </CardHeader>
466
480
  <CardContent className="space-y-4">
467
481
  <div className="flex flex-col gap-3 rounded-md border border-border bg-accent/30 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
@@ -88,6 +88,16 @@ export default function TrashRoute() {
88
88
  };
89
89
 
90
90
  const selectedIds = Array.from(selected);
91
+ const allSelected =
92
+ recordings.length > 0 && selected.size === recordings.length;
93
+
94
+ const toggleSelectAll = () => {
95
+ setSelected((prev) =>
96
+ prev.size === recordings.length
97
+ ? new Set()
98
+ : new Set(recordings.map((r) => r.id)),
99
+ );
100
+ };
91
101
 
92
102
  return (
93
103
  <div className="flex flex-1 flex-col min-h-0">
@@ -96,6 +106,17 @@ export default function TrashRoute() {
96
106
  <div className="ml-auto flex items-center gap-2">
97
107
  {selectedIds.length > 0 && (
98
108
  <>
109
+ <span className="text-sm text-muted-foreground">
110
+ {selectedIds.length} selected
111
+ </span>
112
+ <Button
113
+ size="sm"
114
+ variant="ghost"
115
+ className="gap-1.5"
116
+ onClick={toggleSelectAll}
117
+ >
118
+ {allSelected ? "Deselect all" : "Select all"}
119
+ </Button>
99
120
  <Button
100
121
  size="sm"
101
122
  variant="outline"
@@ -53,6 +53,10 @@ import {
53
53
  compressBlobIfTooLarge,
54
54
  formatMb,
55
55
  } from "@/lib/compress";
56
+ import {
57
+ loadRecorderPreferences,
58
+ saveRecorderPreferences,
59
+ } from "@/lib/recorder-preferences";
56
60
  import { cn } from "@/lib/utils";
57
61
 
58
62
  // Client-side app-state writer (the server module pulls in Node's `events`
@@ -482,6 +486,65 @@ function captureThumbnailFromPreview(
482
486
  });
483
487
  }
484
488
 
489
+ /** Resolve once the off-screen video has a decoded frame, or after a timeout. */
490
+ function waitForVideoFrame(
491
+ video: HTMLVideoElement,
492
+ timeoutMs: number,
493
+ ): Promise<void> {
494
+ if (video.videoWidth > 0 && video.readyState >= 2) return Promise.resolve();
495
+ return new Promise((resolve) => {
496
+ let settled = false;
497
+ const finish = () => {
498
+ if (settled) return;
499
+ settled = true;
500
+ video.removeEventListener("loadeddata", onReady);
501
+ video.removeEventListener("canplay", onReady);
502
+ clearTimeout(timer);
503
+ resolve();
504
+ };
505
+ const onReady = () => {
506
+ if (video.videoWidth > 0 && video.readyState >= 2) finish();
507
+ };
508
+ video.addEventListener("loadeddata", onReady);
509
+ video.addEventListener("canplay", onReady);
510
+ const timer = setTimeout(finish, timeoutMs);
511
+ onReady();
512
+ });
513
+ }
514
+
515
+ /**
516
+ * Capture a thumbnail from a MediaStream that isn't bound to a visible element
517
+ * — used for the screen+camera composite so the thumbnail includes the camera
518
+ * bubble. Best effort: if it misses, the player's backfill path (which reads
519
+ * the recorded file, also composited) still produces a face thumbnail.
520
+ */
521
+ function captureThumbnailFromStream(
522
+ stream: MediaStream,
523
+ recordingId: string,
524
+ ): void {
525
+ const video = document.createElement("video");
526
+ video.srcObject = stream;
527
+ video.muted = true;
528
+ video.playsInline = true;
529
+ void (async () => {
530
+ try {
531
+ await video.play().catch(() => {});
532
+ await waitForVideoFrame(video, 1500);
533
+ const blob = await captureVideoThumbnailBlob(video);
534
+ if (blob) await uploadRecordingThumbnail(recordingId, blob);
535
+ } catch {
536
+ // best effort — the player has a backfill path if this misses.
537
+ } finally {
538
+ try {
539
+ video.pause();
540
+ } catch {
541
+ // ignore
542
+ }
543
+ video.srcObject = null;
544
+ }
545
+ })();
546
+ }
547
+
485
548
  interface PendingRecording {
486
549
  id: string;
487
550
  uploadChunkUrl: string;
@@ -718,7 +781,14 @@ export default function RecordRoute() {
718
781
  const [isPaused, setIsPaused] = useState(false);
719
782
  const [elapsedMs, setElapsedMs] = useState(0);
720
783
  const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
721
- const [cameraSize, setCameraSize] = useState<CameraBubbleSize>("md");
784
+ const [cameraSize, setCameraSize] = useState<CameraBubbleSize>(
785
+ () => loadRecorderPreferences().cameraSize ?? "md",
786
+ );
787
+ // Remember the bubble size across visits alongside the panel's selections.
788
+ const handleCameraSizeChange = useCallback((size: CameraBubbleSize) => {
789
+ setCameraSize(size);
790
+ saveRecorderPreferences({ cameraSize: size });
791
+ }, []);
722
792
  const [previewStream, setPreviewStream] = useState<MediaStream | null>(null);
723
793
  // The capture surface the user actually picked in the browser's native screen
724
794
  // picker (authority over the requested `displaySurface` hint). Drives whether
@@ -1013,7 +1083,14 @@ export default function RecordRoute() {
1013
1083
  // chose a specific mic, do not start a parallel system-default mic
1014
1084
  // session for instant transcription; the recorded audio still uses
1015
1085
  // the exact selected device and can be transcribed after upload.
1016
- if (wantsMic && !opts.micDeviceId && liveTranscription.supported) {
1086
+ //
1087
+ // The one exception is when a stale saved mic forced the engine to
1088
+ // fall back to the system default during acquire(): the recording is
1089
+ // now on the default device, so live transcription would use the same
1090
+ // mic and is safe to start.
1091
+ const usingDefaultMic =
1092
+ !opts.micDeviceId || engine.didMicFallBackToDefault();
1093
+ if (wantsMic && usingDefaultMic && liveTranscription.supported) {
1017
1094
  liveTranscription.start();
1018
1095
  }
1019
1096
 
@@ -1662,10 +1739,17 @@ export default function RecordRoute() {
1662
1739
  }
1663
1740
  setUiState("uploading");
1664
1741
  try {
1665
- // Capture a still-frame thumbnail from the preview while the stream is
1666
- // still live — otherwise the library would show a blank card until the
1667
- // owner opens the recording and triggers the player's backfill path.
1668
- captureThumbnailFromPreview(previewVideoRef.current, pending.id);
1742
+ // Capture a still-frame thumbnail while the stream is still live
1743
+ // otherwise the library would show a blank card until the owner opens the
1744
+ // recording and triggers the player's backfill path. In screen+camera
1745
+ // mode the visible preview is screen-only, so grab the composited stream
1746
+ // (screen + camera bubble) to keep the presenter's face in the thumbnail.
1747
+ const compositeStream = engine.getCompositeStream();
1748
+ if (compositeStream) {
1749
+ captureThumbnailFromStream(compositeStream, pending.id);
1750
+ } else {
1751
+ captureThumbnailFromPreview(previewVideoRef.current, pending.id);
1752
+ }
1669
1753
 
1670
1754
  // Stop live transcription and save the native web transcript before the
1671
1755
  // engine finalizes. This gives the recording an instant transcript
@@ -2058,7 +2142,7 @@ export default function RecordRoute() {
2058
2142
  onImportLoom={importLoom}
2059
2143
  importingLoom={loomImporting}
2060
2144
  cameraSize={cameraSize}
2061
- onCameraSizeChange={setCameraSize}
2145
+ onCameraSizeChange={handleCameraSizeChange}
2062
2146
  />
2063
2147
  ) : (
2064
2148
  <StorageSetupCard
@@ -2137,7 +2221,7 @@ export default function RecordRoute() {
2137
2221
  <CameraBubble
2138
2222
  stream={cameraStream}
2139
2223
  size={cameraSize}
2140
- onSizeChange={setCameraSize}
2224
+ onSizeChange={handleCameraSizeChange}
2141
2225
  hidden={
2142
2226
  (uiState !== "recording" && uiState !== "countdown") ||
2143
2227
  hideBubbleForFullScreenCapture
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  useCallback,
3
3
  useEffect,
4
+ useMemo,
4
5
  useRef,
5
6
  useState,
6
7
  type ReactNode,
@@ -22,6 +23,7 @@ import {
22
23
  agentNativePath,
23
24
  appBasePath,
24
25
  appPath,
26
+ track,
25
27
  useSession,
26
28
  AgentPanel,
27
29
  getBrowserTabId,
@@ -70,6 +72,10 @@ import {
70
72
  clipsSharePageTitle,
71
73
  displayRecordingTitle,
72
74
  } from "../../shared/share-meta";
75
+ import {
76
+ buildSignupAttributionQuery,
77
+ readShareAttribution,
78
+ } from "../../shared/share-attribution";
73
79
 
74
80
  type SharePageMetaRecording = {
75
81
  id: string;
@@ -274,6 +280,61 @@ export default function ShareRoute() {
274
280
  const loaderData = useLoaderData<typeof loader>() as SharePageLoaderData;
275
281
  const { shareId } = useParams<{ shareId: string }>();
276
282
  const navigate = useNavigate();
283
+
284
+ // Viral attribution: read the `ref`/`via` the visitor arrived on (the tagged
285
+ // share link) so we can fire funnel events and forward attribution into the
286
+ // signup URL even when cookies are blocked or `document.referrer` is empty.
287
+ const attribution = useMemo(
288
+ () =>
289
+ readShareAttribution(
290
+ typeof window === "undefined" ? "" : window.location.search,
291
+ ),
292
+ [],
293
+ );
294
+ const recordingId = shareId ?? "";
295
+
296
+ // share_cta_click — fired alongside (never instead of) the real navigation.
297
+ // `track` is non-throwing, but guard anyway so tracking can never break a CTA.
298
+ const fireShareCtaClick = useCallback(
299
+ (cta: "signup" | "download" | "try_clips" | "signin") => {
300
+ try {
301
+ void track("share_cta_click", {
302
+ surface: "clip",
303
+ recording_id: recordingId,
304
+ cta,
305
+ ref: attribution.ref,
306
+ via: attribution.via,
307
+ });
308
+ } catch {
309
+ // Never let analytics break a CTA.
310
+ }
311
+ },
312
+ [recordingId, attribution.ref, attribution.via],
313
+ );
314
+
315
+ // Forward attribution into the signup URL so it survives blocked cookies.
316
+ const signupHref = appPath(
317
+ `/signup?${buildSignupAttributionQuery(attribution.via)}`,
318
+ );
319
+
320
+ // share_view — fire once when the public share page mounts. The ref guard
321
+ // prevents double-fire across re-renders / StrictMode double-invocation.
322
+ const shareViewFiredRef = useRef(false);
323
+ useEffect(() => {
324
+ if (shareViewFiredRef.current) return;
325
+ shareViewFiredRef.current = true;
326
+ try {
327
+ void track("share_view", {
328
+ surface: "clip",
329
+ recording_id: recordingId,
330
+ ref: attribution.ref,
331
+ via: attribution.via,
332
+ });
333
+ } catch {
334
+ // Never let analytics break the page render.
335
+ }
336
+ }, [recordingId, attribution.ref, attribution.via]);
337
+
277
338
  const playerRef = useRef<VideoPlayerHandle | null>(null);
278
339
  const [password, setPassword] = useState<string | null>(() => {
279
340
  if (typeof window === "undefined" || !shareId) return null;
@@ -690,7 +751,11 @@ export default function ShareRoute() {
690
751
  </Button>
691
752
  ) : session ? null : (
692
753
  <Button variant="ghost" size="sm" asChild>
693
- <a href={appPath("/")} className="gap-1.5">
754
+ <a
755
+ href={appPath("/")}
756
+ className="gap-1.5"
757
+ onClick={() => fireShareCtaClick("try_clips")}
758
+ >
694
759
  Try Clips
695
760
  <IconExternalLink className="h-3.5 w-3.5" />
696
761
  </a>
@@ -879,7 +944,10 @@ export default function ShareRoute() {
879
944
  browserTabId={getBrowserTabId()}
880
945
  />
881
946
  ) : (
882
- <PublicAgentEmptyState />
947
+ <PublicAgentEmptyState
948
+ signupHref={signupHref}
949
+ onCtaClick={fireShareCtaClick}
950
+ />
883
951
  )}
884
952
  </TabsContent>
885
953
  <TabsContent
@@ -932,6 +1000,7 @@ export default function ShareRoute() {
932
1000
  if (!open) setSignInIntent(null);
933
1001
  }}
934
1002
  intent={signInIntent ?? "comment"}
1003
+ onSignIn={() => fireShareCtaClick("signin")}
935
1004
  />
936
1005
  </div>
937
1006
  );
@@ -947,7 +1016,13 @@ function sanitizeFilename(name: string): string {
947
1016
  );
948
1017
  }
949
1018
 
950
- function PublicAgentEmptyState() {
1019
+ function PublicAgentEmptyState({
1020
+ signupHref,
1021
+ onCtaClick,
1022
+ }: {
1023
+ signupHref: string;
1024
+ onCtaClick: (cta: "signup" | "download" | "try_clips" | "signin") => void;
1025
+ }) {
951
1026
  const [platform, setPlatform] = useState<ViewerPlatform | null>(null);
952
1027
 
953
1028
  useEffect(() => {
@@ -1005,12 +1080,18 @@ function PublicAgentEmptyState() {
1005
1080
  Loom alternative
1006
1081
  </p>
1007
1082
  <div className="mt-7 flex w-full max-w-[220px] flex-col gap-2">
1008
- <CaptureInstallButton className="w-full gap-2" align="center">
1083
+ <CaptureInstallButton
1084
+ className="w-full gap-2"
1085
+ align="center"
1086
+ onClick={() => onCtaClick("download")}
1087
+ >
1009
1088
  <IconDownload className="h-4 w-4" />
1010
1089
  {downloadLabel}
1011
1090
  </CaptureInstallButton>
1012
1091
  <Button asChild variant="outline" className="w-full">
1013
- <a href={appPath("/signup")}>Sign up</a>
1092
+ <a href={signupHref} onClick={() => onCtaClick("signup")}>
1093
+ Sign up
1094
+ </a>
1014
1095
  </Button>
1015
1096
  </div>
1016
1097
  </div>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Clips shared in Slack no longer show a 'Could not start playback' error in the inline preview — they autoplay muted where allowed and play on click otherwise
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Made the play button on shared and embedded clips scale with the player size so it's no longer oversized in small previews like Slack unfurls
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Public clip share links now show whether they'll play inline in Slack, with a one-click link to connect a workspace