@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +53 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/design-connect.ts +419 -19
- package/corpus/core/src/client/AgentPanel.tsx +33 -1
- package/corpus/core/src/client/ConnectBuilderCard.tsx +1 -0
- package/corpus/core/src/client/chat/index.ts +1 -0
- package/corpus/core/src/client/chat/run-recovery.tsx +9 -7
- package/corpus/core/src/client/chat-view-transition.ts +33 -2
- package/corpus/core/src/client/composer/PromptComposer.tsx +21 -2
- package/corpus/core/src/client/index.ts +1 -0
- package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -4
- package/corpus/core/src/client/sharing/ShareButton.tsx +3 -3
- package/corpus/core/src/client/use-agent-chat-home-handoff.ts +31 -3
- package/corpus/core/src/db/client.ts +29 -2
- package/corpus/core/src/provider-api/index.ts +29 -0
- package/corpus/core/src/server/core-routes-plugin.ts +58 -20
- package/corpus/core/src/styles/agent-native.css +54 -0
- package/corpus/core/src/usage/store.ts +2 -2
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +118 -10
- package/corpus/templates/analytics/app/components/layout/layout-route-policy.ts +7 -0
- package/corpus/templates/analytics/app/global.css +8 -0
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +7 -1
- package/corpus/templates/analytics/app/lib/chat-handoff.ts +34 -0
- package/corpus/templates/analytics/app/pages/Ask.tsx +9 -18
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +2 -2
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +109 -21
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +4 -6
- package/corpus/templates/analytics/changelog/2026-06-30-ask-opens-a-fresh-chat-unless-you-were-just-chatting-and-das.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-30-dashboard-email-reports-are-more-reliable-and-use-cleaner.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-30-dashboard-toolbar-controls-use-a-quieter-share-button-and-ve.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-30-session-replay-rows-show-whole-minute-durations-in-the-playl.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-30-session-replays-now-render-captured-styling-and-keep-the-eve.md +6 -0
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +16 -3
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +31 -14
- package/corpus/templates/assets/actions/open-asset-picker.ts +9 -0
- package/corpus/templates/assets/app/global.css +15 -0
- package/corpus/templates/assets/app/hooks/use-navigation-state.ts +4 -0
- package/corpus/templates/assets/app/routes/library.tsx +90 -16
- package/corpus/templates/assets/changelog/2026-06-30-the-assets-picker-now-supports-a-vertical-embedded-layout-fo.md +6 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +1 -12
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -5
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -8
- package/corpus/templates/clips/changelog/2026-06-30-extension-save-recovery-avoids-false-upload-failures.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-30-longer-chrome-extension-recordings-now-keep-saving-reliably-.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/background.ts +19 -10
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +38 -12
- package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +224 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +119 -16
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +16 -0
- package/corpus/templates/clips/server/lib/recording-upload-state.ts +48 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +9 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +63 -13
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +74 -0
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +32 -0
- package/corpus/templates/design/AGENTS.md +27 -0
- package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1 -1
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +5 -5
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +63 -20
- package/corpus/templates/design/actions/apply-motion-edit.ts +67 -69
- package/corpus/templates/design/actions/apply-shader-fill.ts +148 -56
- package/corpus/templates/design/actions/capture-design-state.ts +27 -4
- package/corpus/templates/design/actions/connect-localhost.ts +59 -22
- package/corpus/templates/design/actions/export-zip.ts +23 -8
- package/corpus/templates/design/actions/generate-design.ts +53 -1
- package/corpus/templates/design/actions/get-component-details.ts +5 -19
- package/corpus/templates/design/actions/get-motion-timeline.ts +135 -10
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +116 -0
- package/corpus/templates/design/actions/import-design-tokens.ts +521 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +10 -1
- package/corpus/templates/design/actions/insert-design-native-asset.ts +268 -0
- package/corpus/templates/design/actions/insert-figma-library-asset.ts +233 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +32 -10
- package/corpus/templates/design/actions/list-design-native-assets.ts +105 -0
- package/corpus/templates/design/actions/list-figma-library-assets.ts +292 -0
- package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +238 -0
- package/corpus/templates/design/actions/navigate.ts +18 -3
- package/corpus/templates/design/actions/provider-api-catalog.ts +2 -2
- package/corpus/templates/design/actions/provider-api-docs.ts +1 -1
- package/corpus/templates/design/actions/provider-api-request.ts +3 -3
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +48 -0
- package/corpus/templates/design/actions/run-design-audit.ts +53 -4
- package/corpus/templates/design/actions/run-design-extension-action.ts +1 -1
- package/corpus/templates/design/actions/view-screen.ts +1 -1
- package/corpus/templates/design/actions/write-local-file.ts +213 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +230 -3308
- package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +30 -20
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +776 -287
- package/corpus/templates/design/app/components/design/EditPanel.tsx +212 -208
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +14 -2
- package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +161 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +235 -266
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +490 -55
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +43 -62
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +34 -61
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +251 -0
- package/corpus/templates/design/app/components/design/bridge/codegen.ts +125 -0
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +4037 -0
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +62 -0
- package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +272 -0
- package/corpus/templates/design/app/components/design/bridge/nav.bridge.ts +116 -0
- package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +24 -0
- package/corpus/templates/design/app/components/design/bridge/shader-fill-preview.bridge.ts +78 -0
- package/corpus/templates/design/app/components/design/bridge/tsconfig.json +17 -0
- package/corpus/templates/design/app/components/design/bridge/tweak.bridge.ts +28 -0
- package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +43 -0
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +45 -16
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +88 -9
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +94 -9
- package/corpus/templates/design/app/components/layout/Layout.tsx +19 -3
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/design/app/global.css +7 -0
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +23 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +21 -0
- package/corpus/templates/design/app/i18n-data.ts +358 -0
- package/corpus/templates/design/app/lib/pending-generation.ts +1 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2402 -499
- package/corpus/templates/design/app/pages/Index.tsx +5 -1
- package/corpus/templates/design/changelog/2026-06-30-applying-the-one-click-fix-for-a-small-tap-target-accessibil.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-canvas-creation-screen-dragging-undo-redo-and-image-fill-edi.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-prop-edits-and-shader-fills-now-preserve-rapid-sam.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-can-browse-figma-library-components-and-insert-them-i.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-now-has-a-figma-style-left-rail-for-file-agent-assets.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-now-has-native-components-embedded-media-and-figma-im.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-preview-now-includes-a-publish-app-waitlist-option-fr.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-s-left-rail-now-matches-the-sidebar-chrome-and-uses-t.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-s-tools-create-menu-now-stays-compact-instead-of-rend.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-starter-prompts-now-begin-generating-immediately-and-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-tools-now-use-a-quieter-figma-style-list-layout.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-drawing-rectangles-on-the-all-screens-canvas-no-longer-flash.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-fixed-code-native-design-studio-edits-so-component-props-mot.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-fixed-visual-editing-hover-overlays-and-spacing-handles-so-s.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-motion-edits-now-save-automatically-without-a-separate-css-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-motion-timelines-now-reopen-as-editable-tracks-after-writing.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-new-design-prompts-with-uploaded-screenshots-work-reliably-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-screen-previews-in-all-screens-view-now-keep-rounded-outline.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-selection-outlines-now-sit-flush-on-rectangles-drawn-directl.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-share-options-use-a-tighter-design-editor-popover-with-compa.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-collapsed-motion-panel-now-stays-tucked-under-the-left-s.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-design-inspector-now-shows-states-and-review-in-a-tighte.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-sidebar-keeps-the-organization-picker-space-stable-while.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-tokens-can-now-be-imported-from-pasted-notes-code-files-fold.md +6 -0
- package/corpus/templates/design/e2e/helpers.ts +168 -65
- package/corpus/templates/design/package.json +3 -1
- package/corpus/templates/design/server/db/schema.ts +23 -0
- package/corpus/templates/design/server/handlers/uploads.ts +22 -1
- package/corpus/templates/design/server/lib/provider-api.ts +7 -4
- package/corpus/templates/design/server/lib/verify-write-grant.ts +108 -0
- package/corpus/templates/design/server/plugins/db.ts +39 -0
- package/corpus/templates/design/server/register-secrets.ts +38 -0
- package/corpus/templates/design/shared/board-file.ts +228 -0
- package/corpus/templates/design/shared/board-objects.ts +288 -0
- package/corpus/templates/design/shared/code-layer.ts +52 -0
- package/corpus/templates/design/shared/motion-compiler.ts +58 -7
- package/corpus/templates/design/shared/motion-timeline.ts +2 -2
- package/corpus/templates/design/tsconfig.json +2 -1
- package/corpus/templates/slides/AGENTS.md +9 -0
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +4 -0
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +4 -24
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +29 -20
- package/corpus/templates/slides/app/lib/export-pptx-client.ts +365 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -9
- package/corpus/templates/slides/app/vite-env.d.ts +18 -0
- package/corpus/templates/slides/changelog/2026-06-30-powerpoint-downloads-now-preserve-the-rendered-slide-layout-.md +6 -0
- package/corpus/templates/slides/changelog/2026-06-30-unavailable-decks-open-in-a-cleaner-full-height-centered-sta.md +6 -0
- package/corpus/templates/slides/package.json +1 -0
- package/corpus/templates/slides/vite.config.ts +1 -0
- package/dist/cli/design-connect.d.ts +25 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +357 -18
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/client/AgentPanel.d.ts +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +18 -3
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
- package/dist/client/ConnectBuilderCard.js +1 -0
- package/dist/client/ConnectBuilderCard.js.map +1 -1
- package/dist/client/chat/index.d.ts +1 -1
- package/dist/client/chat/index.d.ts.map +1 -1
- package/dist/client/chat/index.js +1 -1
- package/dist/client/chat/index.js.map +1 -1
- package/dist/client/chat/run-recovery.d.ts.map +1 -1
- package/dist/client/chat/run-recovery.js +5 -5
- package/dist/client/chat/run-recovery.js.map +1 -1
- package/dist/client/chat-view-transition.d.ts +6 -0
- package/dist/client/chat-view-transition.d.ts.map +1 -1
- package/dist/client/chat-view-transition.js +23 -2
- package/dist/client/chat-view-transition.js.map +1 -1
- package/dist/client/composer/PromptComposer.d.ts.map +1 -1
- package/dist/client/composer/PromptComposer.js +3 -2
- package/dist/client/composer/PromptComposer.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
- package/dist/client/org/OrgSwitcher.js +11 -4
- package/dist/client/org/OrgSwitcher.js.map +1 -1
- package/dist/client/sharing/ShareButton.js +3 -3
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/use-agent-chat-home-handoff.d.ts +8 -2
- package/dist/client/use-agent-chat-home-handoff.d.ts.map +1 -1
- package/dist/client/use-agent-chat-home-handoff.js +20 -6
- package/dist/client/use-agent-chat-home-handoff.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/client.d.ts +1 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +28 -2
- package/dist/db/client.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +8 -8
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/index.d.ts +5 -4
- package/dist/provider-api/index.d.ts.map +1 -1
- package/dist/provider-api/index.js +28 -0
- package/dist/provider-api/index.js.map +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +25 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +43 -18
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/styles/agent-native.css +54 -0
- package/dist/usage/store.js +2 -2
- package/dist/usage/store.js.map +1 -1
- package/package.json +1 -1
|
@@ -195,21 +195,33 @@
|
|
|
195
195
|
|
|
196
196
|
window.addEventListener("resize", () => applyBubbleGeom());
|
|
197
197
|
|
|
198
|
-
function
|
|
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)
|
|
205
|
+
if (chrome.runtime.lastError) {
|
|
206
|
+
callback(null);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
204
209
|
const parts = (response as { parts?: unknown } | undefined)?.parts;
|
|
205
|
-
|
|
210
|
+
callback(Array.isArray(parts) ? (parts as OverlayPart[]) : []);
|
|
206
211
|
},
|
|
207
212
|
);
|
|
208
213
|
} catch {
|
|
209
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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({
|
|
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
|
);
|