@agent-native/core 0.175.2-nightly-20260826165737 → 0.175.3
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/templates/clips/app/components/player/comments-panel.tsx +1 -1
- package/corpus/templates/clips/desktop/src/app.tsx +171 -51
- package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
- package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
- package/corpus/templates/clips/desktop/src/main.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
- package/corpus/templates/clips/desktop/src/styles.css +20 -308
- package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +50 -28
- package/corpus/templates/slides/app/context/DeckContext.tsx +78 -23
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -5
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/package.json +3 -3
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
package/corpus/README.md
CHANGED
|
@@ -681,7 +681,7 @@ function CommentComposer({
|
|
|
681
681
|
className={cn(
|
|
682
682
|
"resize-none border-0 bg-background text-sm",
|
|
683
683
|
isSharePresentation
|
|
684
|
-
? "min-h-10 flex-1 border-0
|
|
684
|
+
? "min-h-10 flex-1 border-0 px-3 py-2 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
685
685
|
: "min-h-[60px]",
|
|
686
686
|
)}
|
|
687
687
|
onKeyDown={(e) => {
|
|
@@ -408,6 +408,7 @@ const CAM_ON_KEY = "clips:camera-on";
|
|
|
408
408
|
const MIC_ON_KEY = "clips:mic-on";
|
|
409
409
|
const SYSTEM_AUDIO_KEY = "clips:system-audio";
|
|
410
410
|
const READINESS_REVIEWED_KEY = "clips:readiness-reviewed";
|
|
411
|
+
const VIDEO_STORAGE_CONFIGURED_KEY = "clips:video-storage-configured";
|
|
411
412
|
// The docs section for the tray's rolling buffer, published under the same
|
|
412
413
|
// Rewind name the settings tab uses.
|
|
413
414
|
const REWIND_DOCS_URL =
|
|
@@ -431,6 +432,10 @@ function stopRestartHandoff(handoff: RestartHandoff): void {
|
|
|
431
432
|
[handoff.displayStream, handoff.audioStream].forEach((stream) =>
|
|
432
433
|
stream?.getTracks().forEach((track) => track.stop()),
|
|
433
434
|
);
|
|
435
|
+
// Every creation site attaches its own catch, so this teardown promise
|
|
436
|
+
// can't reject — this guard keeps a handoff abandoned mid-restart from
|
|
437
|
+
// ever surfacing an unhandled rejection if a future site forgets.
|
|
438
|
+
void handoff.transcriptionTornDown?.catch(() => {});
|
|
434
439
|
}
|
|
435
440
|
|
|
436
441
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
@@ -490,64 +495,77 @@ async function fetchWithAbortTimeout(
|
|
|
490
495
|
|
|
491
496
|
async function hasConfiguredVideoStorage(
|
|
492
497
|
serverUrl: string,
|
|
498
|
+
account: string | null,
|
|
493
499
|
): Promise<VideoStorageProbe> {
|
|
494
500
|
const base = serverUrl.replace(/\/+$/, "");
|
|
495
501
|
|
|
496
|
-
//
|
|
497
|
-
// or
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
502
|
+
// One endpoint's answer: "configured", "missing" (a definitive
|
|
503
|
+
// not-configured), or "unknown" (threw, non-OK, or unparseable).
|
|
504
|
+
const probeEndpoint = async (path: string): Promise<VideoStorageProbe> => {
|
|
505
|
+
try {
|
|
506
|
+
const res = await fetchWithAbortTimeout(
|
|
507
|
+
`${base}${path}`,
|
|
508
|
+
{
|
|
509
|
+
credentials: "include",
|
|
510
|
+
cache: "no-store",
|
|
511
|
+
},
|
|
512
|
+
VIDEO_STORAGE_PROBE_TIMEOUT_MS,
|
|
513
|
+
);
|
|
514
|
+
if (!res.ok) return "unknown";
|
|
515
|
+
// coercion-ok: an unparseable body maps to the typed "unknown" probe
|
|
516
|
+
// result, which callers treat as distinct from configured/missing.
|
|
517
|
+
const body = (await res.json().catch(() => null)) as {
|
|
511
518
|
configured?: boolean;
|
|
512
519
|
} | null;
|
|
513
|
-
if (body)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
520
|
+
if (!body) return "unknown";
|
|
521
|
+
return body.configured ? "configured" : "missing";
|
|
522
|
+
} catch {
|
|
523
|
+
return "unknown";
|
|
517
524
|
}
|
|
518
|
-
}
|
|
519
|
-
// Fall through to the Builder status endpoint.
|
|
520
|
-
}
|
|
525
|
+
};
|
|
521
526
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
} catch {
|
|
541
|
-
// Network error or unreachable server — treat as indeterminate below.
|
|
527
|
+
const probes = [
|
|
528
|
+
probeEndpoint("/_agent-native/file-upload/status"),
|
|
529
|
+
probeEndpoint("/_agent-native/builder/status"),
|
|
530
|
+
];
|
|
531
|
+
// The probes run concurrently. "configured" wins as soon as either endpoint
|
|
532
|
+
// reports it, but "missing" is only declared after both have settled — a
|
|
533
|
+
// Builder-credits-only user has file-upload configured:false and builder
|
|
534
|
+
// configured:true and must not be routed to storage setup.
|
|
535
|
+
let probe: VideoStorageProbe;
|
|
536
|
+
if ((await Promise.race(probes)) === "configured") {
|
|
537
|
+
probe = "configured";
|
|
538
|
+
} else {
|
|
539
|
+
const results = await Promise.all(probes);
|
|
540
|
+
probe = results.includes("configured")
|
|
541
|
+
? "configured"
|
|
542
|
+
: results.includes("missing")
|
|
543
|
+
? "missing"
|
|
544
|
+
: "unknown";
|
|
542
545
|
}
|
|
543
|
-
|
|
544
|
-
|
|
546
|
+
// Last-known-good cache: seeds the next launch's Start button so it isn't
|
|
547
|
+
// held behind this round-trip. Only "configured" is ever cached —
|
|
548
|
+
// "missing"/"unknown" must always re-probe — and only when we know whose
|
|
549
|
+
// answer it is, so an unauthenticated probe never writes one.
|
|
550
|
+
if (probe === "configured" && account) {
|
|
551
|
+
saveBool(videoStorageConfiguredKey(serverUrl, account), true);
|
|
552
|
+
}
|
|
553
|
+
return probe;
|
|
545
554
|
}
|
|
546
555
|
|
|
547
556
|
function authTokenStorageKey(serverUrl: string): string {
|
|
548
557
|
return `${AUTH_TOKEN_KEY}:${originForServer(serverUrl)}`;
|
|
549
558
|
}
|
|
550
559
|
|
|
560
|
+
// Whether video storage is configured is a fact about one account on one
|
|
561
|
+
// server: both status endpoints answer as the authenticated user. A key any
|
|
562
|
+
// coarser lets one answer enable Start for a server or an account it was never
|
|
563
|
+
// about — including after a sign-out. `account` is null before the session
|
|
564
|
+
// probe settles, which is not an identity, so nothing is cached or seeded then.
|
|
565
|
+
function videoStorageConfiguredKey(serverUrl: string, account: string): string {
|
|
566
|
+
return `${VIDEO_STORAGE_CONFIGURED_KEY}:${originForServer(serverUrl)}:${account}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
551
569
|
function loadDesktopAuthToken(serverUrl: string): string {
|
|
552
570
|
return loadString(authTokenStorageKey(serverUrl), "");
|
|
553
571
|
}
|
|
@@ -1234,6 +1252,10 @@ export function App({
|
|
|
1234
1252
|
const serverHostForSignIn = serverUrl
|
|
1235
1253
|
.replace(/^https?:\/\//, "")
|
|
1236
1254
|
.replace(/\/+$/, "");
|
|
1255
|
+
// Seeded from the last-known-good cache so a machine that ever probed
|
|
1256
|
+
// Starts at "checking" and is seeded from the cache only once the signed-in
|
|
1257
|
+
// account is known — the cached answer belongs to one account on one server,
|
|
1258
|
+
// and the seed effect below is what applies it.
|
|
1237
1259
|
const [videoStorageStatus, setVideoStorageStatus] =
|
|
1238
1260
|
useState<VideoStorageStatus>("checking");
|
|
1239
1261
|
const [signedInAs, setSignedInAs] = useState<string | null>(null);
|
|
@@ -1295,14 +1317,43 @@ export function App({
|
|
|
1295
1317
|
setDesktopAuthContext(serverUrl, loadDesktopAuthToken(serverUrl));
|
|
1296
1318
|
}, [serverUrl]);
|
|
1297
1319
|
|
|
1320
|
+
// Who and where `videoStorageStatus` is an answer about. Every probe captures
|
|
1321
|
+
// this and drops its result if it has since moved on, so an in-flight probe
|
|
1322
|
+
// can never land one account-and-server's answer on another's.
|
|
1323
|
+
const videoStorageIdentity = `${originForServer(serverUrl)}|${signedInAs ?? ""}`;
|
|
1324
|
+
const videoStorageIdentityRef = useRef(videoStorageIdentity);
|
|
1325
|
+
// A change of server or account makes the current answer meaningless, and the
|
|
1326
|
+
// probe below deliberately never downgrades "configured" to "checking", so
|
|
1327
|
+
// without this the previous answer would keep Start enabled against the new
|
|
1328
|
+
// identity until its first probe lands. Re-seed from that identity's own
|
|
1329
|
+
// cache — which is also what applies the cache at launch, once the session
|
|
1330
|
+
// probe has said who is signed in.
|
|
1331
|
+
useEffect(() => {
|
|
1332
|
+
videoStorageIdentityRef.current = videoStorageIdentity;
|
|
1333
|
+
setVideoStorageStatus(
|
|
1334
|
+
signedInAs &&
|
|
1335
|
+
loadBool(videoStorageConfiguredKey(serverUrl, signedInAs), false)
|
|
1336
|
+
? "configured"
|
|
1337
|
+
: "checking",
|
|
1338
|
+
);
|
|
1339
|
+
}, [videoStorageIdentity, serverUrl, signedInAs]);
|
|
1340
|
+
|
|
1298
1341
|
const refreshVideoStorageStatus = useCallback(async () => {
|
|
1299
1342
|
if (authStatus !== "authed" || localRecordingMode !== "off") {
|
|
1300
1343
|
setVideoStorageStatus("configured");
|
|
1301
1344
|
return true;
|
|
1302
1345
|
}
|
|
1303
1346
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1347
|
+
// Deliberately no "checking" reset here: the status may already be seeded
|
|
1348
|
+
// from the last-known-good cache or the mount-time warmup probe, and
|
|
1349
|
+
// downgrading "configured" to "checking" would re-disable Start for the
|
|
1350
|
+
// probe's whole round-trip. A definitive probe result below still wins.
|
|
1351
|
+
const probedIdentity = videoStorageIdentity;
|
|
1352
|
+
const probe = await hasConfiguredVideoStorage(serverUrl, signedInAs);
|
|
1353
|
+
// The server or the account moved on while this was in flight: this answer
|
|
1354
|
+
// is about an identity the UI is no longer using, and "configured over
|
|
1355
|
+
// there" is not evidence about what Start would record to now.
|
|
1356
|
+
if (videoStorageIdentityRef.current !== probedIdentity) return false;
|
|
1306
1357
|
if (probe === "unknown") {
|
|
1307
1358
|
// The check couldn't be completed (offline/unreachable). Never downgrade
|
|
1308
1359
|
// an already-connected user to "missing" on an indeterminate result;
|
|
@@ -1316,12 +1367,25 @@ export function App({
|
|
|
1316
1367
|
}
|
|
1317
1368
|
setVideoStorageStatus(probe);
|
|
1318
1369
|
return probe === "configured";
|
|
1319
|
-
}, [
|
|
1370
|
+
}, [
|
|
1371
|
+
authStatus,
|
|
1372
|
+
localRecordingMode,
|
|
1373
|
+
serverUrl,
|
|
1374
|
+
signedInAs,
|
|
1375
|
+
videoStorageIdentity,
|
|
1376
|
+
]);
|
|
1320
1377
|
|
|
1321
1378
|
useEffect(() => {
|
|
1322
1379
|
void refreshVideoStorageStatus();
|
|
1323
1380
|
}, [refreshVideoStorageStatus]);
|
|
1324
1381
|
|
|
1382
|
+
// There is deliberately no mount-time warmup probe here any more. One used to
|
|
1383
|
+
// run in parallel with checkAuth to overlap the round-trips, but a probe
|
|
1384
|
+
// started before the session settles cannot say which account its answer
|
|
1385
|
+
// belongs to, and applying it anyway is exactly how one account inherited
|
|
1386
|
+
// another's "configured". A returning user is covered by the account-scoped
|
|
1387
|
+
// cache seed above at no round-trip cost; a first launch pays one probe.
|
|
1388
|
+
|
|
1325
1389
|
useEffect(() => {
|
|
1326
1390
|
if (
|
|
1327
1391
|
authStatus !== "authed" ||
|
|
@@ -2604,6 +2668,13 @@ export function App({
|
|
|
2604
2668
|
};
|
|
2605
2669
|
}, []);
|
|
2606
2670
|
|
|
2671
|
+
// Warm the multi-second SCShareableContent lookup while the popover is
|
|
2672
|
+
// open so a recording start within the cache TTL skips it. Fire-and-forget.
|
|
2673
|
+
useEffect(() => {
|
|
2674
|
+
if (!popoverVisible) return;
|
|
2675
|
+
invoke("native_fullscreen_prefetch_capture_content").catch(() => {});
|
|
2676
|
+
}, [popoverVisible]);
|
|
2677
|
+
|
|
2607
2678
|
const speechPermissionChecked = useRef(false);
|
|
2608
2679
|
useEffect(() => {
|
|
2609
2680
|
if (!popoverVisible || !micOn || speechPermissionChecked.current) return;
|
|
@@ -2676,6 +2747,12 @@ export function App({
|
|
|
2676
2747
|
// failed). Stop and cancel are terminal transitions on the recorder a
|
|
2677
2748
|
// restart is already tearing down, so they must not run against it.
|
|
2678
2749
|
const restartInFlightRef = useRef(false);
|
|
2750
|
+
// The take the recorder last announced, tracked from the same
|
|
2751
|
+
// `clips:recorder-session` event the pill uses for its identity. A stop that
|
|
2752
|
+
// throws has no result to name, so this is the only way its failure event can
|
|
2753
|
+
// say which take it belongs to instead of being applied to whichever card
|
|
2754
|
+
// happens to be open.
|
|
2755
|
+
const sessionRecordingIdRef = useRef<string | null>(null);
|
|
2679
2756
|
const recordingInFlight = isRecording || recordingFlowActive;
|
|
2680
2757
|
useLayoutEffect(() => {
|
|
2681
2758
|
recordingFlowGateRef.current = recordingInFlight;
|
|
@@ -3497,6 +3574,8 @@ export function App({
|
|
|
3497
3574
|
preAcquiredCameraStream,
|
|
3498
3575
|
preAcquiredDisplayStream: options?.resumeCapture?.displayStream ?? null,
|
|
3499
3576
|
preAcquiredAudioStream: options?.resumeCapture?.audioStream ?? null,
|
|
3577
|
+
pendingTranscriptionTeardown:
|
|
3578
|
+
options?.resumeCapture?.transcriptionTornDown ?? null,
|
|
3500
3579
|
signal: startController.signal,
|
|
3501
3580
|
});
|
|
3502
3581
|
// macOS: give WebKit a short window to dispatch getDisplayMedia before
|
|
@@ -3771,6 +3850,14 @@ export function App({
|
|
|
3771
3850
|
// ignore — best-effort
|
|
3772
3851
|
});
|
|
3773
3852
|
};
|
|
3853
|
+
track(
|
|
3854
|
+
listen<{ recordingId?: string | null }>(
|
|
3855
|
+
"clips:recorder-session",
|
|
3856
|
+
(event) => {
|
|
3857
|
+
sessionRecordingIdRef.current = event.payload?.recordingId ?? null;
|
|
3858
|
+
},
|
|
3859
|
+
),
|
|
3860
|
+
);
|
|
3774
3861
|
track(
|
|
3775
3862
|
listen("clips:recorder-stop", async () => {
|
|
3776
3863
|
if (restartInFlightRef.current) return;
|
|
@@ -3797,6 +3884,9 @@ export function App({
|
|
|
3797
3884
|
|
|
3798
3885
|
let stopFailed = false;
|
|
3799
3886
|
let stopResult: RecorderStopResult | null = null;
|
|
3887
|
+
// Captured before the await: by the time a slow stop throws, a
|
|
3888
|
+
// replacement take may already have announced itself.
|
|
3889
|
+
const stoppingRecordingId = sessionRecordingIdRef.current;
|
|
3800
3890
|
try {
|
|
3801
3891
|
stopResult = await handle.stop();
|
|
3802
3892
|
if (stopResult.localOnly) {
|
|
@@ -3804,6 +3894,15 @@ export function App({
|
|
|
3804
3894
|
folderPath: stopResult.localFolder,
|
|
3805
3895
|
files: stopResult.localFiles ?? [],
|
|
3806
3896
|
});
|
|
3897
|
+
// A local-only stop has no upload, so nothing else ever publishes
|
|
3898
|
+
// its outcome. Without this the pill's completion card would hold
|
|
3899
|
+
// "finishing up" until its stall timeout — and it must not claim
|
|
3900
|
+
// the file was saved before the export actually returned.
|
|
3901
|
+
emit("clips:native-upload-finished", {
|
|
3902
|
+
recordingId: stopResult.recordingId,
|
|
3903
|
+
ok: true,
|
|
3904
|
+
localFilePath: stopResult.localFiles?.[0]?.path ?? null,
|
|
3905
|
+
}).catch(() => {});
|
|
3807
3906
|
} else {
|
|
3808
3907
|
setLastRecordingId(stopResult.recordingId);
|
|
3809
3908
|
// The browser opens `/r/<id>` (the author's dashboard); what lands
|
|
@@ -3814,6 +3913,18 @@ export function App({
|
|
|
3814
3913
|
} catch (err) {
|
|
3815
3914
|
stopFailed = true;
|
|
3816
3915
|
setRecError(err instanceof Error ? err.message : String(err));
|
|
3916
|
+
// Only when the stop itself threw. Past that point the upload
|
|
3917
|
+
// pipeline owns the completion event and a copy-link failure is not
|
|
3918
|
+
// an upload failure — but a stop that never produced a result is
|
|
3919
|
+
// not a completion, and a local-only take has no other publisher to
|
|
3920
|
+
// correct the card.
|
|
3921
|
+
if (!stopResult) {
|
|
3922
|
+
emit("clips:native-upload-finished", {
|
|
3923
|
+
recordingId: stoppingRecordingId ?? undefined,
|
|
3924
|
+
ok: false,
|
|
3925
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3926
|
+
}).catch(() => {});
|
|
3927
|
+
}
|
|
3817
3928
|
await loadPendingUploads();
|
|
3818
3929
|
} finally {
|
|
3819
3930
|
recordingStopFinalizingRef.current = false;
|
|
@@ -3836,8 +3947,19 @@ export function App({
|
|
|
3836
3947
|
track(
|
|
3837
3948
|
listen("clips:recorder-cancel", async () => {
|
|
3838
3949
|
if (restartInFlightRef.current) return;
|
|
3950
|
+
const cancelDone = recorder.cancel();
|
|
3951
|
+
// Optimistic feedback: bring the popover back and clear the tray's
|
|
3952
|
+
// recording state the moment the cancel is dispatched — the recorder
|
|
3953
|
+
// teardown can take seconds and neither call depends on it. The flow
|
|
3954
|
+
// gate below must NOT be released here: it stays latched until
|
|
3955
|
+
// cancel() resolves so a fast Start can't race the tearing-down
|
|
3956
|
+
// session.
|
|
3957
|
+
if (!cancelled) {
|
|
3958
|
+
invoke("set_recording_state", { active: false }).catch(() => {});
|
|
3959
|
+
invoke("show_popover").catch(() => {});
|
|
3960
|
+
}
|
|
3839
3961
|
try {
|
|
3840
|
-
await
|
|
3962
|
+
await cancelDone;
|
|
3841
3963
|
} finally {
|
|
3842
3964
|
if (!cancelled) {
|
|
3843
3965
|
(
|
|
@@ -3849,8 +3971,6 @@ export function App({
|
|
|
3849
3971
|
setRecorder(null);
|
|
3850
3972
|
setRecordingFlowActive(false);
|
|
3851
3973
|
setBubbleSessionEpoch((epoch) => epoch + 1);
|
|
3852
|
-
invoke("set_recording_state", { active: false }).catch(() => {});
|
|
3853
|
-
invoke("show_popover").catch(() => {});
|
|
3854
3974
|
}
|
|
3855
3975
|
}
|
|
3856
3976
|
}),
|
|
@@ -8101,7 +8221,7 @@ function Setup({
|
|
|
8101
8221
|
/* A fixed height, not content-driven: the tray window resizes itself to
|
|
8102
8222
|
match rendered content, so a taller tab would otherwise grow the window
|
|
8103
8223
|
out from under the user. Tabs scroll inside this frame instead. */
|
|
8104
|
-
className="flex h-[560px] max-h-
|
|
8224
|
+
className="flex h-[560px] max-h-screen w-full flex-col overflow-hidden rounded-[14px] bg-background text-foreground"
|
|
8105
8225
|
>
|
|
8106
8226
|
<div className="grid min-h-0 flex-1 grid-cols-[176px_minmax(0,1fr)]">
|
|
8107
8227
|
<nav
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the recording pill's completion card reads a `clips:native-upload-finished`
|
|
3
|
+
* payload.
|
|
4
|
+
*
|
|
5
|
+
* The card is fed from three places — the recorder's event, a localStorage
|
|
6
|
+
* hand-off for a pill that was still mounting, and a Rust-side replay of the
|
|
7
|
+
* last result — and the pill's window is reused across a restart, so a payload
|
|
8
|
+
* can arrive for a recording the window has already moved past.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type PillDoneStage = "finishing" | "uploading" | "uploaded" | "failed";
|
|
12
|
+
|
|
13
|
+
export type NativeUploadFinished = {
|
|
14
|
+
recordingId?: string;
|
|
15
|
+
ok?: boolean;
|
|
16
|
+
viewUrl?: string;
|
|
17
|
+
error?: string | null;
|
|
18
|
+
localFilePath?: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PillCompletion = {
|
|
22
|
+
stage: "uploaded" | "failed";
|
|
23
|
+
savedLocally: boolean;
|
|
24
|
+
viewUrl: string | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The completion state the card should show, or `null` when the payload
|
|
29
|
+
* belongs to a different recording than the one on screen and must be ignored.
|
|
30
|
+
*
|
|
31
|
+
* Identity is checked through `isCompletionForSession`: a late completion from
|
|
32
|
+
* an earlier take would otherwise repaint the current card with the old clip's
|
|
33
|
+
* link and status.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Whether an upload event belongs to the take the card is showing.
|
|
37
|
+
*
|
|
38
|
+
* Shared by the completion and progress paths so one rule governs both. When
|
|
39
|
+
* both sides name an id and they differ, the event is a leftover from an
|
|
40
|
+
* earlier take — this window is reused, so that happens. When either side has
|
|
41
|
+
* no id there is nothing to compare and the event is taken, which is the
|
|
42
|
+
* contract the pill has always had.
|
|
43
|
+
*/
|
|
44
|
+
export function isCompletionForSession(
|
|
45
|
+
sessionRecordingId: string | null | undefined,
|
|
46
|
+
payload: { recordingId?: string },
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!sessionRecordingId || !payload.recordingId) return true;
|
|
49
|
+
return payload.recordingId === sessionRecordingId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveCompletion(
|
|
53
|
+
sessionRecordingId: string | null | undefined,
|
|
54
|
+
payload: NativeUploadFinished,
|
|
55
|
+
): PillCompletion | null {
|
|
56
|
+
if (!isCompletionForSession(sessionRecordingId, payload)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
// A local-only stop succeeds with no view URL — the file on disk is the
|
|
61
|
+
// whole result — so success is `ok`, never the presence of a link.
|
|
62
|
+
stage: payload.ok ? "uploaded" : "failed",
|
|
63
|
+
savedLocally: Boolean(payload.localFilePath),
|
|
64
|
+
viewUrl: payload.viewUrl ?? null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PillCardTone = "pending" | "ok" | "warn";
|
|
69
|
+
|
|
70
|
+
export type PillCardState = {
|
|
71
|
+
title: string;
|
|
72
|
+
/** Sub-line detail, appended after the take's duration. Empty when the
|
|
73
|
+
* title already says everything there is to say. */
|
|
74
|
+
detail: string;
|
|
75
|
+
tone: PillCardTone;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The completion card's headline and tone.
|
|
80
|
+
*
|
|
81
|
+
* The card goes up the instant Stop is pressed, before the export or upload
|
|
82
|
+
* has returned anything, so the headline tracks `stage` rather than asserting
|
|
83
|
+
* success. A stop that fails must not leave a green check and "Recording
|
|
84
|
+
* saved" on screen with only a small caption disagreeing — the headline is
|
|
85
|
+
* what a glance and a screen reader both take away.
|
|
86
|
+
*/
|
|
87
|
+
export function completionCardState(
|
|
88
|
+
stage: PillDoneStage,
|
|
89
|
+
session: { hasLink: boolean; savedLocally: boolean },
|
|
90
|
+
): PillCardState {
|
|
91
|
+
switch (stage) {
|
|
92
|
+
case "uploaded":
|
|
93
|
+
return {
|
|
94
|
+
title: "Recording saved",
|
|
95
|
+
// With a link the row below already shows it; without one the file on
|
|
96
|
+
// disk is the only place the take exists, so say where it went.
|
|
97
|
+
detail: session.hasLink ? "" : "saved on this device",
|
|
98
|
+
tone: "ok",
|
|
99
|
+
};
|
|
100
|
+
case "failed":
|
|
101
|
+
return {
|
|
102
|
+
title: "Upload paused",
|
|
103
|
+
detail: session.savedLocally ? "saved on this device" : "",
|
|
104
|
+
tone: "warn",
|
|
105
|
+
};
|
|
106
|
+
case "uploading":
|
|
107
|
+
return { title: "Uploading", detail: "", tone: "pending" };
|
|
108
|
+
case "finishing":
|
|
109
|
+
return { title: "Finishing up", detail: "", tone: "pending" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the recording pill does when the recorder flips `clips:toolbar-enabled`.
|
|
3
|
+
*
|
|
4
|
+
* The pill's window outlives a single take: a restart reuses it for the
|
|
5
|
+
* replacement session, and the completion card keeps it open after a stop. So
|
|
6
|
+
* "enabled" is not simply "show the controls" — it also decides whether the
|
|
7
|
+
* previous session's card is retired.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type PillMode = "recording" | "confirm" | "done";
|
|
11
|
+
|
|
12
|
+
export type ToolbarEnabledEffect =
|
|
13
|
+
/** A live session is taking a window still showing the last take's card. */
|
|
14
|
+
| "adopt-new-session"
|
|
15
|
+
/** No session owns the pill: snap the segments back to their rest state. */
|
|
16
|
+
| "reset-to-rest"
|
|
17
|
+
/** Leave the pill exactly as it is. */
|
|
18
|
+
| "keep";
|
|
19
|
+
|
|
20
|
+
export function toolbarEnabledEffect(
|
|
21
|
+
enabled: boolean,
|
|
22
|
+
mode: PillMode,
|
|
23
|
+
): ToolbarEnabledEffect {
|
|
24
|
+
if (enabled) {
|
|
25
|
+
// A confirm strip is deliberately NOT rescued here. The recorder re-emits
|
|
26
|
+
// `toolbar-enabled(true)` on every `toolbar-ready` handshake, so resetting
|
|
27
|
+
// on enable would close a question the user is still reading. Answering a
|
|
28
|
+
// confirm therefore has to leave confirm mode at the point of the answer.
|
|
29
|
+
return mode === "done" ? "adopt-new-session" : "keep";
|
|
30
|
+
}
|
|
31
|
+
// The completion card owns the window after a stop; disabling the controls
|
|
32
|
+
// must not wipe it out from under the user.
|
|
33
|
+
return mode === "done" ? "keep" : "reset-to-rest";
|
|
34
|
+
}
|