@agent-native/core 0.80.11 → 0.81.1

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 (230) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +53 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/design-connect.ts +419 -19
  5. package/corpus/core/src/client/AgentPanel.tsx +33 -1
  6. package/corpus/core/src/client/ConnectBuilderCard.tsx +1 -0
  7. package/corpus/core/src/client/chat/index.ts +1 -0
  8. package/corpus/core/src/client/chat/run-recovery.tsx +9 -7
  9. package/corpus/core/src/client/chat-view-transition.ts +33 -2
  10. package/corpus/core/src/client/composer/PromptComposer.tsx +21 -2
  11. package/corpus/core/src/client/index.ts +1 -0
  12. package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -4
  13. package/corpus/core/src/client/sharing/ShareButton.tsx +3 -3
  14. package/corpus/core/src/client/use-agent-chat-home-handoff.ts +31 -3
  15. package/corpus/core/src/db/client.ts +29 -2
  16. package/corpus/core/src/provider-api/index.ts +29 -0
  17. package/corpus/core/src/server/core-routes-plugin.ts +58 -20
  18. package/corpus/core/src/styles/agent-native.css +54 -0
  19. package/corpus/core/src/usage/store.ts +2 -2
  20. package/corpus/templates/analytics/app/components/layout/Layout.tsx +118 -10
  21. package/corpus/templates/analytics/app/components/layout/layout-route-policy.ts +7 -0
  22. package/corpus/templates/analytics/app/global.css +8 -0
  23. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +7 -1
  24. package/corpus/templates/analytics/app/lib/chat-handoff.ts +34 -0
  25. package/corpus/templates/analytics/app/pages/Ask.tsx +9 -18
  26. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +2 -2
  27. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +109 -21
  28. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +4 -6
  29. package/corpus/templates/analytics/changelog/2026-06-30-ask-opens-a-fresh-chat-unless-you-were-just-chatting-and-das.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-06-30-dashboard-email-reports-are-more-reliable-and-use-cleaner.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-06-30-dashboard-toolbar-controls-use-a-quieter-share-button-and-ve.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-06-30-session-replay-rows-show-whole-minute-durations-in-the-playl.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-06-30-session-replays-now-render-captured-styling-and-keep-the-eve.md +6 -0
  34. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +16 -3
  35. package/corpus/templates/analytics/server/lib/dashboard-report.ts +31 -14
  36. package/corpus/templates/assets/actions/open-asset-picker.ts +9 -0
  37. package/corpus/templates/assets/app/global.css +15 -0
  38. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +4 -0
  39. package/corpus/templates/assets/app/routes/library.tsx +90 -16
  40. package/corpus/templates/assets/changelog/2026-06-30-the-assets-picker-now-supports-a-vertical-embedded-layout-fo.md +6 -0
  41. package/corpus/templates/clips/actions/finalize-recording.ts +1 -12
  42. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -5
  43. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -8
  44. package/corpus/templates/clips/changelog/2026-06-30-extension-save-recovery-avoids-false-upload-failures.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-06-30-longer-chrome-extension-recordings-now-keep-saving-reliably-.md +6 -0
  46. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  47. package/corpus/templates/clips/chrome-extension/src/background.ts +19 -10
  48. package/corpus/templates/clips/chrome-extension/src/content-script.ts +38 -12
  49. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +224 -0
  50. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +119 -16
  51. package/corpus/templates/clips/chrome-extension/src/overlay.ts +16 -0
  52. package/corpus/templates/clips/server/lib/recording-upload-state.ts +48 -0
  53. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +9 -1
  54. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +63 -13
  55. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +74 -0
  56. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +32 -0
  57. package/corpus/templates/design/AGENTS.md +27 -0
  58. package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1 -1
  59. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +5 -5
  60. package/corpus/templates/design/actions/apply-component-prop-edit.ts +63 -20
  61. package/corpus/templates/design/actions/apply-motion-edit.ts +67 -69
  62. package/corpus/templates/design/actions/apply-shader-fill.ts +148 -56
  63. package/corpus/templates/design/actions/capture-design-state.ts +27 -4
  64. package/corpus/templates/design/actions/connect-localhost.ts +59 -22
  65. package/corpus/templates/design/actions/export-zip.ts +23 -8
  66. package/corpus/templates/design/actions/generate-design.ts +53 -1
  67. package/corpus/templates/design/actions/get-component-details.ts +5 -19
  68. package/corpus/templates/design/actions/get-motion-timeline.ts +135 -10
  69. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +116 -0
  70. package/corpus/templates/design/actions/import-design-tokens.ts +521 -0
  71. package/corpus/templates/design/actions/index-design-tokens.ts +10 -1
  72. package/corpus/templates/design/actions/insert-design-native-asset.ts +268 -0
  73. package/corpus/templates/design/actions/insert-figma-library-asset.ts +233 -0
  74. package/corpus/templates/design/actions/list-design-extensions.ts +32 -10
  75. package/corpus/templates/design/actions/list-design-native-assets.ts +105 -0
  76. package/corpus/templates/design/actions/list-figma-library-assets.ts +292 -0
  77. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +238 -0
  78. package/corpus/templates/design/actions/navigate.ts +18 -3
  79. package/corpus/templates/design/actions/provider-api-catalog.ts +2 -2
  80. package/corpus/templates/design/actions/provider-api-docs.ts +1 -1
  81. package/corpus/templates/design/actions/provider-api-request.ts +3 -3
  82. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +48 -0
  83. package/corpus/templates/design/actions/run-design-audit.ts +53 -4
  84. package/corpus/templates/design/actions/run-design-extension-action.ts +1 -1
  85. package/corpus/templates/design/actions/view-screen.ts +1 -1
  86. package/corpus/templates/design/actions/write-local-file.ts +213 -0
  87. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +230 -3308
  88. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +30 -20
  89. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +776 -287
  90. package/corpus/templates/design/app/components/design/EditPanel.tsx +212 -208
  91. package/corpus/templates/design/app/components/design/LayersPanel.tsx +14 -2
  92. package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +161 -0
  93. package/corpus/templates/design/app/components/design/MotionDock.tsx +235 -266
  94. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +490 -55
  95. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +43 -62
  96. package/corpus/templates/design/app/components/design/StatesPanel.tsx +34 -61
  97. package/corpus/templates/design/app/components/design/TokensPanel.tsx +251 -0
  98. package/corpus/templates/design/app/components/design/bridge/codegen.ts +125 -0
  99. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +4037 -0
  100. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +62 -0
  101. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +272 -0
  102. package/corpus/templates/design/app/components/design/bridge/nav.bridge.ts +116 -0
  103. package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +24 -0
  104. package/corpus/templates/design/app/components/design/bridge/shader-fill-preview.bridge.ts +78 -0
  105. package/corpus/templates/design/app/components/design/bridge/tsconfig.json +17 -0
  106. package/corpus/templates/design/app/components/design/bridge/tweak.bridge.ts +28 -0
  107. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +43 -0
  108. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +45 -16
  109. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +88 -9
  110. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +94 -9
  111. package/corpus/templates/design/app/components/layout/Layout.tsx +19 -3
  112. package/corpus/templates/design/app/components/layout/Sidebar.tsx +1 -1
  113. package/corpus/templates/design/app/global.css +7 -0
  114. package/corpus/templates/design/app/hooks/use-navigation-state.ts +23 -0
  115. package/corpus/templates/design/app/i18n/zh-TW.ts +21 -0
  116. package/corpus/templates/design/app/i18n-data.ts +358 -0
  117. package/corpus/templates/design/app/lib/pending-generation.ts +1 -0
  118. package/corpus/templates/design/app/pages/DesignEditor.tsx +2402 -499
  119. package/corpus/templates/design/app/pages/Index.tsx +5 -1
  120. package/corpus/templates/design/changelog/2026-06-30-applying-the-one-click-fix-for-a-small-tap-target-accessibil.md +6 -0
  121. package/corpus/templates/design/changelog/2026-06-30-canvas-creation-screen-dragging-undo-redo-and-image-fill-edi.md +6 -0
  122. package/corpus/templates/design/changelog/2026-06-30-component-prop-edits-and-shader-fills-now-preserve-rapid-sam.md +6 -0
  123. package/corpus/templates/design/changelog/2026-06-30-design-can-browse-figma-library-components-and-insert-them-i.md +6 -0
  124. package/corpus/templates/design/changelog/2026-06-30-design-now-has-a-figma-style-left-rail-for-file-agent-assets.md +6 -0
  125. package/corpus/templates/design/changelog/2026-06-30-design-now-has-native-components-embedded-media-and-figma-im.md +6 -0
  126. package/corpus/templates/design/changelog/2026-06-30-design-preview-now-includes-a-publish-app-waitlist-option-fr.md +6 -0
  127. package/corpus/templates/design/changelog/2026-06-30-design-s-left-rail-now-matches-the-sidebar-chrome-and-uses-t.md +6 -0
  128. package/corpus/templates/design/changelog/2026-06-30-design-s-tools-create-menu-now-stays-compact-instead-of-rend.md +6 -0
  129. package/corpus/templates/design/changelog/2026-06-30-design-starter-prompts-now-begin-generating-immediately-and-.md +6 -0
  130. package/corpus/templates/design/changelog/2026-06-30-design-tools-now-use-a-quieter-figma-style-list-layout.md +6 -0
  131. package/corpus/templates/design/changelog/2026-06-30-drawing-rectangles-on-the-all-screens-canvas-no-longer-flash.md +6 -0
  132. package/corpus/templates/design/changelog/2026-06-30-fixed-code-native-design-studio-edits-so-component-props-mot.md +6 -0
  133. package/corpus/templates/design/changelog/2026-06-30-fixed-visual-editing-hover-overlays-and-spacing-handles-so-s.md +6 -0
  134. package/corpus/templates/design/changelog/2026-06-30-motion-edits-now-save-automatically-without-a-separate-css-a.md +6 -0
  135. package/corpus/templates/design/changelog/2026-06-30-motion-timelines-now-reopen-as-editable-tracks-after-writing.md +6 -0
  136. package/corpus/templates/design/changelog/2026-06-30-new-design-prompts-with-uploaded-screenshots-work-reliably-o.md +6 -0
  137. package/corpus/templates/design/changelog/2026-06-30-screen-previews-in-all-screens-view-now-keep-rounded-outline.md +6 -0
  138. package/corpus/templates/design/changelog/2026-06-30-selection-outlines-now-sit-flush-on-rectangles-drawn-directl.md +6 -0
  139. package/corpus/templates/design/changelog/2026-06-30-share-options-use-a-tighter-design-editor-popover-with-compa.md +6 -0
  140. package/corpus/templates/design/changelog/2026-06-30-the-collapsed-motion-panel-now-stays-tucked-under-the-left-s.md +6 -0
  141. package/corpus/templates/design/changelog/2026-06-30-the-design-inspector-now-shows-states-and-review-in-a-tighte.md +6 -0
  142. package/corpus/templates/design/changelog/2026-06-30-the-sidebar-keeps-the-organization-picker-space-stable-while.md +6 -0
  143. package/corpus/templates/design/changelog/2026-06-30-tokens-can-now-be-imported-from-pasted-notes-code-files-fold.md +6 -0
  144. package/corpus/templates/design/e2e/helpers.ts +168 -65
  145. package/corpus/templates/design/package.json +3 -1
  146. package/corpus/templates/design/server/db/schema.ts +23 -0
  147. package/corpus/templates/design/server/handlers/uploads.ts +22 -1
  148. package/corpus/templates/design/server/lib/provider-api.ts +7 -4
  149. package/corpus/templates/design/server/lib/verify-write-grant.ts +108 -0
  150. package/corpus/templates/design/server/plugins/db.ts +39 -0
  151. package/corpus/templates/design/server/register-secrets.ts +38 -0
  152. package/corpus/templates/design/shared/board-file.ts +228 -0
  153. package/corpus/templates/design/shared/board-objects.ts +288 -0
  154. package/corpus/templates/design/shared/code-layer.ts +52 -0
  155. package/corpus/templates/design/shared/motion-compiler.ts +58 -7
  156. package/corpus/templates/design/shared/motion-timeline.ts +2 -2
  157. package/corpus/templates/design/tsconfig.json +2 -1
  158. package/corpus/templates/slides/AGENTS.md +9 -0
  159. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +4 -0
  160. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +4 -24
  161. package/corpus/templates/slides/app/lib/export-pdf-client.ts +29 -20
  162. package/corpus/templates/slides/app/lib/export-pptx-client.ts +365 -0
  163. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -9
  164. package/corpus/templates/slides/app/vite-env.d.ts +18 -0
  165. package/corpus/templates/slides/changelog/2026-06-30-powerpoint-downloads-now-preserve-the-rendered-slide-layout-.md +6 -0
  166. package/corpus/templates/slides/changelog/2026-06-30-unavailable-decks-open-in-a-cleaner-full-height-centered-sta.md +6 -0
  167. package/corpus/templates/slides/package.json +1 -0
  168. package/corpus/templates/slides/vite.config.ts +1 -0
  169. package/dist/cli/design-connect.d.ts +25 -0
  170. package/dist/cli/design-connect.d.ts.map +1 -1
  171. package/dist/cli/design-connect.js +357 -18
  172. package/dist/cli/design-connect.js.map +1 -1
  173. package/dist/client/AgentPanel.d.ts +1 -1
  174. package/dist/client/AgentPanel.d.ts.map +1 -1
  175. package/dist/client/AgentPanel.js +18 -3
  176. package/dist/client/AgentPanel.js.map +1 -1
  177. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  178. package/dist/client/ConnectBuilderCard.js +1 -0
  179. package/dist/client/ConnectBuilderCard.js.map +1 -1
  180. package/dist/client/chat/index.d.ts +1 -1
  181. package/dist/client/chat/index.d.ts.map +1 -1
  182. package/dist/client/chat/index.js +1 -1
  183. package/dist/client/chat/index.js.map +1 -1
  184. package/dist/client/chat/run-recovery.d.ts.map +1 -1
  185. package/dist/client/chat/run-recovery.js +5 -5
  186. package/dist/client/chat/run-recovery.js.map +1 -1
  187. package/dist/client/chat-view-transition.d.ts +6 -0
  188. package/dist/client/chat-view-transition.d.ts.map +1 -1
  189. package/dist/client/chat-view-transition.js +23 -2
  190. package/dist/client/chat-view-transition.js.map +1 -1
  191. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  192. package/dist/client/composer/PromptComposer.js +3 -2
  193. package/dist/client/composer/PromptComposer.js.map +1 -1
  194. package/dist/client/index.d.ts +1 -1
  195. package/dist/client/index.d.ts.map +1 -1
  196. package/dist/client/index.js +1 -1
  197. package/dist/client/index.js.map +1 -1
  198. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  199. package/dist/client/org/OrgSwitcher.js +11 -4
  200. package/dist/client/org/OrgSwitcher.js.map +1 -1
  201. package/dist/client/sharing/ShareButton.js +3 -3
  202. package/dist/client/sharing/ShareButton.js.map +1 -1
  203. package/dist/client/use-agent-chat-home-handoff.d.ts +8 -2
  204. package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
  205. package/dist/client/use-agent-chat-home-handoff.js +20 -6
  206. package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
  207. package/dist/collab/routes.d.ts +1 -1
  208. package/dist/db/client.d.ts +1 -0
  209. package/dist/db/client.d.ts.map +1 -1
  210. package/dist/db/client.js +28 -2
  211. package/dist/db/client.js.map +1 -1
  212. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  213. package/dist/notifications/routes.d.ts +2 -2
  214. package/dist/observability/routes.d.ts +8 -8
  215. package/dist/progress/routes.d.ts +1 -1
  216. package/dist/provider-api/index.d.ts +5 -4
  217. package/dist/provider-api/index.d.ts.map +1 -1
  218. package/dist/provider-api/index.js +28 -0
  219. package/dist/provider-api/index.js.map +1 -1
  220. package/dist/resources/handlers.d.ts +3 -3
  221. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  222. package/dist/server/core-routes-plugin.d.ts +25 -0
  223. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  224. package/dist/server/core-routes-plugin.js +43 -18
  225. package/dist/server/core-routes-plugin.js.map +1 -1
  226. package/dist/server/transcribe-voice.d.ts +1 -1
  227. package/dist/styles/agent-native.css +54 -0
  228. package/dist/usage/store.js +2 -2
  229. package/dist/usage/store.js.map +1 -1
  230. package/package.json +1 -1
@@ -195,21 +195,33 @@
195
195
 
196
196
  window.addEventListener("resize", () => applyBubbleGeom());
197
197
 
198
- function requestState(): void {
198
+ function readCurrentParts(
199
+ callback: (parts: OverlayPart[] | null) => void,
200
+ ): void {
199
201
  try {
200
202
  chrome.runtime.sendMessage(
201
203
  { type: "CLIPS_CONTENT_HELLO" },
202
204
  (response) => {
203
- if (chrome.runtime.lastError) return;
205
+ if (chrome.runtime.lastError) {
206
+ callback(null);
207
+ return;
208
+ }
204
209
  const parts = (response as { parts?: unknown } | undefined)?.parts;
205
- reconcile(Array.isArray(parts) ? (parts as OverlayPart[]) : []);
210
+ callback(Array.isArray(parts) ? (parts as OverlayPart[]) : []);
206
211
  },
207
212
  );
208
213
  } catch {
209
- /* worker asleep; will resync on next message */
214
+ callback(null);
210
215
  }
211
216
  }
212
217
 
218
+ function requestState(): void {
219
+ readCurrentParts((parts) => {
220
+ if (parts) reconcile(parts);
221
+ /* worker asleep; will resync on next message */
222
+ });
223
+ }
224
+
213
225
  // Only wake the service worker (via requestState) when a recording is actually
214
226
  // active. When idle this script does nothing but keep its message listener
215
227
  // registered, so a recording that starts later still reaches this tab via the
@@ -396,14 +408,23 @@
396
408
  function mountDeferredCountdown(): void {
397
409
  countdownDeferred = false;
398
410
  clearTimeout(countdownFallbackTimer);
399
- hideConnecting();
400
- setBubbleHidden(false);
401
- const container = document.getElementById(
402
- CONTAINER_ID,
403
- ) as HTMLDivElement | null;
404
- if (container && !document.getElementById(partFrameId("countdown"))) {
405
- mountPart(container, "countdown");
406
- }
411
+ readCurrentParts((parts) => {
412
+ if (!parts?.includes("countdown")) {
413
+ hideConnecting();
414
+ setBubbleHidden(false);
415
+ if (parts) reconcile(parts);
416
+ return;
417
+ }
418
+
419
+ hideConnecting();
420
+ setBubbleHidden(false);
421
+ const container = document.getElementById(
422
+ CONTAINER_ID,
423
+ ) as HTMLDivElement | null;
424
+ if (container && !document.getElementById(partFrameId("countdown"))) {
425
+ mountPart(container, "countdown");
426
+ }
427
+ });
407
428
  }
408
429
 
409
430
  function reconcile(parts: OverlayPart[]): void {
@@ -493,6 +514,11 @@
493
514
  if (countdownDeferred) mountDeferredCountdown();
494
515
  return;
495
516
  }
517
+ if (data.kind === "countdown-finished") {
518
+ document.getElementById(partFrameId("countdown"))?.remove();
519
+ hideConnecting();
520
+ return;
521
+ }
496
522
  if (data.kind === "bubble-drag-start") {
497
523
  startBubbleDrag();
498
524
  return;
@@ -0,0 +1,224 @@
1
+ type FetchLike = (
2
+ input: RequestInfo | URL,
3
+ init?: RequestInit,
4
+ ) => Promise<Response>;
5
+
6
+ export type RecoveredReadyRecording = {
7
+ ok: true;
8
+ finalized: true;
9
+ recoveredAfterFinalizeError: true;
10
+ id: string;
11
+ recordingId: string;
12
+ status: "ready";
13
+ videoUrl: string;
14
+ durationMs?: number;
15
+ width?: number;
16
+ height?: number;
17
+ hasAudio?: boolean;
18
+ hasCamera?: boolean;
19
+ };
20
+
21
+ type ProbeResult =
22
+ | { ready: true; result: RecoveredReadyRecording }
23
+ | { ready: false; terminal: boolean; status?: string };
24
+
25
+ const DEFAULT_READY_RECOVERY_TIMEOUT_MS = 90_000;
26
+ const DEFAULT_READY_RECOVERY_INTERVAL_MS = 3_000;
27
+ const DEFAULT_READY_RECOVERY_FETCH_TIMEOUT_MS = 2_000;
28
+
29
+ function sleep(ms: number): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+
33
+ export function publicRecordingStatusUrl(
34
+ uploadUrl: string,
35
+ recordingId: string,
36
+ ): string {
37
+ const url = new URL(uploadUrl);
38
+ const match = url.pathname.match(/^(.*)\/api\/uploads\/[^/]+\/chunk$/);
39
+ const basePath = match?.[1] ?? "";
40
+ url.pathname = `${basePath}/api/public-recording`;
41
+ url.search = "";
42
+ url.searchParams.set("id", recordingId);
43
+ return url.toString();
44
+ }
45
+
46
+ export function authenticatedRecordingStatusUrl(
47
+ uploadUrl: string,
48
+ recordingId: string,
49
+ ): string {
50
+ const url = new URL(uploadUrl);
51
+ const match = url.pathname.match(/^(.*)\/api\/uploads\/[^/]+\/chunk$/);
52
+ const basePath = match?.[1] ?? "";
53
+ url.pathname = `${basePath}/api/uploads/${encodeURIComponent(recordingId)}/status`;
54
+ url.search = "";
55
+ return url.toString();
56
+ }
57
+
58
+ function optionalNumber(value: unknown): number | undefined {
59
+ return typeof value === "number" && Number.isFinite(value)
60
+ ? value
61
+ : undefined;
62
+ }
63
+
64
+ function optionalBoolean(value: unknown): boolean | undefined {
65
+ return typeof value === "boolean" ? value : undefined;
66
+ }
67
+
68
+ export function readyRecordingFromPublicPayload(
69
+ payload: unknown,
70
+ fallbackRecordingId: string,
71
+ ): ProbeResult {
72
+ const root =
73
+ payload && typeof payload === "object"
74
+ ? (payload as Record<string, unknown>)
75
+ : null;
76
+ const recording =
77
+ root?.recording && typeof root.recording === "object"
78
+ ? (root.recording as Record<string, unknown>)
79
+ : null;
80
+ const status =
81
+ typeof recording?.status === "string" ? recording.status : undefined;
82
+
83
+ if (!recording) return { ready: false, terminal: false };
84
+ if (status === "failed") return { ready: false, terminal: true, status };
85
+
86
+ const videoUrl =
87
+ typeof recording.videoUrl === "string" ? recording.videoUrl : "";
88
+ if (status !== "ready" || !videoUrl) {
89
+ return { ready: false, terminal: false, status };
90
+ }
91
+
92
+ const id =
93
+ typeof recording.id === "string" && recording.id
94
+ ? recording.id
95
+ : fallbackRecordingId;
96
+ return {
97
+ ready: true,
98
+ result: {
99
+ ok: true,
100
+ finalized: true,
101
+ recoveredAfterFinalizeError: true,
102
+ id,
103
+ recordingId: id,
104
+ status: "ready",
105
+ videoUrl,
106
+ durationMs: optionalNumber(recording.durationMs),
107
+ width: optionalNumber(recording.width),
108
+ height: optionalNumber(recording.height),
109
+ hasAudio: optionalBoolean(recording.hasAudio),
110
+ hasCamera: optionalBoolean(recording.hasCamera),
111
+ },
112
+ };
113
+ }
114
+
115
+ async function fetchWithTimeout(
116
+ fetchImpl: FetchLike,
117
+ url: string,
118
+ init: RequestInit,
119
+ timeoutMs: number,
120
+ ): Promise<Response> {
121
+ if (timeoutMs <= 0 || typeof AbortController === "undefined") {
122
+ return fetchImpl(url, init);
123
+ }
124
+
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
127
+ try {
128
+ return await fetchImpl(url, { ...init, signal: controller.signal });
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+
134
+ export async function waitForReadyRecordingAfterFinalizeError(args: {
135
+ uploadUrl: string;
136
+ recordingId: string;
137
+ authToken?: string | null;
138
+ fetchImpl?: FetchLike;
139
+ sleepImpl?: (ms: number) => Promise<void>;
140
+ timeoutMs?: number;
141
+ intervalMs?: number;
142
+ fetchTimeoutMs?: number;
143
+ }): Promise<RecoveredReadyRecording | null> {
144
+ const fetchImpl = args.fetchImpl ?? fetch;
145
+ const sleepImpl = args.sleepImpl ?? sleep;
146
+ const timeoutMs = Math.max(
147
+ 1,
148
+ args.timeoutMs ?? DEFAULT_READY_RECOVERY_TIMEOUT_MS,
149
+ );
150
+ const intervalMs = Math.max(
151
+ 1,
152
+ args.intervalMs ?? DEFAULT_READY_RECOVERY_INTERVAL_MS,
153
+ );
154
+ const attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs));
155
+ let url: string;
156
+ let authenticatedUrl: string | null = null;
157
+ try {
158
+ url = publicRecordingStatusUrl(args.uploadUrl, args.recordingId);
159
+ authenticatedUrl = args.authToken
160
+ ? authenticatedRecordingStatusUrl(args.uploadUrl, args.recordingId)
161
+ : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+
166
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
167
+ const urls = authenticatedUrl
168
+ ? [
169
+ { url: authenticatedUrl, authenticated: true },
170
+ { url, authenticated: false },
171
+ ]
172
+ : [{ url, authenticated: false }];
173
+
174
+ for (const endpoint of urls) {
175
+ const headers: Record<string, string> = {
176
+ Accept: "application/json",
177
+ "X-Agent-Native-Frontend": "1",
178
+ };
179
+ if (endpoint.authenticated && args.authToken) {
180
+ headers.Authorization = `Bearer ${args.authToken}`;
181
+ }
182
+
183
+ try {
184
+ const response = await fetchWithTimeout(
185
+ fetchImpl,
186
+ endpoint.url,
187
+ {
188
+ method: "GET",
189
+ headers,
190
+ credentials: "include",
191
+ cache: "no-store",
192
+ },
193
+ args.fetchTimeoutMs ?? DEFAULT_READY_RECOVERY_FETCH_TIMEOUT_MS,
194
+ );
195
+
196
+ if (response.ok) {
197
+ const payload = await response.json().catch(() => null);
198
+ const probe = readyRecordingFromPublicPayload(
199
+ payload,
200
+ args.recordingId,
201
+ );
202
+ if (probe.ready) return probe.result;
203
+ if (probe.terminal) return null;
204
+ break;
205
+ }
206
+
207
+ if (
208
+ response.status >= 400 &&
209
+ response.status < 500 &&
210
+ !endpoint.authenticated &&
211
+ !authenticatedUrl
212
+ ) {
213
+ return null;
214
+ }
215
+ } catch {
216
+ // Try the public endpoint fallback below, then keep polling.
217
+ }
218
+ }
219
+
220
+ if (attempt < attempts - 1) await sleepImpl(intervalMs);
221
+ }
222
+
223
+ return null;
224
+ }
@@ -20,6 +20,7 @@ import { scheduleReadyChime } from "@shared/recording-audio";
20
20
  import { chunkUploadUrl, pickMimeType } from "@shared/recording-core";
21
21
  import { MAX_UPLOAD_BYTES } from "@shared/upload-limits";
22
22
 
23
+ import { waitForReadyRecordingAfterFinalizeError } from "./finalize-recovery";
23
24
  import { captureExtensionError, initExtensionSentry } from "./sentry";
24
25
 
25
26
  initExtensionSentry("offscreen");
@@ -33,6 +34,17 @@ function isStorageSetupFailureMessage(message: string | null | undefined) {
33
34
  return STORAGE_SETUP_FAILURE_RE.test(message ?? "");
34
35
  }
35
36
 
37
+ function isFinalUploadRecoveryCandidate(error: Error): boolean {
38
+ const tagged = error as {
39
+ finalUpload?: boolean;
40
+ status?: number;
41
+ storageSetupRequired?: boolean;
42
+ };
43
+ if (!tagged.finalUpload || tagged.storageSetupRequired) return false;
44
+ if (tagged.status === 413) return false;
45
+ return !/too large|exceeds.*limit|chunk too large/i.test(error.message);
46
+ }
47
+
36
48
  type CaptureMode = "screen" | "camera";
37
49
 
38
50
  type AcquireMessage = {
@@ -79,6 +91,8 @@ type UploadResult = {
79
91
  recordingId?: string;
80
92
  videoUrl?: string;
81
93
  status?: string;
94
+ finalized?: boolean;
95
+ recoveredAfterFinalizeError?: boolean;
82
96
  waitingForStorage?: boolean;
83
97
  storageSetupRequired?: boolean;
84
98
  error?: string;
@@ -456,8 +470,12 @@ async function uploadChunk(
456
470
  : data?.error ||
457
471
  `Upload failed (${res.status}): ${text || res.statusText}`,
458
472
  );
459
- (error as { storageSetupRequired?: boolean }).storageSetupRequired =
460
- storageSetupRequired;
473
+ const uploadError = error as {
474
+ status?: number;
475
+ storageSetupRequired?: boolean;
476
+ };
477
+ uploadError.status = res.status;
478
+ uploadError.storageSetupRequired = storageSetupRequired;
461
479
  captureExtensionError(error, {
462
480
  tags: {
463
481
  surface: "offscreen",
@@ -480,6 +498,57 @@ async function uploadChunk(
480
498
  return data;
481
499
  }
482
500
 
501
+ function uploadAbortUrl(uploadUrl: string): string | null {
502
+ try {
503
+ const url = new URL(uploadUrl);
504
+ url.pathname = url.pathname.replace(/\/chunk$/, "/abort");
505
+ url.search = "";
506
+ return url.toString();
507
+ } catch {
508
+ return null;
509
+ }
510
+ }
511
+
512
+ async function abortServerUpload(
513
+ recording: ActiveRecording,
514
+ reason: string,
515
+ ): Promise<void> {
516
+ const url = uploadAbortUrl(recording.uploadUrl);
517
+ if (!url) return;
518
+ const headers: Record<string, string> = {
519
+ "Content-Type": "application/json",
520
+ "X-Agent-Native-Frontend": "1",
521
+ };
522
+ if (recording.authToken) {
523
+ headers.Authorization = `Bearer ${recording.authToken}`;
524
+ }
525
+ const controller =
526
+ typeof AbortController === "undefined" ? null : new AbortController();
527
+ const timer = controller
528
+ ? window.setTimeout(() => controller.abort(), 4_000)
529
+ : undefined;
530
+ try {
531
+ const response = await fetch(url, {
532
+ method: "POST",
533
+ headers,
534
+ credentials: "include",
535
+ body: JSON.stringify({ reason }),
536
+ signal: controller?.signal,
537
+ });
538
+ if (!response.ok) {
539
+ console.warn(
540
+ "[clips-offscreen] abort upload returned",
541
+ response.status,
542
+ await response.text().catch(() => ""),
543
+ );
544
+ }
545
+ } catch (err) {
546
+ console.warn("[clips-offscreen] abort upload failed", err);
547
+ } finally {
548
+ if (timer) window.clearTimeout(timer);
549
+ }
550
+ }
551
+
483
552
  // Keep a local copy of every recorded blob so a failed upload can still be
484
553
  // saved to disk. Blob references are browser-managed (often disk-backed), so
485
554
  // this is far cheaper than holding ArrayBuffers. We stop retaining past the
@@ -930,20 +999,27 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
930
999
  durationMs,
931
1000
  );
932
1001
  }
933
- const result = await uploadChunk(
934
- recording,
935
- new Blob([], { type: recording.mimeType }),
936
- recording.chunkIndex,
937
- {
938
- isFinal: true,
939
- total: recording.chunkIndex,
940
- durationMs,
941
- width: recording.dimensions.width,
942
- height: recording.dimensions.height,
943
- hasAudio: recording.hasAudio,
944
- hasCamera: recording.hasCamera,
945
- },
946
- );
1002
+ let result: UploadResult;
1003
+ try {
1004
+ result = await uploadChunk(
1005
+ recording,
1006
+ new Blob([], { type: recording.mimeType }),
1007
+ recording.chunkIndex,
1008
+ {
1009
+ isFinal: true,
1010
+ total: recording.chunkIndex,
1011
+ durationMs,
1012
+ width: recording.dimensions.width,
1013
+ height: recording.dimensions.height,
1014
+ hasAudio: recording.hasAudio,
1015
+ hasCamera: recording.hasCamera,
1016
+ },
1017
+ );
1018
+ } catch (err) {
1019
+ const error = err instanceof Error ? err : new Error(String(err));
1020
+ (error as { finalUpload?: boolean }).finalUpload = true;
1021
+ throw error;
1022
+ }
947
1023
  cleanup(recording);
948
1024
  if (activeRecording === recording) activeRecording = null;
949
1025
  reportStatus(recording.sessionId, "complete", {
@@ -955,6 +1031,30 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
955
1031
  cleanup(recording);
956
1032
  if (activeRecording === recording) activeRecording = null;
957
1033
  const error = err instanceof Error ? err : new Error(String(err));
1034
+
1035
+ if (isFinalUploadRecoveryCandidate(error)) {
1036
+ const recovered = await waitForReadyRecordingAfterFinalizeError({
1037
+ uploadUrl: recording.uploadUrl,
1038
+ recordingId: recording.recordingId,
1039
+ authToken: recording.authToken,
1040
+ });
1041
+ if (recovered) {
1042
+ console.warn(
1043
+ "[clips-offscreen] final upload looked failed, but the recording is ready; treating as saved.",
1044
+ {
1045
+ recordingId: recording.recordingId,
1046
+ originalError: error.message,
1047
+ },
1048
+ );
1049
+ reportStatus(recording.sessionId, "complete", {
1050
+ recordingId: recording.recordingId,
1051
+ result: recovered,
1052
+ });
1053
+ recording.resolveStopped(recovered);
1054
+ return;
1055
+ }
1056
+ }
1057
+
958
1058
  captureExtensionError(error, {
959
1059
  tags: {
960
1060
  surface: "offscreen",
@@ -969,6 +1069,9 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
969
1069
  });
970
1070
  // The upload failed — save the buffered recording to disk so it isn't lost.
971
1071
  const saved = await saveRecordingToDisk(recording);
1072
+ if (!(error as { storageSetupRequired?: boolean }).storageSetupRequired) {
1073
+ await abortServerUpload(recording, error.message);
1074
+ }
972
1075
  reportStatus(recording.sessionId, "error", {
973
1076
  recordingId: recording.recordingId,
974
1077
  error: error.message,
@@ -28,6 +28,21 @@ function postToolbarSize(height: number): void {
28
28
  }
29
29
  }
30
30
 
31
+ function postCountdownFinished(): void {
32
+ try {
33
+ window.parent.postMessage(
34
+ {
35
+ source: "clips-overlay",
36
+ kind: "countdown-finished",
37
+ part: "countdown",
38
+ },
39
+ "*",
40
+ );
41
+ } catch {
42
+ /* parent gone */
43
+ }
44
+ }
45
+
31
46
  type OverlayState = {
32
47
  phase: OverlayPhase;
33
48
  baseElapsedMs: number;
@@ -244,6 +259,7 @@ function initCountdown(): void {
244
259
  if (!doneSent) {
245
260
  doneSent = true;
246
261
  send("CLIPS_OVERLAY_COUNTDOWN_DONE");
262
+ postCountdownFinished();
247
263
  }
248
264
  return;
249
265
  }
@@ -0,0 +1,48 @@
1
+ import { getDbExec, isPostgres } from "@agent-native/core/db";
2
+
3
+ function escapeLike(value: string): string {
4
+ return value.replace(/[!%_]/g, (match) => `!${match}`);
5
+ }
6
+
7
+ function chunkPrefix(recordingId: string): string {
8
+ return `recording-chunks-${recordingId}-`;
9
+ }
10
+
11
+ function likePrefix(prefix: string): string {
12
+ return `${escapeLike(prefix)}%`;
13
+ }
14
+
15
+ function numberFromRowValue(value: unknown): number {
16
+ if (typeof value === "number" && Number.isFinite(value)) return value;
17
+ if (typeof value === "bigint") return Number(value);
18
+ if (typeof value === "string") {
19
+ const parsed = Number(value);
20
+ return Number.isFinite(parsed) ? parsed : 0;
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ export async function listRecordingChunkKeys(
26
+ ownerEmail: string,
27
+ recordingId: string,
28
+ ): Promise<string[]> {
29
+ const { rows } = await getDbExec().execute({
30
+ sql: `SELECT key FROM application_state WHERE session_id = ? AND key LIKE ? ESCAPE '!'`,
31
+ args: [ownerEmail, likePrefix(chunkPrefix(recordingId))],
32
+ });
33
+ return rows.map((row) => String(row.key));
34
+ }
35
+
36
+ export async function sumRecordingChunkBytes(
37
+ ownerEmail: string,
38
+ recordingId: string,
39
+ ): Promise<number> {
40
+ const bytesExpression = isPostgres()
41
+ ? `COALESCE(SUM((value::jsonb ->> 'bytes')::bigint), 0)`
42
+ : `COALESCE(SUM(json_extract(value, '$.bytes')), 0)`;
43
+ const { rows } = await getDbExec().execute({
44
+ sql: `SELECT ${bytesExpression} AS bytes FROM application_state WHERE session_id = ? AND key LIKE ? ESCAPE '!'`,
45
+ args: [ownerEmail, likePrefix(chunkPrefix(recordingId))],
46
+ });
47
+ return numberFromRowValue(rows[0]?.bytes);
48
+ }
@@ -45,7 +45,11 @@ export default defineEventHandler(async (event: H3Event) => {
45
45
  const db = getDb();
46
46
 
47
47
  const [existing] = await db
48
- .select({ id: schema.recordings.id })
48
+ .select({
49
+ id: schema.recordings.id,
50
+ status: schema.recordings.status,
51
+ videoUrl: schema.recordings.videoUrl,
52
+ })
49
53
  .from(schema.recordings)
50
54
  .where(
51
55
  and(
@@ -59,6 +63,10 @@ export default defineEventHandler(async (event: H3Event) => {
59
63
  return { error: "Recording not found" };
60
64
  }
61
65
 
66
+ if (existing.status === "ready" && existing.videoUrl) {
67
+ return { ok: true, recordingId, alreadyReady: true, chunksCleared: 0 };
68
+ }
69
+
62
70
  const cleared = await deleteAppStateByPrefix(
63
71
  `recording-chunks-${recordingId}-`,
64
72
  );