@agent-native/core 0.68.2 → 0.69.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 (41) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/template-clips.md +12 -7
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/client/AgentPanel.tsx +2 -4
  6. package/corpus/core/src/extensions/content-patch.ts +12 -2
  7. package/corpus/core/src/extensions/web-search-tool.ts +45 -4
  8. package/corpus/core/src/secrets/register-framework-secrets.ts +7 -0
  9. package/corpus/core/src/server/social-og-image.ts +6 -1
  10. package/corpus/core/src/vite/client.ts +83 -1
  11. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +69 -13
  12. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +38 -2
  13. package/corpus/templates/clips/app/lib/capture-install-options.ts +2 -1
  14. package/corpus/templates/clips/app/lib/recorder-preferences.ts +67 -0
  15. package/corpus/templates/clips/app/routes/_app.trash.tsx +21 -0
  16. package/corpus/templates/clips/app/routes/record.tsx +14 -3
  17. package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +5 -2
  18. package/corpus/templates/content/vite.config.ts +37 -1
  19. package/corpus/templates/content/vitest.config.ts +3 -0
  20. package/dist/client/AgentPanel.d.ts +1 -1
  21. package/dist/client/AgentPanel.d.ts.map +1 -1
  22. package/dist/client/AgentPanel.js +1 -1
  23. package/dist/client/AgentPanel.js.map +1 -1
  24. package/dist/extensions/content-patch.d.ts.map +1 -1
  25. package/dist/extensions/content-patch.js +10 -2
  26. package/dist/extensions/content-patch.js.map +1 -1
  27. package/dist/extensions/web-search-tool.d.ts +5 -3
  28. package/dist/extensions/web-search-tool.d.ts.map +1 -1
  29. package/dist/extensions/web-search-tool.js +36 -4
  30. package/dist/extensions/web-search-tool.js.map +1 -1
  31. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  32. package/dist/secrets/register-framework-secrets.js +6 -0
  33. package/dist/secrets/register-framework-secrets.js.map +1 -1
  34. package/dist/server/social-og-image.d.ts.map +1 -1
  35. package/dist/server/social-og-image.js +5 -1
  36. package/dist/server/social-og-image.js.map +1 -1
  37. package/dist/vite/client.d.ts.map +1 -1
  38. package/dist/vite/client.js +83 -1
  39. package/dist/vite/client.js.map +1 -1
  40. package/docs/content/template-clips.md +12 -7
  41. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 1138
31
- - template files: 3944
31
+ - template files: 3945
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.69.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 530de18: Add Firecrawl as a BYOK backend for the web-search agent tool. When `FIRECRAWL_API_KEY` is configured (via app secrets or environment) the tool routes searches through Firecrawl's `/v2/search` API. It slots into the existing first-configured-wins chain after Brave, Tavily, and Exa, and before Builder-managed search, and is registered as an optional framework secret so it surfaces in every template's settings UI.
8
+
9
+ ## 0.68.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 9db3c12: Fix Cloudflare Pages worker bundling for content-heavy templates and add the published Clips Chrome extension URL defaults.
14
+
3
15
  ## 0.68.2
4
16
 
5
17
  ### Patch Changes
@@ -54,10 +54,13 @@ itself. The Chrome extension is the path for active-tab developer logs and
54
54
  browser-only repros. In the Clips UI, use the Chrome option for browser logs and
55
55
  the desktop app for the most seamless everyday capture path.
56
56
 
57
+ The Agent-Native Clips Chrome extension listing is
58
+ `https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn`.
57
59
  If you host your own Clips server, keep the Chrome extension option hidden until
58
- your Web Store listing is live. Set `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` and
59
- `VITE_CLIPS_CHROME_EXTENSION_URL=<chrome-web-store-url>` after approval to show
60
- the extension beside desktop-app download prompts.
60
+ your Web Store listing is live. Set `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1`
61
+ after approval to show the extension beside desktop-app download prompts. Set
62
+ `VITE_CLIPS_CHROME_EXTENSION_URL` only if you need to override the default
63
+ listing URL.
61
64
 
62
65
  ## Agent-readable clips
63
66
 
@@ -165,7 +168,7 @@ Clips is a larger template with a native recorder (it ships a desktop companion
165
168
 
166
169
  1. **Video storage (required).** Connect a storage backend through the onboarding wizard. The easiest path is Builder.io (free during beta, one-click). For self-hosted storage, set `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and optionally `S3_REGION` and `S3_PUBLIC_BASE_URL`. Cloudflare R2 and DigitalOcean Spaces use the same env vars with the `R2_*` prefix.
167
170
  2. **Google Calendar (optional).** To sync upcoming meetings, connect a Google Calendar account from Settings. The OAuth callback URL in dev is `http://localhost:8094/_agent-native/google/callback`. Set up a Google OAuth client in [Google Cloud Console](https://console.cloud.google.com/) with the Gmail and Google Calendar APIs enabled.
168
- 3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording. Browser recordings can save redacted console and fetch/XHR diagnostics from the recorder page. Once a deployment has a published Clips Chrome extension URL, enable `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` and set `VITE_CLIPS_CHROME_EXTENSION_URL` so users can choose the extension for active-tab browser logs or the desktop app for the smoothest native capture path.
171
+ 3. **Screen-capture permissions.** On macOS, grant Screen Recording permission to the browser (or the desktop companion app) in System Settings → Privacy & Security → Screen Recording. Browser recordings can save redacted console and fetch/XHR diagnostics from the recorder page. Once the Chrome extension listing is available, enable `VITE_CLIPS_CHROME_EXTENSION_ENABLED=1` so users can choose the extension for active-tab browser logs or the desktop app for the smoothest native capture path.
169
172
  4. **Slack previews (optional).** Create a Slack app with `links:read`, `links:write`, and `links.embed:write`; subscribe to `link_shared`; add your Clips share domain under **App Unfurl Domains**; set the Request URL to `https://your-clips.example.com/api/slack/unfurl`; and add the OAuth redirect URL `https://your-clips.example.com/api/slack/oauth/callback`. Configure `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and `SLACK_SIGNING_SECRET`, then connect workspaces from Clips Settings.
170
173
 
171
174
  ### Host your own Clips server
@@ -209,9 +212,11 @@ tray app at your deployment.
209
212
 
210
213
  5. **Enable the Chrome extension after publishing.** Keep
211
214
  `VITE_CLIPS_CHROME_EXTENSION_ENABLED` unset until the Chrome Web Store listing
212
- is approved and you have a public extension URL. Then set it to `1` and set
213
- `VITE_CLIPS_CHROME_EXTENSION_URL` to reveal the browser-log option beside the
214
- desktop app prompts.
215
+ is approved. Then set it to `1` to reveal the browser-log option beside the
216
+ desktop app prompts. The default listing URL is
217
+ `https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn`;
218
+ set `VITE_CLIPS_CHROME_EXTENSION_URL` only if your deployment uses a
219
+ different extension listing.
215
220
 
216
221
  6. **Connect optional integrations.** Google Calendar powers the Meetings tab,
217
222
  `GEMINI_API_KEY` or Builder.io Connect powers transcript cleanup and titles,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.68.2",
3
+ "version": "0.69.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -76,10 +76,8 @@ const MultiTabAssistantChatLazy = lazy(() =>
76
76
  })),
77
77
  );
78
78
  import type { MultiTabAssistantChatHeaderProps } from "./MultiTabAssistantChat.js";
79
- import {
80
- assistantUiRecoverableRenderErrorKind,
81
- type AssistantChatProps,
82
- } from "./AssistantChat.js";
79
+ import type { AssistantChatProps } from "./AssistantChat.js";
80
+ import { assistantUiRecoverableRenderErrorKind } from "./assistant-ui-recovery.js";
83
81
  import { useDevMode } from "./use-dev-mode.js";
84
82
  import { useScreenRefreshKey } from "./use-db-sync.js";
85
83
  import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -116,13 +116,23 @@ export async function applyExtensionContentUpdate(
116
116
  export async function formatExtensionHtml(content: string): Promise<string> {
117
117
  try {
118
118
  const prettier = await import("prettier");
119
- return await prettier.format(content, {
119
+ const formatted = await prettier.format(content, {
120
120
  parser: "html",
121
121
  htmlWhitespaceSensitivity: "ignore",
122
122
  });
123
+ return typeof formatted === "string" ? formatted : content;
123
124
  } catch (err: any) {
125
+ const message = String(err?.message ?? err);
126
+ if (
127
+ message.includes("Cannot find package 'prettier'") ||
128
+ message.includes('Cannot find package "prettier"') ||
129
+ message.includes("Cannot find module 'prettier'") ||
130
+ message.includes('Cannot find module "prettier"')
131
+ ) {
132
+ return content;
133
+ }
124
134
  throw new Error(
125
- `Unable to format extension HTML with Prettier: ${err?.message ?? err}`,
135
+ `Unable to format extension HTML with Prettier: ${message}`,
126
136
  );
127
137
  }
128
138
  }
@@ -7,13 +7,15 @@
7
7
  * 1. Brave Search API (BRAVE_SEARCH_API_KEY)
8
8
  * 2. Tavily (TAVILY_API_KEY)
9
9
  * 3. Exa (EXA_API_KEY)
10
- * 4. Builder.io (connected Builder credentials)
10
+ * 4. Firecrawl (FIRECRAWL_API_KEY)
11
+ * 5. Builder.io (connected Builder credentials)
11
12
  *
12
13
  * The first configured backend wins. If none is configured, the tool
13
14
  * returns a helpful message telling the user which keys to add.
14
15
  *
15
- * Connect Builder.io or register BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, or
16
- * EXA_API_KEY via app secrets settings or environment variables.
16
+ * Connect Builder.io or register BRAVE_SEARCH_API_KEY, TAVILY_API_KEY,
17
+ * EXA_API_KEY, or FIRECRAWL_API_KEY via app secrets settings or environment
18
+ * variables.
17
19
  */
18
20
 
19
21
  import type { ActionEntry } from "../agent/production-agent.js";
@@ -222,6 +224,40 @@ async function searchExa(
222
224
  }));
223
225
  }
224
226
 
227
+ async function searchFirecrawl(
228
+ query: string,
229
+ count: number,
230
+ apiKey: string,
231
+ ): Promise<WebSearchResult[]> {
232
+ const res = await fetch("https://api.firecrawl.dev/v2/search", {
233
+ method: "POST",
234
+ headers: {
235
+ "Content-Type": "application/json",
236
+ Authorization: `Bearer ${apiKey}`,
237
+ },
238
+ body: JSON.stringify({
239
+ query,
240
+ limit: count,
241
+ }),
242
+ signal: AbortSignal.timeout(15_000),
243
+ });
244
+ if (!res.ok) {
245
+ throw new Error(`Firecrawl error ${res.status}: ${await res.text()}`);
246
+ }
247
+ // v2 groups results by source ({ data: { web: [...] } }); older shapes
248
+ // return a flat array ({ data: [...] }). Accept both defensively.
249
+ type FirecrawlResult = { title?: string; url?: string; description?: string };
250
+ const data = (await res.json()) as {
251
+ data?: FirecrawlResult[] | { web?: FirecrawlResult[] };
252
+ };
253
+ const results = Array.isArray(data.data) ? data.data : (data.data?.web ?? []);
254
+ return results.map((r) => ({
255
+ title: r.title ?? "",
256
+ url: r.url ?? "",
257
+ snippet: r.description ?? "",
258
+ }));
259
+ }
260
+
225
261
  async function searchBuilderManaged(
226
262
  query: string,
227
263
  count: number,
@@ -287,7 +323,7 @@ export function createWebSearchToolEntry(
287
323
  "web-search": {
288
324
  tool: {
289
325
  description:
290
- "Search the public web. Use to find API documentation, endpoints, current information, or any topic. Returns ranked results from BYOK providers or a grounded Builder-managed summary. Follow up with web-request or provider-api-docs using responseMode:'markdown' or responseMode:'matches' to fetch clean text, links, or compact snippets from promising URLs. Requires either Connect Builder.io or one of: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, or EXA_API_KEY.",
326
+ "Search the public web. Use to find API documentation, endpoints, current information, or any topic. Returns ranked results from BYOK providers or a grounded Builder-managed summary. Follow up with web-request or provider-api-docs using responseMode:'markdown' or responseMode:'matches' to fetch clean text, links, or compact snippets from promising URLs. Requires either Connect Builder.io or one of: BRAVE_SEARCH_API_KEY, TAVILY_API_KEY, EXA_API_KEY, or FIRECRAWL_API_KEY.",
291
327
  parameters: {
292
328
  type: "object" as const,
293
329
  properties: {
@@ -318,6 +354,7 @@ export function createWebSearchToolEntry(
318
354
  const braveKey = await resolveSearchKey("BRAVE_SEARCH_API_KEY", opts);
319
355
  const tavilyKey = await resolveSearchKey("TAVILY_API_KEY", opts);
320
356
  const exaKey = await resolveSearchKey("EXA_API_KEY", opts);
357
+ const firecrawlKey = await resolveSearchKey("FIRECRAWL_API_KEY", opts);
321
358
  const builderCredentials = await resolveBuilderSearchCredentials(opts);
322
359
 
323
360
  let results: WebSearchResult[];
@@ -334,6 +371,9 @@ export function createWebSearchToolEntry(
334
371
  } else if (exaKey) {
335
372
  results = await searchExa(query, count, exaKey);
336
373
  backend = "Exa";
374
+ } else if (firecrawlKey) {
375
+ results = await searchFirecrawl(query, count, firecrawlKey);
376
+ backend = "Firecrawl";
337
377
  } else if (builderCredentials) {
338
378
  managedText = await searchBuilderManaged(
339
379
  query,
@@ -350,6 +390,7 @@ export function createWebSearchToolEntry(
350
390
  " • BRAVE_SEARCH_API_KEY — https://brave.com/search/api/",
351
391
  " • TAVILY_API_KEY — https://tavily.com/",
352
392
  " • EXA_API_KEY — https://exa.ai/",
393
+ " • FIRECRAWL_API_KEY — https://firecrawl.dev/",
353
394
  ].join("\n");
354
395
  }
355
396
  } catch (err: unknown) {
@@ -47,6 +47,13 @@ export function registerFrameworkSecrets(): void {
47
47
  "Enables the web-search agent tool via Exa. Used as fallback when Brave and Tavily are not set and before Builder-managed search.",
48
48
  docsUrl: "https://exa.ai/",
49
49
  },
50
+ {
51
+ key: "FIRECRAWL_API_KEY",
52
+ label: "Firecrawl API Key",
53
+ description:
54
+ "Enables the web-search agent tool via Firecrawl. Used as fallback when Brave, Tavily, and Exa are not set and before Builder-managed search.",
55
+ docsUrl: "https://firecrawl.dev/",
56
+ },
50
57
  ];
51
58
 
52
59
  for (const entry of webSearchKeys) {
@@ -330,7 +330,12 @@ export function renderAgentNativeOgImageSvg(
330
330
  export async function renderAgentNativeOgImagePng(
331
331
  input: AgentNativeOgImageInput = {},
332
332
  ): Promise<Uint8Array> {
333
- const { Resvg } = await import(/* @vite-ignore */ "@resvg/resvg-js");
333
+ const overridePackage =
334
+ typeof process !== "undefined"
335
+ ? process.env.AGENT_NATIVE_RESVG_PACKAGE
336
+ : undefined;
337
+ const resvgPackage = overridePackage || "@resvg/resvg-js";
338
+ const { Resvg } = await import(/* @vite-ignore */ resvgPackage);
334
339
  // Feed resvg the embedded Liberation Sans font explicitly. System fonts can't
335
340
  // be relied on: Linux serverless runtimes (Netlify/Lambda) ship neither Arial
336
341
  // nor Inter, so without a bundled font every `<text>` rendered blank.
@@ -1320,6 +1320,87 @@ function ssrStubPlugin(packages: string[]): Plugin | null {
1320
1320
  if (!packages.length) return null;
1321
1321
  const stubbed = new Set(packages);
1322
1322
  const STUB_ID = "\0agent-native-ssr-stub";
1323
+ const namedExports = [
1324
+ "ActionBarPrimitive",
1325
+ "AllSelection",
1326
+ "Array",
1327
+ "AssistantRuntimeProvider",
1328
+ "Awareness",
1329
+ "BranchPickerPrimitive",
1330
+ "BubbleMenu",
1331
+ "CodeBlockLowlight",
1332
+ "Collaboration",
1333
+ "CollaborationCaret",
1334
+ "ComposerPrimitive",
1335
+ "CompositeAttachmentAdapter",
1336
+ "DOMParser",
1337
+ "Decoration",
1338
+ "DecorationSet",
1339
+ "Editor",
1340
+ "EditorContent",
1341
+ "Extension",
1342
+ "FitAddon",
1343
+ "Fragment",
1344
+ "Image",
1345
+ "Link",
1346
+ "Map",
1347
+ "Markdown",
1348
+ "Mark",
1349
+ "MessagePrimitive",
1350
+ "Node",
1351
+ "NodeSelection",
1352
+ "NodeViewContent",
1353
+ "NodeViewWrapper",
1354
+ "Placeholder",
1355
+ "Plugin",
1356
+ "PluginKey",
1357
+ "ReactNodeViewRenderer",
1358
+ "Selection",
1359
+ "SimpleImageAttachmentAdapter",
1360
+ "SimpleTextAttachmentAdapter",
1361
+ "StarterKit",
1362
+ "Table",
1363
+ "TableCell",
1364
+ "TableHeader",
1365
+ "TableRow",
1366
+ "TaskItem",
1367
+ "TaskList",
1368
+ "Terminal",
1369
+ "TextSelection",
1370
+ "ThreadPrimitive",
1371
+ "WebLinksAddon",
1372
+ "captureException",
1373
+ "common",
1374
+ "createLowlight",
1375
+ "defaultUrlTransform",
1376
+ "extensions",
1377
+ "findTable",
1378
+ "format",
1379
+ "Doc",
1380
+ "getHTMLFromFragment",
1381
+ "getIsolationScope",
1382
+ "init",
1383
+ "isChangeOrigin",
1384
+ "mergeAttributes",
1385
+ "renderToString",
1386
+ "applyUpdate",
1387
+ "encodeStateVector",
1388
+ "encodeStateAsUpdate",
1389
+ "mergeUpdates",
1390
+ "useAui",
1391
+ "useComposer",
1392
+ "useComposerRuntime",
1393
+ "useCurrentEditor",
1394
+ "useEditor",
1395
+ "useLocalRuntime",
1396
+ "useMessagePartText",
1397
+ "useMessageRuntime",
1398
+ "useThread",
1399
+ "useThreadRuntime",
1400
+ "withScope",
1401
+ "XmlFragment",
1402
+ "XmlText",
1403
+ ];
1323
1404
  return {
1324
1405
  name: "agent-native-ssr-stub-heavy-libs",
1325
1406
  enforce: "pre",
@@ -1345,7 +1426,8 @@ function ssrStubPlugin(packages: string[]): Plugin | null {
1345
1426
  "return new Proxy(() => {}, handler); " +
1346
1427
  "} };" +
1347
1428
  "const stub = new Proxy(() => {}, handler);" +
1348
- "export default stub;"
1429
+ "export default stub;" +
1430
+ namedExports.map((name) => `export const ${name} = stub;`).join("")
1349
1431
  );
1350
1432
  },
1351
1433
  };
@@ -48,6 +48,10 @@ import {
48
48
  type RecordingMode,
49
49
  } from "./recorder-engine";
50
50
  import type { CameraBubbleSize } from "./camera-bubble";
51
+ import {
52
+ loadRecorderPreferences,
53
+ saveRecorderPreferences,
54
+ } from "@/lib/recorder-preferences";
51
55
  import { CameraVisualizer, type CameraTestStatus } from "./camera-visualizer";
52
56
  import {
53
57
  MicrophoneVisualizer,
@@ -184,11 +188,14 @@ export function PreRecordPanel({
184
188
  }: PreRecordPanelProps) {
185
189
  const fileInputRef = useRef<HTMLInputElement>(null);
186
190
  const loomInputRef = useRef<HTMLInputElement>(null);
191
+ // Saved selections from the last visit. A `?mode=`/`?surface=` deep link
192
+ // (initialMode/initialDisplaySurface) still takes precedence over them.
193
+ const savedPrefs = useMemo(() => loadRecorderPreferences(), []);
187
194
  const [mode, setMode] = useState<RecordingMode>(
188
- () => initialMode ?? "screen+camera",
195
+ () => initialMode ?? savedPrefs.mode ?? "screen+camera",
189
196
  );
190
197
  const [displaySurface, setDisplaySurface] = useState<DisplaySurface>(
191
- () => initialDisplaySurface ?? "window",
198
+ () => initialDisplaySurface ?? savedPrefs.displaySurface ?? "window",
192
199
  );
193
200
  const [sourceOpen, setSourceOpen] = useState(false);
194
201
  const [deviceSettingsOpen, setDeviceSettingsOpen] = useState(false);
@@ -197,8 +204,12 @@ export function PreRecordPanel({
197
204
  const [loomError, setLoomError] = useState<string | null>(null);
198
205
  const [mics, setMics] = useState<MediaDeviceInfo[]>([]);
199
206
  const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
200
- const [micId, setMicId] = useState<string>("default");
201
- const [cameraId, setCameraId] = useState<string>("default");
207
+ const [micId, setMicId] = useState<string>(
208
+ () => savedPrefs.micId ?? "default",
209
+ );
210
+ const [cameraId, setCameraId] = useState<string>(
211
+ () => savedPrefs.cameraId ?? "default",
212
+ );
202
213
  const [enumError, setEnumError] = useState<string | null>(null);
203
214
  const [micAccessStatus, setMicAccessStatus] =
204
215
  useState<DeviceAccessStatus>("idle");
@@ -274,13 +285,56 @@ export function PreRecordPanel({
274
285
  [mics],
275
286
  );
276
287
 
288
+ // Reset to "default" only once a populated list genuinely excludes the saved
289
+ // device — an empty list means "not enumerated yet", and resetting then would
290
+ // wipe a restored preference before devices load.
277
291
  useEffect(() => {
278
292
  if (micId === "default" || micId === NO_MIC_DEVICE_ID) return;
279
- if (!mics.some((mic) => mic.deviceId === micId)) {
293
+ if (mics.length > 0 && !mics.some((mic) => mic.deviceId === micId)) {
280
294
  setMicId("default");
281
295
  }
282
296
  }, [micId, mics]);
283
297
 
298
+ // Same guard for cameras. Not persisted, so a temporarily missing device
299
+ // doesn't erase the saved choice.
300
+ useEffect(() => {
301
+ if (cameraId === "default" || cameraId === NO_CAMERA_DEVICE_ID) return;
302
+ if (
303
+ cameras.length > 0 &&
304
+ !cameras.some((camera) => camera.deviceId === cameraId)
305
+ ) {
306
+ setCameraId("default");
307
+ }
308
+ }, [cameraId, cameras]);
309
+
310
+ // Camera-only mode needs a camera — coerce a restored "off" sentinel to
311
+ // "default" so Start doesn't forward it as an exact deviceId (covers the
312
+ // ?mode=camera deep-link/restore path the mode buttons already handle).
313
+ useEffect(() => {
314
+ if (mode === "camera" && cameraId === NO_CAMERA_DEVICE_ID) {
315
+ setCameraId("default");
316
+ }
317
+ }, [mode, cameraId]);
318
+
319
+ // Persist deliberate picks only (not the resets above), so an unavailable
320
+ // device on load can't clobber the stored preference.
321
+ const chooseMode = useCallback((value: RecordingMode) => {
322
+ setMode(value);
323
+ saveRecorderPreferences({ mode: value });
324
+ }, []);
325
+ const chooseDisplaySurface = useCallback((value: DisplaySurface) => {
326
+ setDisplaySurface(value);
327
+ saveRecorderPreferences({ displaySurface: value });
328
+ }, []);
329
+ const chooseMic = useCallback((value: string) => {
330
+ setMicId(value);
331
+ saveRecorderPreferences({ micId: value });
332
+ }, []);
333
+ const chooseCamera = useCallback((value: string) => {
334
+ setCameraId(value);
335
+ saveRecorderPreferences({ cameraId: value });
336
+ }, []);
337
+
284
338
  const requestMicrophoneChoices = useCallback(async () => {
285
339
  if (!navigator.mediaDevices?.getUserMedia) {
286
340
  setMicAccessStatus("error");
@@ -376,9 +430,9 @@ export function PreRecordPanel({
376
430
  void requestMicrophoneChoices();
377
431
  return;
378
432
  }
379
- setMicId(value);
433
+ chooseMic(value);
380
434
  },
381
- [requestMicrophoneChoices],
435
+ [requestMicrophoneChoices, chooseMic],
382
436
  );
383
437
 
384
438
  const handleMicSignalChange = useCallback((hasSignal: boolean) => {
@@ -530,12 +584,12 @@ export function PreRecordPanel({
530
584
  key={opt.value}
531
585
  type="button"
532
586
  onClick={() => {
533
- setMode(opt.value);
587
+ chooseMode(opt.value);
534
588
  if (
535
589
  opt.value === "camera" &&
536
590
  cameraId === NO_CAMERA_DEVICE_ID
537
591
  ) {
538
- setCameraId("default");
592
+ chooseCamera("default");
539
593
  }
540
594
  }}
541
595
  className={cn(
@@ -605,7 +659,7 @@ export function PreRecordPanel({
605
659
  <button
606
660
  key={opt.value}
607
661
  type="button"
608
- onClick={() => setDisplaySurface(opt.value)}
662
+ onClick={() => chooseDisplaySurface(opt.value)}
609
663
  className={cn(
610
664
  "flex min-h-[76px] flex-col rounded-lg border p-2 text-left transition-colors",
611
665
  active
@@ -703,7 +757,7 @@ export function PreRecordPanel({
703
757
  <Switch
704
758
  checked={audioEnabled}
705
759
  onCheckedChange={(checked) =>
706
- setMicId(checked ? "default" : NO_MIC_DEVICE_ID)
760
+ chooseMic(checked ? "default" : NO_MIC_DEVICE_ID)
707
761
  }
708
762
  disabled={busy}
709
763
  aria-label="Include audio in this recording"
@@ -740,7 +794,7 @@ export function PreRecordPanel({
740
794
  </div>
741
795
  <Select
742
796
  value={cameraId}
743
- onValueChange={setCameraId}
797
+ onValueChange={chooseCamera}
744
798
  disabled={!needsCamera}
745
799
  >
746
800
  <SelectTrigger className="h-9 min-w-0 flex-1 border-0 bg-transparent px-2 shadow-none hover:bg-muted/45 focus:ring-0 focus:ring-offset-0 disabled:opacity-60">
@@ -762,7 +816,9 @@ export function PreRecordPanel({
762
816
  <Switch
763
817
  checked={needsCamera}
764
818
  onCheckedChange={(checked) =>
765
- setCameraId(checked ? "default" : NO_CAMERA_DEVICE_ID)
819
+ chooseCamera(
820
+ checked ? "default" : NO_CAMERA_DEVICE_ID,
821
+ )
766
822
  }
767
823
  disabled={busy}
768
824
  aria-label="Include camera in this recording"
@@ -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
  }
@@ -592,7 +603,20 @@ export class RecorderEngine {
592
603
  audio: false,
593
604
  });
594
605
  } catch (err) {
595
- throw this.friendlyError(err, "camera");
606
+ // Stale/unplugged cameraDeviceId — retry with the default camera
607
+ // instead of failing the recording.
608
+ if (this.opts.cameraDeviceId && isDeviceUnavailableError(err)) {
609
+ try {
610
+ this.cameraStream = await navigator.mediaDevices.getUserMedia({
611
+ video: true,
612
+ audio: false,
613
+ });
614
+ } catch (retryErr) {
615
+ throw this.friendlyError(retryErr, "camera");
616
+ }
617
+ } else {
618
+ throw this.friendlyError(err, "camera");
619
+ }
596
620
  }
597
621
  }
598
622
 
@@ -603,7 +627,19 @@ export class RecorderEngine {
603
627
  video: false,
604
628
  });
605
629
  } catch (err) {
606
- throw this.friendlyError(err, "microphone");
630
+ // Same stale-device fallback as the camera — retry default mic.
631
+ if (this.opts.micDeviceId && isDeviceUnavailableError(err)) {
632
+ try {
633
+ this.micStream = await navigator.mediaDevices.getUserMedia({
634
+ audio: voiceFocusedAudioConstraints(),
635
+ video: false,
636
+ });
637
+ } catch (retryErr) {
638
+ throw this.friendlyError(retryErr, "microphone");
639
+ }
640
+ } else {
641
+ throw this.friendlyError(err, "microphone");
642
+ }
607
643
  }
608
644
  }
609
645
 
@@ -4,7 +4,8 @@ function isTruthy(value: unknown): boolean {
4
4
  }
5
5
 
6
6
  const chromeExtensionUrl =
7
- import.meta.env.VITE_CLIPS_CHROME_EXTENSION_URL?.trim() ?? "";
7
+ import.meta.env.VITE_CLIPS_CHROME_EXTENSION_URL?.trim() ??
8
+ "https://chromewebstore.google.com/detail/baoipacpchggcdigagnajakiidcgcffn";
8
9
 
9
10
  export const clipsChromeExtensionEnabled = isTruthy(
10
11
  import.meta.env.VITE_CLIPS_CHROME_EXTENSION_ENABLED,
@@ -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
+ }