@agent-native/core 0.79.6 → 0.79.9

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 (97) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/locales/zh-TW/durable-background-runs.md +74 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +0 -93
  6. package/corpus/core/src/agent/run-manager.ts +9 -1
  7. package/corpus/core/src/client/agent-chat-adapter.ts +24 -16
  8. package/corpus/core/src/client/session-replay.ts +67 -13
  9. package/corpus/core/src/onboarding/default-steps.ts +3 -3
  10. package/corpus/core/src/server/agent-chat-plugin.ts +82 -162
  11. package/corpus/core/src/server/agent-run-context.ts +204 -0
  12. package/corpus/core/src/server/credential-provider.ts +20 -4
  13. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +14 -4
  14. package/corpus/templates/analytics/changelog/2026-06-26-session-replay-uploads-now-accept-compressed-payloads-so-lar.md +6 -0
  15. package/corpus/templates/analytics/changelog/2026-06-26-sql-chart-tooltips-now-stay-stable-while-moving-across-dense.md +6 -0
  16. package/corpus/templates/analytics/server/handlers/session-replay.ts +56 -8
  17. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +1 -1
  18. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +44 -16
  19. package/corpus/templates/clips/app/i18n/ar-SA.ts +12 -9
  20. package/corpus/templates/clips/app/i18n/de-DE.ts +12 -9
  21. package/corpus/templates/clips/app/i18n/en-US.ts +19 -16
  22. package/corpus/templates/clips/app/i18n/es-ES.ts +12 -9
  23. package/corpus/templates/clips/app/i18n/fr-FR.ts +12 -9
  24. package/corpus/templates/clips/app/i18n/hi-IN.ts +13 -9
  25. package/corpus/templates/clips/app/i18n/ja-JP.ts +12 -9
  26. package/corpus/templates/clips/app/i18n/ko-KR.ts +12 -9
  27. package/corpus/templates/clips/app/i18n/pt-BR.ts +12 -9
  28. package/corpus/templates/clips/app/i18n/zh-CN.ts +19 -14
  29. package/corpus/templates/clips/app/i18n/zh-TW.ts +19 -14
  30. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +7 -0
  31. package/corpus/templates/clips/app/routes/record.tsx +2 -2
  32. package/corpus/templates/clips/changelog/2026-06-26-desktop-recordings-recover-more-reliably-when-macos-reports-.md +6 -0
  33. package/corpus/templates/clips/changelog/2026-06-26-slack-unfurls-now-recognize-recording-dashboard-links-pasted.md +6 -0
  34. package/corpus/templates/clips/changelog/2026-06-26-storage-setup-now-explains-why-clips-needs-connected-storage.md +6 -0
  35. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  36. package/corpus/templates/clips/chrome-extension/src/background.ts +24 -3
  37. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +28 -2
  38. package/corpus/templates/clips/chrome-extension/src/popup.html +16 -0
  39. package/corpus/templates/clips/chrome-extension/src/popup.ts +59 -7
  40. package/corpus/templates/clips/chrome-extension/src/sentry.ts +28 -1
  41. package/corpus/templates/clips/chrome-extension/src/styles.css +74 -0
  42. package/corpus/templates/clips/desktop/src/app.tsx +85 -18
  43. package/corpus/templates/clips/desktop/src/styles.css +55 -0
  44. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +88 -51
  45. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +12 -3
  46. package/corpus/templates/clips/server/lib/slack-unfurls.ts +5 -2
  47. package/corpus/templates/slides/actions/_app-url.ts +1 -1
  48. package/corpus/templates/slides/changelog/2026-06-26-deck-links-now-use-the-workspace-gateway-when-available-so-g.md +6 -0
  49. package/dist/agent/production-agent.d.ts.map +1 -1
  50. package/dist/agent/production-agent.js +0 -79
  51. package/dist/agent/production-agent.js.map +1 -1
  52. package/dist/agent/run-manager.d.ts.map +1 -1
  53. package/dist/agent/run-manager.js +12 -1
  54. package/dist/agent/run-manager.js.map +1 -1
  55. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  56. package/dist/client/agent-chat-adapter.js +15 -8
  57. package/dist/client/agent-chat-adapter.js.map +1 -1
  58. package/dist/client/session-replay.d.ts.map +1 -1
  59. package/dist/client/session-replay.js +55 -14
  60. package/dist/client/session-replay.js.map +1 -1
  61. package/dist/collab/awareness.d.ts +2 -2
  62. package/dist/collab/awareness.d.ts.map +1 -1
  63. package/dist/collab/routes.d.ts +2 -2
  64. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  65. package/dist/notifications/routes.d.ts +1 -1
  66. package/dist/observability/routes.d.ts +7 -7
  67. package/dist/onboarding/default-steps.js +3 -3
  68. package/dist/onboarding/default-steps.js.map +1 -1
  69. package/dist/progress/routes.d.ts +1 -1
  70. package/dist/resources/handlers.d.ts +3 -3
  71. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  72. package/dist/server/agent-chat-plugin.js +69 -154
  73. package/dist/server/agent-chat-plugin.js.map +1 -1
  74. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  75. package/dist/server/agent-run-context.d.ts +35 -0
  76. package/dist/server/agent-run-context.d.ts.map +1 -0
  77. package/dist/server/agent-run-context.js +137 -0
  78. package/dist/server/agent-run-context.js.map +1 -0
  79. package/dist/server/credential-provider.d.ts.map +1 -1
  80. package/dist/server/credential-provider.js +15 -4
  81. package/dist/server/credential-provider.js.map +1 -1
  82. package/dist/server/transcribe-voice.d.ts +1 -1
  83. package/docs/content/locales/zh-TW/durable-background-runs.md +74 -0
  84. package/package.json +1 -1
  85. package/corpus/templates/.retired/README.md +0 -9
  86. package/corpus/templates/.retired/calls/package.json +0 -8
  87. package/corpus/templates/.retired/code/package.json +0 -8
  88. package/corpus/templates/.retired/contracts/package.json +0 -8
  89. package/corpus/templates/.retired/images/package.json +0 -8
  90. package/corpus/templates/.retired/issues/package.json +0 -8
  91. package/corpus/templates/.retired/meeting-notes/package.json +0 -8
  92. package/corpus/templates/.retired/migration/package.json +0 -8
  93. package/corpus/templates/.retired/recruiting/package.json +0 -8
  94. package/corpus/templates/.retired/scheduling/package.json +0 -8
  95. package/corpus/templates/.retired/visual-plans/package.json +0 -8
  96. package/corpus/templates/.retired/voice/package.json +0 -8
  97. package/corpus/templates/.retired/workbench/package.json +0 -8
@@ -23,6 +23,15 @@ import { captureExtensionError, initExtensionSentry } from "./sentry";
23
23
 
24
24
  initExtensionSentry("offscreen");
25
25
 
26
+ const STORAGE_SETUP_REQUIRED_MESSAGE =
27
+ "Connect storage to finish saving this clip: Builder.io (free tier storage + AI) or S3-compatible storage.";
28
+ const STORAGE_SETUP_FAILURE_RE =
29
+ /video storage is not connected|no video storage configured|file upload provider|storage provider|connect builder|s3-compatible/i;
30
+
31
+ function isStorageSetupFailureMessage(message: string | null | undefined) {
32
+ return STORAGE_SETUP_FAILURE_RE.test(message ?? "");
33
+ }
34
+
26
35
  type CaptureMode = "screen" | "camera";
27
36
 
28
37
  type AcquireMessage = {
@@ -396,7 +405,14 @@ async function uploadChunk(
396
405
  body,
397
406
  });
398
407
  const text = await res.text().catch(() => "");
399
- const data = text ? (JSON.parse(text) as UploadResult) : {};
408
+ let data: UploadResult = {};
409
+ if (text) {
410
+ try {
411
+ data = JSON.parse(text) as UploadResult;
412
+ } catch {
413
+ data = {};
414
+ }
415
+ }
400
416
  if (!res.ok) {
401
417
  console.error(
402
418
  "[clips-offscreen] chunk upload failed",
@@ -405,9 +421,17 @@ async function uploadChunk(
405
421
  Boolean(recording.authToken),
406
422
  text.slice(0, 200),
407
423
  );
424
+ const storageSetupRequired =
425
+ data?.storageSetupRequired === true ||
426
+ isStorageSetupFailureMessage(data?.error || text);
408
427
  const error = new Error(
409
- data?.error || `Upload failed (${res.status}): ${text || res.statusText}`,
428
+ storageSetupRequired
429
+ ? STORAGE_SETUP_REQUIRED_MESSAGE
430
+ : data?.error ||
431
+ `Upload failed (${res.status}): ${text || res.statusText}`,
410
432
  );
433
+ (error as { storageSetupRequired?: boolean }).storageSetupRequired =
434
+ storageSetupRequired;
411
435
  captureExtensionError(error, {
412
436
  tags: {
413
437
  surface: "offscreen",
@@ -845,6 +869,8 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
845
869
  reportStatus(recording.sessionId, "error", {
846
870
  recordingId: recording.recordingId,
847
871
  error: error.message,
872
+ storageSetupRequired: (error as { storageSetupRequired?: boolean })
873
+ .storageSetupRequired,
848
874
  });
849
875
  recording.rejectStopped(error);
850
876
  }
@@ -405,6 +405,22 @@
405
405
 
406
406
  <span id="status" class="status" role="status" aria-live="polite"></span>
407
407
 
408
+ <div id="storage-help" class="storage-help" hidden>
409
+ <button id="storage-help-open" class="storage-help-open" type="button">
410
+ Use Builder.io (free)
411
+ </button>
412
+ <span aria-hidden="true">·</span>
413
+ <span class="storage-help-why" tabindex="0">
414
+ Why am I seeing this?
415
+ <span class="storage-help-details" role="tooltip">
416
+ Clips is 100% free and open source, so you need to hook up a way to
417
+ store your clips. Connect storage with Builder.io for free-tier
418
+ storage and AI, or use S3-compatible object storage and your own LLM
419
+ keys.
420
+ </span>
421
+ </span>
422
+ </div>
423
+
408
424
  <nav class="bottom-row" aria-label="Clips shortcuts">
409
425
  <button id="open-library" class="bottom-button" type="button">
410
426
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -93,9 +93,17 @@ const FEEDBACK_URL =
93
93
  const FEEDBACK_PLACEHOLDER = "Tell us what's on your mind...";
94
94
  const FEEDBACK_SUBMIT_TEXT = "Send feedback";
95
95
  const FEEDBACK_SUCCESS_MESSAGE = "Thanks for the feedback!";
96
+ const STORAGE_SETUP_REQUIRED_MESSAGE =
97
+ "Connect storage to finish saving this clip: Builder.io (free tier storage + AI) or S3-compatible storage.";
98
+ const STORAGE_SETUP_FAILURE_RE =
99
+ /video storage is not connected|no video storage configured|file upload provider|storage provider|connect builder|s3-compatible/i;
96
100
  const feedbackTarget = parseFeedbackTarget(FEEDBACK_URL);
97
101
  const feedbackSchemaCache = new Map<string, Promise<FeedbackFormSchema>>();
98
102
 
103
+ function isStorageSetupFailureMessage(message: string | null | undefined) {
104
+ return STORAGE_SETUP_FAILURE_RE.test(message ?? "");
105
+ }
106
+
99
107
  function screenSurface(
100
108
  value: CaptureSurface,
101
109
  ): Exclude<CaptureSurface, "camera"> {
@@ -507,6 +515,14 @@ function setStatus(message: string, kind: "info" | "error" = "info"): void {
507
515
  status.dataset.kind = kind;
508
516
  }
509
517
 
518
+ function setStorageHelp(visible: boolean): void {
519
+ byId<HTMLDivElement>("storage-help").hidden = !visible;
520
+ }
521
+
522
+ function storageSetupUrl(settings: ExtensionSettings): string {
523
+ return `${settings.clipsBaseUrl.replace(/\/+$/, "")}/record`;
524
+ }
525
+
510
526
  function renderMode(settings: ExtensionSettings): void {
511
527
  const mode = recordingMode(settings);
512
528
  for (const button of document.querySelectorAll<HTMLButtonElement>(
@@ -690,7 +706,10 @@ function renderActiveRecording(recording: NativeRecording | null): void {
690
706
  start.hidden = active;
691
707
  signIn.hidden = true;
692
708
  if (recordingActions) recordingActions.hidden = !active;
693
- if (!recording) return;
709
+ if (!recording) {
710
+ setStorageHelp(false);
711
+ return;
712
+ }
694
713
 
695
714
  recordingTitle.textContent = recording.targetTitle || "Current recording";
696
715
  const host = hostnameLabel(recording.targetUrl);
@@ -706,10 +725,16 @@ function renderActiveRecording(recording: NativeRecording | null): void {
706
725
  : recording.status === "stopping"
707
726
  ? "Stopping..."
708
727
  : recording.status === "error"
709
- ? recording.error || "Recording needs attention"
728
+ ? isStorageSetupFailureMessage(recording.error)
729
+ ? STORAGE_SETUP_REQUIRED_MESSAGE
730
+ : recording.error || "Recording needs attention"
710
731
  : `Recording ${formatDuration(recording.startedAtMs)}`;
711
732
  recordingStatus.dataset.kind =
712
733
  recording.status === "error" ? "error" : "info";
734
+ setStorageHelp(
735
+ recording.status === "error" &&
736
+ isStorageSetupFailureMessage(recording.error),
737
+ );
713
738
  }
714
739
 
715
740
  async function init(): Promise<void> {
@@ -751,6 +776,7 @@ async function init(): Promise<void> {
751
776
  const openSettings = byId<HTMLButtonElement>("open-settings");
752
777
  const openRecent = byId<HTMLButtonElement>("open-recent");
753
778
  const signIn = byId<HTMLButtonElement>("sign-in");
779
+ const storageHelpOpen = byId<HTMLButtonElement>("storage-help-open");
754
780
  let activeRecording: NativeRecording | null = null;
755
781
  let authStatus: AuthStatus = "checking";
756
782
  let feedbackOpenedAt = 0;
@@ -1071,6 +1097,7 @@ async function init(): Promise<void> {
1071
1097
  start.addEventListener("click", async () => {
1072
1098
  start.disabled = true;
1073
1099
  signIn.hidden = true;
1100
+ setStorageHelp(false);
1074
1101
  setStatus(""); // no chatty "Checking…/Starting…" text — the disabled button is enough
1075
1102
  try {
1076
1103
  authStatus = await readAuthStatus(settings);
@@ -1085,10 +1112,11 @@ async function init(): Promise<void> {
1085
1112
  if (!storageConfigured) {
1086
1113
  start.disabled = false;
1087
1114
  setStatus(
1088
- "Connect Builder.io or S3 storage in Clips, then start recording.",
1115
+ "Connect storage in Clips first: Builder.io (free tier storage + AI) or S3-compatible storage.",
1089
1116
  "error",
1090
1117
  );
1091
- await createTab(`${settings.clipsBaseUrl.replace(/\/+$/, "")}/record`);
1118
+ setStorageHelp(true);
1119
+ await createTab(storageSetupUrl(settings));
1092
1120
  window.close();
1093
1121
  return;
1094
1122
  }
@@ -1117,7 +1145,14 @@ async function init(): Promise<void> {
1117
1145
  setStatus("");
1118
1146
  return;
1119
1147
  }
1120
- setStatus(message, "error");
1148
+ const storageSetupFailure = isStorageSetupFailureMessage(message);
1149
+ setStorageHelp(storageSetupFailure);
1150
+ setStatus(
1151
+ storageSetupFailure
1152
+ ? "Connect storage in Clips first: Builder.io (free tier storage + AI) or S3-compatible storage."
1153
+ : message,
1154
+ "error",
1155
+ );
1121
1156
  } catch (err) {
1122
1157
  captureExtensionError(err, {
1123
1158
  tags: { surface: "popup", action: "start-recording" },
@@ -1128,13 +1163,24 @@ async function init(): Promise<void> {
1128
1163
  },
1129
1164
  });
1130
1165
  start.disabled = false;
1166
+ const message =
1167
+ err instanceof Error ? err.message : "Could not start Clips.";
1168
+ const storageSetupFailure = isStorageSetupFailureMessage(message);
1169
+ setStorageHelp(storageSetupFailure);
1131
1170
  setStatus(
1132
- err instanceof Error ? err.message : "Could not start Clips.",
1171
+ storageSetupFailure
1172
+ ? "Connect storage in Clips first: Builder.io (free tier storage + AI) or S3-compatible storage."
1173
+ : message,
1133
1174
  "error",
1134
1175
  );
1135
1176
  }
1136
1177
  });
1137
1178
 
1179
+ storageHelpOpen.addEventListener("click", async () => {
1180
+ await createTab(storageSetupUrl(settings));
1181
+ window.close();
1182
+ });
1183
+
1138
1184
  signIn.addEventListener("click", async () => {
1139
1185
  signIn.disabled = true;
1140
1186
  const response = await sendRuntimeMessage<PopupStartResponse>({
@@ -1161,7 +1207,13 @@ async function init(): Promise<void> {
1161
1207
  }
1162
1208
  stop.disabled = false;
1163
1209
  discard.disabled = false;
1164
- setStatus(response.error || "Could not stop recording.", "error");
1210
+ const message = response.error || "Could not stop recording.";
1211
+ const storageSetupFailure = isStorageSetupFailureMessage(message);
1212
+ setStorageHelp(storageSetupFailure);
1213
+ setStatus(
1214
+ storageSetupFailure ? STORAGE_SETUP_REQUIRED_MESSAGE : message,
1215
+ "error",
1216
+ );
1165
1217
  });
1166
1218
 
1167
1219
  discard.addEventListener("click", async () => {
@@ -67,6 +67,31 @@ function scrubEvent<T extends Sentry.Event>(event: T): T {
67
67
  return event;
68
68
  }
69
69
 
70
+ function shouldDropExpectedMediaPermissionError(
71
+ error: unknown,
72
+ context: CaptureContext,
73
+ ): boolean {
74
+ const tags = context.tags ?? {};
75
+ const isPermissionProbe =
76
+ tags.surface === "permission" &&
77
+ (tags.permission === "mic" || tags.permission === "cam");
78
+ const isOptionalBubbleCamera =
79
+ tags.surface === "overlay" && tags.overlayPart === "bubble";
80
+ if (!isPermissionProbe && !isOptionalBubbleCamera) return false;
81
+
82
+ const text =
83
+ error instanceof Error
84
+ ? `${error.name}: ${error.message}`
85
+ : String(error ?? "");
86
+
87
+ return (
88
+ /\bNotAllowedError\b/i.test(text) ||
89
+ /\bPermission (?:denied|dismissed)\b/i.test(text) ||
90
+ /\b(?:NotFoundError|DevicesNotFoundError)\b/i.test(text) ||
91
+ /\bRequested device not found\b/i.test(text)
92
+ );
93
+ }
94
+
70
95
  function scrubScopeString(value: string): string {
71
96
  const scrubbed = scrubUrl(value, { redactAllQueryValues: true });
72
97
  if (scrubbed !== value) return scrubbed;
@@ -175,7 +200,9 @@ export function captureExtensionError(
175
200
  error: unknown,
176
201
  context: CaptureContext = {},
177
202
  ): string | undefined {
178
- if (!initialized) return undefined;
203
+ if (!initialized || shouldDropExpectedMediaPermissionError(error, context)) {
204
+ return undefined;
205
+ }
179
206
  try {
180
207
  return Sentry.withScope((scope) => {
181
208
  if (context.tags) {
@@ -574,6 +574,80 @@ svg {
574
574
  color: var(--danger);
575
575
  }
576
576
 
577
+ .storage-help {
578
+ position: relative;
579
+ display: flex;
580
+ min-height: 18px;
581
+ align-items: center;
582
+ justify-content: center;
583
+ gap: 5px;
584
+ color: var(--fg-subtle);
585
+ font-size: 11px;
586
+ line-height: 1.3;
587
+ text-align: center;
588
+ }
589
+
590
+ .storage-help[hidden] {
591
+ display: none;
592
+ }
593
+
594
+ .storage-help-open,
595
+ .storage-help-why {
596
+ border: 0;
597
+ background: transparent;
598
+ color: var(--fg-muted);
599
+ font: inherit;
600
+ padding: 0;
601
+ text-decoration: underline;
602
+ text-decoration-style: dotted;
603
+ text-underline-offset: 3px;
604
+ }
605
+
606
+ .storage-help-open {
607
+ cursor: pointer;
608
+ }
609
+
610
+ .storage-help-why {
611
+ position: relative;
612
+ cursor: help;
613
+ }
614
+
615
+ .storage-help-open:hover,
616
+ .storage-help-open:focus-visible,
617
+ .storage-help-why:hover,
618
+ .storage-help-why:focus-visible {
619
+ color: var(--fg);
620
+ outline: none;
621
+ }
622
+
623
+ .storage-help-details {
624
+ position: absolute;
625
+ right: 0;
626
+ bottom: calc(100% + 8px);
627
+ z-index: 70;
628
+ width: 310px;
629
+ padding: 8px 10px;
630
+ border-radius: var(--radius-sm);
631
+ background: var(--bg);
632
+ box-shadow: var(--shadow-md);
633
+ color: var(--fg);
634
+ font-size: 12px;
635
+ line-height: 1.45;
636
+ opacity: 0;
637
+ pointer-events: none;
638
+ text-align: left;
639
+ transform: translateY(3px);
640
+ transition:
641
+ opacity 120ms ease-out,
642
+ transform 120ms ease-out;
643
+ }
644
+
645
+ .storage-help-why:hover .storage-help-details,
646
+ .storage-help-why:focus-visible .storage-help-details {
647
+ opacity: 1;
648
+ transform: translateY(0);
649
+ }
650
+
577
651
  .bottom-row {
578
652
  display: grid;
579
653
  grid-template-columns: repeat(3, 1fr);
@@ -3,6 +3,7 @@ import {
3
3
  IconArrowLeft,
4
4
  IconCircleCheck,
5
5
  IconDownload,
6
+ IconExternalLink,
6
7
  IconFolderOpen,
7
8
  IconPencil,
8
9
  IconInfoCircle,
@@ -118,6 +119,15 @@ type MeetingTranscriptionMode = "manual" | "ask" | "auto";
118
119
  type CaptureMode = "screen" | "screen-camera" | "camera";
119
120
  type VideoStorageStatus = "checking" | "configured" | "missing";
120
121
 
122
+ const STORAGE_SETUP_HELP_TEXT =
123
+ "Clips is 100% free and open source, so you need to hook up a way to store your clips. Connect storage with Builder.io for free-tier storage and AI, or use S3-compatible object storage and your own LLM keys.";
124
+ const STORAGE_SETUP_FAILURE_RE =
125
+ /video storage is not connected|no video storage configured|file upload provider|storage provider|connect builder|s3-compatible/i;
126
+
127
+ function isStorageSetupFailureMessage(message: string | null | undefined) {
128
+ return STORAGE_SETUP_FAILURE_RE.test(message ?? "");
129
+ }
130
+
121
131
  const STORAGE_KEY = "clips:server-url";
122
132
  const MODE_KEY = "clips:last-mode";
123
133
  const VOICE_SHORTCUT_KEY = "clips:voice-shortcut";
@@ -1559,7 +1569,11 @@ export function App() {
1559
1569
  } catch (err) {
1560
1570
  const message = err instanceof Error ? err.message : String(err);
1561
1571
  console.error("[clips-tray] retry saved upload failed:", err);
1562
- setRecError(message);
1572
+ setRecError(
1573
+ isStorageSetupFailureMessage(message)
1574
+ ? "Connect storage to finish uploading this saved clip: Builder.io (free tier storage + AI) or S3-compatible storage."
1575
+ : message,
1576
+ );
1563
1577
  await loadPendingUploads();
1564
1578
  } finally {
1565
1579
  setRetryingUploadId(null);
@@ -1636,20 +1650,23 @@ export function App() {
1636
1650
  });
1637
1651
  }
1638
1652
 
1639
- const openVideoStorageSetup = useCallback(() => {
1640
- const base = serverUrl.replace(/\/+$/, "");
1641
- setRecError(
1642
- "Connect Builder.io or S3-compatible storage before recording from desktop. Opening storage setup...",
1643
- );
1644
- void openExternal(`${base}/record`).catch((err) => {
1653
+ const openVideoStorageSetup = useCallback(
1654
+ (targetServerUrl?: string) => {
1655
+ const base = (targetServerUrl?.trim() || serverUrl).replace(/\/+$/, "");
1645
1656
  setRecError(
1646
- err instanceof Error
1647
- ? err.message
1648
- : "Could not open Clips storage setup.",
1657
+ "Connect storage before recording from desktop: Builder.io (free tier storage + AI) or S3-compatible storage. Opening storage setup...",
1649
1658
  );
1650
- });
1651
- void refreshVideoStorageStatus();
1652
- }, [refreshVideoStorageStatus, serverUrl]);
1659
+ void openExternal(`${base}/record`).catch((err) => {
1660
+ setRecError(
1661
+ err instanceof Error
1662
+ ? err.message
1663
+ : "Could not open Clips storage setup.",
1664
+ );
1665
+ });
1666
+ void refreshVideoStorageStatus();
1667
+ },
1668
+ [refreshVideoStorageStatus, serverUrl],
1669
+ );
1653
1670
 
1654
1671
  async function handleStartRecording(options?: {
1655
1672
  ignoreActiveRecorder?: boolean;
@@ -2148,6 +2165,7 @@ export function App() {
2148
2165
  onRetry={retryPendingUpload}
2149
2166
  onDiscard={discardPendingUpload}
2150
2167
  onOpenFolder={openPendingUploadFolder}
2168
+ onConnectStorage={(upload) => openVideoStorageSetup(upload.serverUrl)}
2151
2169
  />
2152
2170
  ) : null}
2153
2171
 
@@ -2407,6 +2425,7 @@ function PendingUploadBanner({
2407
2425
  onRetry,
2408
2426
  onDiscard,
2409
2427
  onOpenFolder,
2428
+ onConnectStorage,
2410
2429
  }: {
2411
2430
  uploads: PendingDesktopUpload[];
2412
2431
  retryingUploadId: string | null;
@@ -2416,11 +2435,13 @@ function PendingUploadBanner({
2416
2435
  onRetry: (upload: PendingDesktopUpload) => void;
2417
2436
  onDiscard: (upload: PendingDesktopUpload) => void;
2418
2437
  onOpenFolder: (upload: PendingDesktopUpload) => void;
2438
+ onConnectStorage: (upload: PendingDesktopUpload) => void;
2419
2439
  }) {
2420
2440
  const latest = uploads[0];
2421
2441
  if (!latest) return null;
2422
2442
 
2423
2443
  const retrying = retryingUploadId === latest.recordingId;
2444
+ const storageSetupFailure = isStorageSetupFailureMessage(latest.lastError);
2424
2445
  const exporting = exportingUploadId === latest.recordingId;
2425
2446
  const canOpenFolder = latest.kind === "native" && !!latest.folderPath;
2426
2447
  const canExport = latest.kind === "browser";
@@ -2430,6 +2451,16 @@ function PendingUploadBanner({
2430
2451
  uploads.length === 1
2431
2452
  ? "1 Clip saved locally"
2432
2453
  : `${uploads.length} Clips saved locally`;
2454
+ const nativeCorrupt = latest.kind === "native" && !!latest.corrupt;
2455
+ const title = nativeCorrupt
2456
+ ? uploads.length === 1
2457
+ ? "Clip could not be finalized"
2458
+ : "Some Clips could not be finalized"
2459
+ : storageSetupFailure
2460
+ ? uploads.length === 1
2461
+ ? "Connect storage to upload saved Clip"
2462
+ : "Connect storage to upload saved Clips"
2463
+ : savedLabel;
2433
2464
  const details = [
2434
2465
  latest.savedAt ? `saved ${formatAgo(latest.savedAt)}` : null,
2435
2466
  formatFileSize(latest.bytes),
@@ -2444,11 +2475,36 @@ function PendingUploadBanner({
2444
2475
  <IconUpload size={17} stroke={1.8} />
2445
2476
  </div>
2446
2477
  <div className="pending-upload-copy">
2447
- <div className="pending-upload-title">{savedLabel}</div>
2448
- <div className="pending-upload-sub">
2449
- {details.join(" · ")}
2450
- {errorText ? ` · ${errorText}` : ""}
2478
+ <div className="pending-upload-title">{title}</div>
2479
+ <div
2480
+ className={
2481
+ storageSetupFailure
2482
+ ? "pending-upload-sub pending-upload-sub-wrap"
2483
+ : "pending-upload-sub"
2484
+ }
2485
+ >
2486
+ {nativeCorrupt
2487
+ ? `${details.join(" · ")} · discard and record again`
2488
+ : storageSetupFailure
2489
+ ? `${details.join(" · ")} · your clip is safe locally`
2490
+ : `${details.join(" · ")}${errorText ? ` · ${errorText}` : ""}`}
2451
2491
  </div>
2492
+ {storageSetupFailure ? (
2493
+ <Tooltip>
2494
+ <TooltipTrigger asChild>
2495
+ <button type="button" className="pending-upload-why">
2496
+ Why am I seeing this?
2497
+ </button>
2498
+ </TooltipTrigger>
2499
+ <TooltipContent
2500
+ side="bottom"
2501
+ align="start"
2502
+ className="tooltip-content-wide"
2503
+ >
2504
+ {STORAGE_SETUP_HELP_TEXT}
2505
+ </TooltipContent>
2506
+ </Tooltip>
2507
+ ) : null}
2452
2508
  </div>
2453
2509
  <div className="pending-upload-actions">
2454
2510
  {canOpenFolder ? (
@@ -2488,6 +2544,17 @@ function PendingUploadBanner({
2488
2544
  </button>
2489
2545
  ) : (
2490
2546
  <>
2547
+ {storageSetupFailure ? (
2548
+ <button
2549
+ type="button"
2550
+ className="pending-upload-connect"
2551
+ disabled={actionsDisabled}
2552
+ onClick={() => onConnectStorage(latest)}
2553
+ >
2554
+ <IconExternalLink size={14} stroke={2} />
2555
+ Connect
2556
+ </button>
2557
+ ) : null}
2491
2558
  <button
2492
2559
  type="button"
2493
2560
  className="pending-upload-retry"
@@ -3705,7 +3772,7 @@ function Setup({
3705
3772
  className="secondary"
3706
3773
  onClick={connectBuilder}
3707
3774
  >
3708
- Connect Builder.io
3775
+ Use Builder.io (free)
3709
3776
  </button>
3710
3777
  ) : null}
3711
3778
  </div>
@@ -1448,6 +1448,10 @@ body[data-clips-route="recording-pill"] #root {
1448
1448
  transform-origin: var(--radix-tooltip-content-transform-origin);
1449
1449
  }
1450
1450
 
1451
+ .tooltip-content-wide {
1452
+ max-width: 320px;
1453
+ }
1454
+
1451
1455
  .tooltip-content[data-state="delayed-open"],
1452
1456
  .tooltip-content[data-state="instant-open"] {
1453
1457
  animation: tooltip-in 120ms ease-out;
@@ -1720,12 +1724,38 @@ body[data-clips-route="recording-pill"] #root {
1720
1724
  white-space: nowrap;
1721
1725
  }
1722
1726
 
1727
+ .pending-upload-sub-wrap {
1728
+ overflow: visible;
1729
+ white-space: normal;
1730
+ }
1731
+
1732
+ .pending-upload-why {
1733
+ margin-top: 3px;
1734
+ padding: 0;
1735
+ border: 0;
1736
+ background: transparent;
1737
+ color: #64748b;
1738
+ cursor: help;
1739
+ font-size: 11px;
1740
+ line-height: 1.2;
1741
+ text-decoration: underline;
1742
+ text-decoration-style: dotted;
1743
+ text-underline-offset: 3px;
1744
+ }
1745
+
1746
+ .pending-upload-why:hover,
1747
+ .pending-upload-why:focus-visible {
1748
+ color: #334155;
1749
+ outline: none;
1750
+ }
1751
+
1723
1752
  .pending-upload-actions {
1724
1753
  display: inline-flex;
1725
1754
  align-items: center;
1726
1755
  gap: 6px;
1727
1756
  }
1728
1757
 
1758
+ .pending-upload-connect,
1729
1759
  .pending-upload-retry {
1730
1760
  height: 28px;
1731
1761
  border: 1px solid #bfdbfe;
@@ -1746,6 +1776,10 @@ body[data-clips-route="recording-pill"] #root {
1746
1776
  opacity 120ms;
1747
1777
  }
1748
1778
 
1779
+ .pending-upload-connect {
1780
+ padding: 0 8px;
1781
+ }
1782
+
1749
1783
  .pending-upload-discard {
1750
1784
  width: 28px;
1751
1785
  height: 28px;
@@ -1789,6 +1823,11 @@ body[data-clips-route="recording-pill"] #root {
1789
1823
  border-color: #93c5fd;
1790
1824
  }
1791
1825
 
1826
+ .pending-upload-connect:hover {
1827
+ background: #dbeafe;
1828
+ border-color: #93c5fd;
1829
+ }
1830
+
1792
1831
  .pending-upload-folder:hover {
1793
1832
  background: #dbeafe;
1794
1833
  border-color: #93c5fd;
@@ -1801,6 +1840,7 @@ body[data-clips-route="recording-pill"] #root {
1801
1840
  }
1802
1841
 
1803
1842
  .pending-upload-retry:disabled,
1843
+ .pending-upload-connect:disabled,
1804
1844
  .pending-upload-folder:disabled,
1805
1845
  .pending-upload-discard:disabled {
1806
1846
  cursor: default;
@@ -1972,6 +2012,16 @@ body[data-clips-route="recording-pill"] #root {
1972
2012
  color: #93c5fd;
1973
2013
  }
1974
2014
 
2015
+ .pending-upload-why {
2016
+ color: #94a3b8;
2017
+ }
2018
+
2019
+ .pending-upload-why:hover,
2020
+ .pending-upload-why:focus-visible {
2021
+ color: #cbd5e1;
2022
+ }
2023
+
2024
+ .pending-upload-connect,
1975
2025
  .pending-upload-retry {
1976
2026
  background: rgba(15, 23, 42, 0.7);
1977
2027
  border-color: rgba(96, 165, 250, 0.38);
@@ -1995,6 +2045,11 @@ body[data-clips-route="recording-pill"] #root {
1995
2045
  border-color: rgba(147, 197, 253, 0.62);
1996
2046
  }
1997
2047
 
2048
+ .pending-upload-connect:hover {
2049
+ background: rgba(30, 64, 175, 0.34);
2050
+ border-color: rgba(147, 197, 253, 0.62);
2051
+ }
2052
+
1998
2053
  .pending-upload-folder:hover {
1999
2054
  background: rgba(30, 64, 175, 0.34);
2000
2055
  border-color: rgba(147, 197, 253, 0.62);