@agent-native/core 0.175.2-nightly-20260826150235 → 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.
Files changed (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
  3. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
  4. package/corpus/templates/clips/desktop/src/app.tsx +171 -51
  5. package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
  6. package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
  8. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
  9. package/corpus/templates/clips/desktop/src/main.tsx +4 -4
  10. package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
  11. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
  12. package/corpus/templates/clips/desktop/src/styles.css +20 -308
  13. package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
  15. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
  16. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
  17. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
  18. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
  19. package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
  20. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
  21. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
  22. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
  23. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
  24. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
  25. package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
  26. package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
  28. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
  29. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
  30. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
  31. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
  32. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
  33. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
  34. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
  35. package/corpus/templates/design/shared/canvas-math.ts +11 -1
  36. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
  37. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +88 -23
  38. package/corpus/templates/slides/app/context/DeckContext.tsx +155 -86
  39. package/corpus/templates/slides/app/pages/DeckEditor.tsx +7 -5
  40. package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
  41. package/dist/client/hooks/index.d.ts +1 -0
  42. package/dist/client/hooks/index.js +1 -0
  43. package/dist/client/use-pinch-zoom.js +17 -4
  44. package/dist/client/zoom-gesture.d.ts +74 -0
  45. package/dist/client/zoom-gesture.js +100 -0
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/observability/routes.d.ts +3 -3
  49. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  50. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-browser.d.ts +13 -13
  53. package/dist/server/builder-browser.js +46 -15
  54. package/dist/server/builder-oauth.d.ts +12 -0
  55. package/dist/server/builder-oauth.js +13 -2
  56. package/dist/server/core-routes-plugin.js +54 -19
  57. package/dist/server/realtime-token.d.ts +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/client.js +2 -0
  60. package/docs/content/template-chat-developers.mdx +100 -13
  61. package/docs/content/template-chat-first-edits.mdx +5 -5
  62. package/docs/content/template-chat.mdx +84 -36
  63. package/package.json +4 -3
  64. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
@@ -0,0 +1,1320 @@
1
+ import {
2
+ IconAlertTriangle,
3
+ IconCheck,
4
+ IconCopy,
5
+ IconLink,
6
+ IconLoader2,
7
+ IconPlayerPauseFilled,
8
+ IconPlayerPlayFilled,
9
+ IconRefresh,
10
+ IconTrash,
11
+ IconX,
12
+ } from "@tabler/icons-react";
13
+ import { invoke } from "@tauri-apps/api/core";
14
+ import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
15
+ import { emit, listen } from "@tauri-apps/api/event";
16
+ import { currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
17
+ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
18
+ import { open as openExternal } from "@tauri-apps/plugin-shell";
19
+ import { useEffect, useRef, useState } from "react";
20
+ import type { FocusEvent } from "react";
21
+ import { flushSync } from "react-dom";
22
+
23
+ import {
24
+ completionCardState,
25
+ isCompletionForSession,
26
+ resolveCompletion,
27
+ } from "../lib/pill-completion";
28
+ import type {
29
+ NativeUploadFinished,
30
+ PillDoneStage as DoneStage,
31
+ } from "../lib/pill-completion";
32
+ import { toolbarEnabledEffect } from "../lib/pill-session";
33
+ import type { PillMode } from "../lib/pill-session";
34
+
35
+ const SEG_MS = 180;
36
+ const HOVER_INTENT_MS = 150;
37
+ // Within this distance of the right screen edge the pill anchors its RIGHT
38
+ // edge and grows left instead, so growth never runs off-screen.
39
+ const RIGHT_EDGE_ANCHOR_PX = 200;
40
+ const FINALIZING_RESULT_STORAGE_KEY = "clips-finalizing-result";
41
+
42
+ type Seg = "mid" | "q" | "del" | "res" | "extras";
43
+
44
+ type RecorderSession = {
45
+ viewUrl?: string | null;
46
+ recordingId?: string | null;
47
+ localOnly?: boolean;
48
+ };
49
+
50
+ const hasTauri = "__TAURI_INTERNALS__" in window;
51
+ const demoMode = import.meta.env.DEV && !hasTauri;
52
+
53
+ function safeEmit(event: string, payload?: unknown): Promise<void> {
54
+ if (!hasTauri) return Promise.resolve();
55
+ return emit(event, payload);
56
+ }
57
+
58
+ function safeInvoke<T = unknown>(
59
+ cmd: string,
60
+ args?: Record<string, unknown>,
61
+ ): Promise<T | undefined> {
62
+ if (!hasTauri) return Promise.resolve(undefined);
63
+ return invoke<T>(cmd, args).then(
64
+ (v) => v,
65
+ (err) => {
66
+ console.warn(`[record-pill] invoke ${cmd} failed:`, err);
67
+ return undefined;
68
+ },
69
+ );
70
+ }
71
+
72
+ function safeListen<T>(
73
+ event: string,
74
+ cb: (payload: T) => void,
75
+ ): Promise<() => void> {
76
+ if (!hasTauri) return Promise.resolve(() => {});
77
+ return listen<T>(event, (ev) => cb(ev.payload));
78
+ }
79
+
80
+ function formatTimer(ms: number): string {
81
+ const total = Math.max(0, Math.floor(ms / 1000));
82
+ const totalMin = Math.floor(total / 60);
83
+ // Past 99:59 the m:ss form overflows its slot; switch to a compact h:mm so
84
+ // the anchored controls never shift for a marathon take.
85
+ if (totalMin >= 100) {
86
+ const h = Math.floor(totalMin / 60);
87
+ const m = totalMin % 60;
88
+ return `${h}:${m.toString().padStart(2, "0")}h`;
89
+ }
90
+ const s = total % 60;
91
+ return `${totalMin}:${s.toString().padStart(2, "0")}`;
92
+ }
93
+
94
+ function formatDurationCopy(ms: number): string {
95
+ const total = Math.max(0, Math.round(ms / 1000));
96
+ if (total < 60) return `${total} sec`;
97
+ return `${Math.round(total / 60)} min`;
98
+ }
99
+
100
+ /**
101
+ * The recording pill — one dark capsule with four modes: recording, paused,
102
+ * confirm, done. The leading stop square is the recording indicator and the
103
+ * stop/save action — red while live, grey-white while paused — with the
104
+ * timer sharing its color, plain against the chrome. Every control is a bare
105
+ * glyph; no rings or fills. Left edge anchored: hover extras
106
+ * and the inline confirms grow rightward while the stop circle, timer, and
107
+ * pause button hold position (near the right screen edge the anchor
108
+ * mirrors). Stop swaps the
109
+ * pill for the completion card in place; the link is copied only when the
110
+ * user clicks Copy (an automatic copy would clear their clipboard
111
+ * unannounced). While paused the pause circle swaps to a play glyph — the
112
+ * amber dot carries the paused state; the button carries the way back.
113
+ * Pure command emitter — the recorder in the popover window owns
114
+ * capture, and drives us through the same IPC contract the old toolbar used:
115
+ *
116
+ * receives → `clips:recorder-state` { paused, elapsedMs },
117
+ * `clips:toolbar-enabled`, `clips:toolbar-preparing`,
118
+ * `clips:recorder-session` { viewUrl, recordingId, localOnly },
119
+ * `clips:native-upload-progress` / `-finished`,
120
+ * `voice:audio-level` { level, source }
121
+ * emits → `clips:recorder-stop`, `:pause`, `:resume`, `:restart`,
122
+ * `:cancel`, `clips:toolbar-ready`
123
+ *
124
+ * Stop must NOT close this window: it invokes `set_toolbar_finishing(true)`
125
+ * BEFORE emitting stop so every teardown path skips the toolbar label, then
126
+ * renders the completion card here until the user dismisses it.
127
+ */
128
+ export function RecordingPill() {
129
+ const [mode, setMode] = useState<PillMode>("recording");
130
+ const [paused, setPaused] = useState(false);
131
+ const [elapsed, setElapsed] = useState(0);
132
+ const [enabled, setEnabled] = useState(demoMode);
133
+ const [barHeights, setBarHeights] = useState<number[]>([4, 4, 4, 4, 4]);
134
+ const [announcement, setAnnouncement] = useState("");
135
+ const [confirmQuestion, setConfirmQuestion] = useState("");
136
+ // What the confirm's action button does. Both intents share the confirm
137
+ // strip, but only delete is destructive-red; restart is a start-fresh
138
+ // action and wears the pill's white affirmative treatment, same family
139
+ // as Stop.
140
+ const [confirmIntent, setConfirmIntent] = useState<"delete" | "restart">(
141
+ "delete",
142
+ );
143
+ // Whether the delete confirm was entered from an already-paused recording:
144
+ // Esc then backs out to that pause, while the Resume button always resumes.
145
+ const confirmFromPausedRef = useRef(false);
146
+ const [doneStage, setDoneStage] = useState<DoneStage>("finishing");
147
+ const [doneDurationMs, setDoneDurationMs] = useState(0);
148
+ const [copied, setCopied] = useState(false);
149
+ const [savedLocally, setSavedLocally] = useState(false);
150
+ const [pendingAction, setPendingAction] = useState<
151
+ "restart" | "cancel" | null
152
+ >(null);
153
+
154
+ const modeRef = useRef<PillMode>("recording");
155
+ const elapsedRef = useRef(0);
156
+ const sessionRef = useRef<RecorderSession>({});
157
+ const [viewUrl, setViewUrl] = useState<string | null>(null);
158
+ const viewUrlRef = useRef<string | null>(null);
159
+ const reducedRef = useRef(
160
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches,
161
+ );
162
+ const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
163
+ const revealedRef = useRef(false);
164
+ const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
165
+ const shrinkTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
166
+ const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
167
+ const levelDecayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
168
+ const animatingUntilRef = useRef(0);
169
+ const pauseTransitionRef = useRef<"pause" | "resume" | null>(null);
170
+ const pauseTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
171
+ null,
172
+ );
173
+
174
+ const settleBatchRef = useRef(0);
175
+ const pendingSegsRef = useRef<Set<Seg>>(new Set());
176
+ const finishSettleRef = useRef<(() => void) | null>(null);
177
+ const pillRef = useRef<HTMLDivElement | null>(null);
178
+ const cardRef = useRef<HTMLDivElement | null>(null);
179
+ const segRefs = useRef<Record<Seg, HTMLSpanElement | null>>({
180
+ mid: null,
181
+ q: null,
182
+ del: null,
183
+ res: null,
184
+ extras: null,
185
+ });
186
+
187
+ modeRef.current = mode;
188
+ elapsedRef.current = elapsed;
189
+ viewUrlRef.current = viewUrl;
190
+
191
+ // Auto-gain state: raw mic peaks rarely pass ~0.3 even when shouting, so a
192
+ // fixed scale leaves the meter nearly flat. Levels are normalized against a
193
+ // slowly-decaying rolling peak, like a real meter's AGC.
194
+ const meterGainRef = useRef(0.04);
195
+ const meterHistoryRef = useRef<number[]>([0, 0, 0, 0, 0]);
196
+
197
+ /**
198
+ * Each level event pushes one normalized sample through a five-slot
199
+ * history, so the lines are the actual signal scrolling by — a waveform,
200
+ * not a glyph — and shouting pins them to the top of the range.
201
+ */
202
+ function applyMicLevel(raw: number) {
203
+ const level = Math.max(0, Math.min(1, raw));
204
+ meterGainRef.current = Math.max(level, meterGainRef.current * 0.985, 0.04);
205
+ const norm = Math.min(1, level / meterGainRef.current) ** 0.7;
206
+ const history = meterHistoryRef.current;
207
+ history.shift();
208
+ history.push(norm);
209
+ setBarHeights(history.map((v) => 3 + Math.round(v * 11)));
210
+ if (levelDecayRef.current) clearTimeout(levelDecayRef.current);
211
+ levelDecayRef.current = setTimeout(() => {
212
+ meterHistoryRef.current = [0, 0, 0, 0, 0];
213
+ setBarHeights([4, 4, 4, 4, 4]);
214
+ }, 350);
215
+ }
216
+
217
+ function clearPauseTransition() {
218
+ pauseTransitionRef.current = null;
219
+ if (pauseTransitionTimerRef.current) {
220
+ clearTimeout(pauseTransitionTimerRef.current);
221
+ pauseTransitionTimerRef.current = null;
222
+ }
223
+ }
224
+
225
+ // ---- segment motion: measure natural width, animate exact pixels ----
226
+
227
+ function segInnerWidth(k: Seg): number {
228
+ const el = segRefs.current[k];
229
+ const inner = el?.firstElementChild as HTMLElement | null;
230
+ if (!inner) return 0;
231
+ return Math.ceil(inner.getBoundingClientRect().width);
232
+ }
233
+
234
+ function segCurrentWidth(k: Seg): number {
235
+ const el = segRefs.current[k];
236
+ if (!el) return 0;
237
+ return el.getBoundingClientRect().width;
238
+ }
239
+
240
+ function setSeg(k: Seg, open: boolean, delayMs = 0) {
241
+ const el = segRefs.current[k];
242
+ if (!el) return;
243
+ // A segment that still sits at `auto` (the Stop capsule before its first
244
+ // collapse) cannot animate FROM auto — snap it to its measured pixels
245
+ // and force a reflow so the width transition has a number to leave from.
246
+ if (!open && (!el.style.width || el.style.width === "auto")) {
247
+ el.style.transition = "none";
248
+ el.style.width = `${segCurrentWidth(k)}px`;
249
+ void el.offsetWidth;
250
+ }
251
+ const reduced = reducedRef.current;
252
+ el.style.transition = reduced
253
+ ? "none"
254
+ : `width ${SEG_MS}ms var(--pill-ease), opacity ${SEG_MS}ms ease`;
255
+ el.style.transitionDelay = reduced ? "0ms" : `${delayMs}ms`;
256
+ el.style.width = open ? `${segInnerWidth(k)}px` : "0px";
257
+ el.style.opacity = open ? "1" : "0";
258
+ }
259
+
260
+ // Native window ops run strictly one at a time. Concurrent
261
+ // setSize/setPosition sequences read stale rects out from under each other
262
+ // and strand the window clipped and offset (a half-cut pill with content
263
+ // painting past the window edge). Every op re-reads geometry at execution
264
+ // time inside the chain.
265
+ const windowOpChainRef = useRef<Promise<void>>(Promise.resolve());
266
+ function queueWindowOp(op: () => Promise<void>) {
267
+ windowOpChainRef.current = windowOpChainRef.current
268
+ .then(op)
269
+ .catch((err) => {
270
+ console.warn("[record-pill] window op failed", err);
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Resize the native window around the content, keeping the pill's anchor
276
+ * edge fixed. The left edge is the anchor unless the pill sits within
277
+ * RIGHT_EDGE_ANCHOR_PX of the screen's right edge — then the right edge
278
+ * holds and growth extends left. Height keeps the bottom edge fixed so the
279
+ * taller completion card rises from where the pill sat.
280
+ */
281
+ function resizeWindowTo(contentW: number, contentH: number) {
282
+ if (!hasTauri) return;
283
+ queueWindowOp(async () => {
284
+ const win = getCurrentWindow();
285
+ const [pos, size, scale, monitor] = await Promise.all([
286
+ win.outerPosition(),
287
+ win.outerSize(),
288
+ win.scaleFactor(),
289
+ currentMonitor(),
290
+ ]);
291
+ const w = Math.ceil(contentW * scale);
292
+ const h = Math.ceil(contentH * scale);
293
+ let x = pos.x;
294
+ if (monitor) {
295
+ const monRight = monitor.position.x + monitor.size.width;
296
+ const nearRightEdge =
297
+ pos.x + size.width >=
298
+ monRight - Math.round(RIGHT_EDGE_ANCHOR_PX * scale);
299
+ if (nearRightEdge) x = pos.x + size.width - w;
300
+ // Never let growth push past the screen edge — macOS shoves the
301
+ // window back and the correction fights the next resize.
302
+ x = Math.min(x, monRight - w);
303
+ x = Math.max(x, monitor.position.x);
304
+ }
305
+ const y = pos.y + size.height - h;
306
+ // Order the ops so the window never transiently overhangs: when the
307
+ // origin moves left/up (right-anchored growth), move first, then
308
+ // grow; otherwise grow first, then move.
309
+ if (x < pos.x || y < pos.y) {
310
+ await win.setPosition(new PhysicalPosition(x, y));
311
+ await win.setSize(new PhysicalSize(w, h));
312
+ } else {
313
+ await win.setSize(new PhysicalSize(w, h));
314
+ await win.setPosition(new PhysicalPosition(x, y));
315
+ }
316
+ });
317
+ }
318
+
319
+ function syncWindowToContent() {
320
+ const el =
321
+ (mode === "done" ? cardRef.current : pillRef.current) ?? pillRef.current;
322
+ if (!el) return;
323
+ // offsetWidth/Height are layout metrics, immune to the card's scale-in
324
+ // entrance — a rect measured mid-animation locks the window too narrow.
325
+ resizeWindowTo(el.offsetWidth, el.offsetHeight);
326
+ }
327
+
328
+ // Which segments are (or are animating toward) open. Live rects mid-flight
329
+ // under-measure a transition target, so the window budget is computed from
330
+ // this intent instead of from the DOM.
331
+ const openSegsRef = useRef<Record<Seg, boolean>>({
332
+ mid: true,
333
+ q: false,
334
+ del: false,
335
+ res: false,
336
+ extras: false,
337
+ });
338
+
339
+ /**
340
+ * Run one choreographed set of segment transitions: pre-grow the window to
341
+ * the post-transition budget (plus slack) so nothing clips, start every
342
+ * segment's width+opacity bar, then shrink to the exact measured rect once
343
+ * the last bar lands.
344
+ */
345
+ function transitionSegs(changes: Array<[Seg, boolean, number]>) {
346
+ const pill = pillRef.current;
347
+ if (!pill) return;
348
+ for (const [k, open] of changes) openSegsRef.current[k] = open;
349
+ const pillW = pill.getBoundingClientRect().width;
350
+ const segsW = (Object.keys(openSegsRef.current) as Seg[]).reduce(
351
+ (sum, k) => sum + segCurrentWidth(k),
352
+ 0,
353
+ );
354
+ const staticW = pillW - segsW;
355
+ const targetW = (Object.keys(openSegsRef.current) as Seg[]).reduce(
356
+ (sum, k) => sum + (openSegsRef.current[k] ? segInnerWidth(k) : 0),
357
+ staticW,
358
+ );
359
+ const maxDelay = changes.reduce((m, [, , d]) => Math.max(m, d), 0);
360
+ const settleMs = reducedRef.current ? 16 : SEG_MS + maxDelay + 40;
361
+ animatingUntilRef.current = Date.now() + settleMs;
362
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
363
+ // Budget the window for whichever is wider, plus slack for measurement
364
+ // rounding — the settle pass snaps to the exact rect.
365
+ resizeWindowTo(
366
+ Math.ceil(Math.max(pillW, targetW)) + 12,
367
+ Math.ceil(pill.getBoundingClientRect().height),
368
+ );
369
+ // The settle is driven by the transitions actually ending, not by a
370
+ // timer — a throttled clock runs them late, and snapping on a fixed
371
+ // schedule cuts the choreography off mid-motion.
372
+ const batch = ++settleBatchRef.current;
373
+ pendingSegsRef.current.clear();
374
+ for (const [k, open, delay] of changes) {
375
+ const before = segCurrentWidth(k);
376
+ setSeg(k, open, delay);
377
+ const el = segRefs.current[k];
378
+ const after = el ? Number.parseFloat(el.style.width) : before;
379
+ if (!reducedRef.current && Math.abs(after - before) > 0.5) {
380
+ pendingSegsRef.current.add(k);
381
+ }
382
+ }
383
+ const finishSettle = () => {
384
+ if (settleBatchRef.current !== batch) return;
385
+ if (shrinkTimerRef.current) clearTimeout(shrinkTimerRef.current);
386
+ pendingSegsRef.current.clear();
387
+ animatingUntilRef.current = Date.now();
388
+ syncWindowToContent();
389
+ };
390
+ finishSettleRef.current = finishSettle;
391
+ if (pendingSegsRef.current.size === 0) {
392
+ finishSettle();
393
+ return;
394
+ }
395
+ // Dead-clock fallback: if the ends never fire, force every segment to
396
+ // its intended state so the pill can never strand half-open. Sized well
397
+ // past any late-running transition so it never clips a live one.
398
+ shrinkTimerRef.current = setTimeout(
399
+ () => {
400
+ if (settleBatchRef.current !== batch) return;
401
+ for (const k of Object.keys(openSegsRef.current) as Seg[]) {
402
+ const el = segRefs.current[k];
403
+ if (!el) continue;
404
+ el.style.transition = "none";
405
+ el.style.width = openSegsRef.current[k]
406
+ ? `${segInnerWidth(k)}px`
407
+ : "0px";
408
+ el.style.opacity = openSegsRef.current[k] ? "1" : "0";
409
+ }
410
+ finishSettle();
411
+ },
412
+ Math.max(1_000, settleMs * 3),
413
+ );
414
+ }
415
+
416
+ /** Snap every segment to its resting state (Stop visible, all confirm and
417
+ * hover segments collapsed) with no animation — used when the recorder
418
+ * disables the pill (restart teardown, session reset). */
419
+ function resetToRest() {
420
+ revealedRef.current = false;
421
+ for (const k of ["q", "del", "res", "extras"] as Seg[]) {
422
+ const el = segRefs.current[k];
423
+ if (!el) continue;
424
+ el.style.transition = "none";
425
+ el.style.width = "0px";
426
+ el.style.opacity = "0";
427
+ openSegsRef.current[k] = false;
428
+ }
429
+ const mid = segRefs.current.mid;
430
+ if (mid) {
431
+ mid.style.transition = "none";
432
+ mid.style.width = "auto";
433
+ mid.style.opacity = "1";
434
+ openSegsRef.current.mid = true;
435
+ }
436
+ setMode("recording");
437
+ clearPauseTransition();
438
+ setPaused(false);
439
+ }
440
+
441
+ // ---- reveal / confirm / done choreography ----
442
+
443
+ function reveal(open: boolean) {
444
+ if (open === revealedRef.current) return;
445
+ if (open && modeRef.current !== "recording") return;
446
+ revealedRef.current = open;
447
+ transitionSegs([["extras", open, 0]]);
448
+ }
449
+
450
+ const pausedRef = useRef(false);
451
+ pausedRef.current = paused;
452
+ const enabledRef = useRef(false);
453
+ enabledRef.current = enabled;
454
+ const elapsedAnchorRef = useRef<{ elapsedMs: number; at: number } | null>(
455
+ null,
456
+ );
457
+
458
+ // The recorder reports state every 500ms; rendering only those ticks makes
459
+ // the clock feel like it starts late and counts in lurches. Interpolate
460
+ // from the last report at 250ms so the timer runs the moment capture is
461
+ // live and re-anchors on every real tick.
462
+ useEffect(() => {
463
+ if (!enabled || paused || mode === "done") return;
464
+ const t = setInterval(() => {
465
+ const anchor = elapsedAnchorRef.current;
466
+ if (!anchor) return;
467
+ setElapsed(anchor.elapsedMs + (performance.now() - anchor.at));
468
+ }, 250);
469
+ return () => clearInterval(t);
470
+ }, [enabled, paused, mode]);
471
+
472
+ function enterConfirm(intent: "delete" | "restart") {
473
+ if (modeRef.current !== "recording") return;
474
+ const wasPaused = pausedRef.current;
475
+ confirmFromPausedRef.current = wasPaused;
476
+ setMode("confirm");
477
+ // The question's segment width is measured synchronously below, so the
478
+ // new text (and the action button's label) must be committed to the DOM
479
+ // before transitionSegs runs — without flushSync it would measure the
480
+ // previous (empty) question.
481
+ flushSync(() => {
482
+ setConfirmIntent(intent);
483
+ setConfirmQuestion(
484
+ intent === "delete"
485
+ ? `Delete ${formatDurationCopy(elapsedRef.current)}?`
486
+ : "Start a new recording?",
487
+ );
488
+ });
489
+ // Pause at the instant of the click — the deliberation must not end up
490
+ // in the clip. A recording already paused by hand stays exactly as the
491
+ // user left it, and exiting the confirm restores that state instead of
492
+ // resuming behind their back.
493
+ if (!wasPaused) applyPauseIntent("pause");
494
+ revealedRef.current = false;
495
+ transitionSegs([
496
+ ["extras", false, 0],
497
+ ["mid", false, 0],
498
+ ["q", true, 0],
499
+ ["del", true, 20],
500
+ ["res", true, 40],
501
+ ]);
502
+ setAnnouncement("Paused");
503
+ }
504
+
505
+ function exitConfirm(resume: boolean) {
506
+ if (modeRef.current !== "confirm") return;
507
+ setMode("recording");
508
+ if (resume && pausedRef.current) applyPauseIntent("resume");
509
+ else if (!resume && !confirmFromPausedRef.current) {
510
+ applyPauseIntent("resume");
511
+ }
512
+ transitionSegs([
513
+ ["res", false, 0],
514
+ ["del", false, 20],
515
+ ["q", false, 40],
516
+ ["mid", true, 40],
517
+ ]);
518
+ setAnnouncement(pausedRef.current ? "Paused" : "Recording");
519
+ }
520
+
521
+ function applyPauseIntent(transition: "pause" | "resume") {
522
+ clearPauseTransition();
523
+ pauseTransitionRef.current = transition;
524
+ setPaused(transition === "pause");
525
+ pauseTransitionTimerRef.current = setTimeout(clearPauseTransition, 3_000);
526
+ void safeEmit(`clips:recorder-${transition}`).catch(() => {
527
+ clearPauseTransition();
528
+ });
529
+ }
530
+
531
+ function togglePause() {
532
+ if (!enabled || modeRef.current !== "recording") return;
533
+ const transition = pausedRef.current ? "resume" : "pause";
534
+ applyPauseIntent(transition);
535
+ setAnnouncement(transition === "pause" ? "Paused" : "Recording");
536
+ }
537
+
538
+ // Copying is always the user's click — an automatic copy would clear their
539
+ // clipboard without them knowing.
540
+ const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
541
+ async function copyLink(url: string) {
542
+ try {
543
+ if (hasTauri) await writeText(url);
544
+ else await navigator.clipboard.writeText(url);
545
+ setCopied(true);
546
+ setAnnouncement("Link copied");
547
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
548
+ copiedTimerRef.current = setTimeout(() => setCopied(false), 1_600);
549
+ } catch (err) {
550
+ console.error("[record-pill] clipboard write failed:", err);
551
+ }
552
+ }
553
+
554
+ function stop() {
555
+ // Guarded through the ref: the tray-stop listener holds a first-render
556
+ // closure of this function, where the `enabled` state is still false.
557
+ if (!enabledRef.current || modeRef.current === "done") return;
558
+ setDoneDurationMs(elapsedRef.current);
559
+ // Every stop — hosted or local-only — starts as "finishing" and is only
560
+ // called done by the completion event the stop actually produces. A
561
+ // local-only export that fails must not have already claimed it saved.
562
+ setDoneStage("finishing");
563
+ // Anchor the card on THIS take. The window is reused across restarts, so
564
+ // anything a previous session's late completion left behind has to go.
565
+ setViewUrl(sessionRef.current.viewUrl ?? null);
566
+ setSavedLocally(false);
567
+ setCopied(false);
568
+ // Hold the window open BEFORE the stop event so the recorder's teardown
569
+ // can't close us out from under the card.
570
+ void safeInvoke("set_toolbar_finishing", { hold: true }).then(() => {
571
+ void safeEmit("clips:recorder-stop");
572
+ });
573
+ // Pre-grow the window for the card so its entrance never renders clipped;
574
+ // the done-mode effect refits to the exact card rect one frame later.
575
+ resizeWindowTo(340, 180);
576
+ setMode("done");
577
+ if (demoMode) {
578
+ setTimeout(() => {
579
+ handleUploadFinished({
580
+ ok: true,
581
+ viewUrl: "https://clips.agent-native.com/r/demo",
582
+ });
583
+ }, 2_000);
584
+ }
585
+ }
586
+
587
+ function scheduleCloseFallback(action: string) {
588
+ fallbackTimerRef.current = setTimeout(() => {
589
+ console.warn(
590
+ `[record-pill] recorder did not close the pill within 3s after ${action} — self-closing`,
591
+ );
592
+ if (hasTauri)
593
+ getCurrentWindow()
594
+ .close()
595
+ .catch(() => {});
596
+ }, 3_000);
597
+ }
598
+
599
+ function confirmDestructive() {
600
+ if (pendingAction) return;
601
+ // The confirm strip is a question about the session that is ending here.
602
+ // Leave it now, before either answer is dispatched: the restart path
603
+ // reuses this same window for the replacement take, and a pill still in
604
+ // `confirm` would come back with the old question up and Stop/Pause
605
+ // disabled. `pendingAction` (cleared by the next `toolbar-enabled`) keeps
606
+ // the restart/delete glyphs disabled meanwhile.
607
+ resetToRest();
608
+ if (confirmIntent === "restart") {
609
+ setPendingAction("restart");
610
+ setElapsed(0);
611
+ // Hide immediately — the restart teardown and fresh countdown follow,
612
+ // and recording controls must not sit on screen while no capture is
613
+ // live. The replacement session's `clips:toolbar-enabled` re-shows the
614
+ // pill at 0:00, reusing this window thanks to the finishing hold.
615
+ setEnabled(false);
616
+ void safeInvoke("set_toolbar_finishing", { hold: true }).then(() => {
617
+ void safeEmit("clips:recorder-restart");
618
+ });
619
+ fallbackTimerRef.current = setTimeout(() => {
620
+ console.warn(
621
+ "[record-pill] recorder did not restart within 15s — self-closing",
622
+ );
623
+ void safeInvoke("set_toolbar_finishing", { hold: false });
624
+ if (hasTauri)
625
+ getCurrentWindow()
626
+ .close()
627
+ .catch(() => {});
628
+ }, 15_000);
629
+ return;
630
+ }
631
+ setPendingAction("cancel");
632
+ // Vanish now — feedback must not wait on the recorder's teardown. The
633
+ // window close (or its 3s fallback) follows behind.
634
+ setEnabled(false);
635
+ void safeEmit("clips:recorder-cancel").then(() =>
636
+ scheduleCloseFallback("cancel"),
637
+ );
638
+ }
639
+
640
+ function dismissCard() {
641
+ void safeInvoke("set_toolbar_finishing", { hold: false }).then(() => {
642
+ if (hasTauri)
643
+ getCurrentWindow()
644
+ .close()
645
+ .catch(() => {});
646
+ });
647
+ }
648
+
649
+ function handleUploadFinished(payload: NativeUploadFinished) {
650
+ // A completion only means something to a card that is on screen. While
651
+ // the pill is recording there is no card, and anything applied here would
652
+ // sit in state waiting to surface on the NEXT take's card — which is how
653
+ // a discarded take's URL reached its replacement. Nothing is lost by
654
+ // dropping these: the done-mode effect drains both the stored result and
655
+ // the localStorage hand-off when a card does open.
656
+ if (modeRef.current !== "done") return;
657
+ const completion = resolveCompletion(
658
+ sessionRef.current.recordingId,
659
+ payload,
660
+ );
661
+ if (!completion) return;
662
+ if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
663
+ setSavedLocally(completion.savedLocally);
664
+ // The session already published this clip's link so Stop could offer Copy
665
+ // immediately; a payload without one must not take it away.
666
+ if (completion.viewUrl) setViewUrl(completion.viewUrl);
667
+ setDoneStage(completion.stage);
668
+ }
669
+
670
+ // ---- listeners ----
671
+
672
+ useEffect(() => {
673
+ const unlistens: Array<() => void> = [];
674
+ const registrations: Array<Promise<() => void>> = [];
675
+ let stopped = false;
676
+ const track = (p: Promise<() => void>) => {
677
+ registrations.push(
678
+ p.then((u) => {
679
+ if (stopped) u();
680
+ else unlistens.push(u);
681
+ return u;
682
+ }),
683
+ );
684
+ };
685
+ track(
686
+ safeListen<{ paused: boolean; elapsedMs: number }>(
687
+ "clips:recorder-state",
688
+ (payload) => {
689
+ const nextPaused = !!payload.paused;
690
+ const pending = pauseTransitionRef.current;
691
+ const reached =
692
+ (pending === "pause" && nextPaused) ||
693
+ (pending === "resume" && !nextPaused);
694
+ if (!pending || reached) setPaused(nextPaused);
695
+ if (reached) clearPauseTransition();
696
+ elapsedAnchorRef.current = {
697
+ elapsedMs: payload.elapsedMs ?? 0,
698
+ at: performance.now(),
699
+ };
700
+ setElapsed(payload.elapsedMs ?? 0);
701
+ },
702
+ ),
703
+ );
704
+ track(
705
+ safeListen<boolean>("clips:toolbar-enabled", (payload) => {
706
+ setEnabled(!!payload);
707
+ setPendingAction(null);
708
+ if (fallbackTimerRef.current) {
709
+ clearTimeout(fallbackTimerRef.current);
710
+ fallbackTimerRef.current = null;
711
+ }
712
+ if (payload && !elapsedAnchorRef.current) {
713
+ elapsedAnchorRef.current = { elapsedMs: 0, at: performance.now() };
714
+ }
715
+ // A live session owns the pill now: release any restart hold.
716
+ if (payload) void safeInvoke("set_toolbar_finishing", { hold: false });
717
+ switch (toolbarEnabledEffect(!!payload, modeRef.current)) {
718
+ case "adopt-new-session":
719
+ setViewUrl(null);
720
+ setCopied(false);
721
+ setSavedLocally(false);
722
+ setDoneStage("finishing");
723
+ sessionRef.current = {};
724
+ resetToRest();
725
+ break;
726
+ case "reset-to-rest":
727
+ setElapsed(0);
728
+ elapsedAnchorRef.current = null;
729
+ resetToRest();
730
+ break;
731
+ case "keep":
732
+ break;
733
+ }
734
+ }),
735
+ );
736
+ track(
737
+ safeListen("clips:toolbar-sync", () => {
738
+ void safeEmit("clips:toolbar-ready", {});
739
+ }),
740
+ );
741
+ track(
742
+ safeListen<RecorderSession>("clips:recorder-session", (payload) => {
743
+ sessionRef.current = payload ?? {};
744
+ if (payload?.viewUrl) setViewUrl(payload.viewUrl);
745
+ }),
746
+ );
747
+ track(
748
+ safeListen<{
749
+ recordingId?: string;
750
+ stage?: string;
751
+ progress?: number | null;
752
+ }>("clips:native-upload-progress", (payload) => {
753
+ if (modeRef.current !== "done") return;
754
+ // A local-only take never uploads, so any native upload progress
755
+ // reaching this card belongs to some other recording.
756
+ if (sessionRef.current.localOnly) return;
757
+ if (!isCompletionForSession(sessionRef.current.recordingId, payload)) {
758
+ return;
759
+ }
760
+ if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
761
+ stallTimerRef.current = setTimeout(() => {
762
+ setDoneStage((s) => (s === "uploaded" ? s : "failed"));
763
+ }, 120_000);
764
+ if (payload?.stage && payload.stage !== "opening") {
765
+ setDoneStage((s) =>
766
+ s === "uploaded" || s === "failed" ? s : "uploading",
767
+ );
768
+ }
769
+ }),
770
+ );
771
+ track(
772
+ safeListen<NativeUploadFinished>(
773
+ "clips:native-upload-finished",
774
+ (payload) => handleUploadFinished(payload ?? {}),
775
+ ),
776
+ );
777
+ track(
778
+ // The menu-bar status item doubles as a Stop button while recording;
779
+ // route its click through the same stop flow so the finishing hold and
780
+ // completion card run. Before capture is live there is nothing to stop,
781
+ // so the click falls back to opening Clips.
782
+ safeListen("clips:tray-stop-request", () => {
783
+ if (enabledRef.current && modeRef.current !== "done") stop();
784
+ else void safeInvoke("show_popover");
785
+ }),
786
+ );
787
+ track(
788
+ safeListen<{ level?: number; source?: string }>(
789
+ "voice:audio-level",
790
+ (payload) => {
791
+ if ((payload?.source ?? "mic") !== "mic") return;
792
+ applyMicLevel(Number(payload?.level) || 0);
793
+ },
794
+ ),
795
+ );
796
+ // The handshake goes out only once our own listeners exist. The recorder
797
+ // answers `toolbar-ready` immediately, and `listen()` is asynchronous, so
798
+ // emitting first can drop the reply — costing the pill its enable and its
799
+ // session identity until some later event happens to arrive.
800
+ void Promise.allSettled(registrations).then((results) => {
801
+ const failures = results.flatMap((r) =>
802
+ r.status === "rejected" ? [r.reason] : [],
803
+ );
804
+ if (failures.length > 0) {
805
+ console.error("[record-pill] listener registration failed:", failures);
806
+ }
807
+ if (stopped) return;
808
+ void safeEmit("clips:toolbar-ready", {});
809
+ });
810
+ return () => {
811
+ stopped = true;
812
+ unlistens.forEach((u) => {
813
+ try {
814
+ u();
815
+ } catch {
816
+ // coercion-ok: unlisten during window teardown has no failure mode to surface
817
+ }
818
+ });
819
+ for (const t of [
820
+ hoverTimerRef,
821
+ fallbackTimerRef,
822
+ shrinkTimerRef,
823
+ stallTimerRef,
824
+ levelDecayRef,
825
+ pauseTransitionTimerRef,
826
+ ]) {
827
+ if (t.current) clearTimeout(t.current);
828
+ }
829
+ };
830
+ }, []);
831
+
832
+ function handleSegTransitionEnd(e: React.TransitionEvent) {
833
+ if (e.propertyName !== "width") return;
834
+ const el = e.target as HTMLElement;
835
+ const entry = (Object.keys(segRefs.current) as Seg[]).find(
836
+ (k) => segRefs.current[k] === el,
837
+ );
838
+ if (!entry) return;
839
+ pendingSegsRef.current.delete(entry);
840
+ if (pendingSegsRef.current.size === 0) finishSettleRef.current?.();
841
+ }
842
+
843
+ // Escape resumes during confirm — window-level, per the spec.
844
+ useEffect(() => {
845
+ const onKey = (e: KeyboardEvent) => {
846
+ if (e.key === "Escape" && modeRef.current === "confirm") {
847
+ e.preventDefault();
848
+ exitConfirm(false);
849
+ }
850
+ };
851
+ window.addEventListener("keydown", onKey);
852
+ return () => window.removeEventListener("keydown", onKey);
853
+ }, []);
854
+
855
+ // Fit the native window to the measured pill once fonts have settled.
856
+ useEffect(() => {
857
+ let cancelled = false;
858
+ const fit = () => {
859
+ if (cancelled) return;
860
+ syncWindowToContent();
861
+ };
862
+ if (document.fonts?.ready) {
863
+ void document.fonts.ready.then(fit);
864
+ } else {
865
+ fit();
866
+ }
867
+ return () => {
868
+ cancelled = true;
869
+ };
870
+ }, []);
871
+
872
+ // Done mode: card replaces the pill in place — refit the window to the
873
+ // card and drain any completion result the events may have raced past.
874
+ useEffect(() => {
875
+ if (mode !== "done") return;
876
+ requestAnimationFrame(() => syncWindowToContent());
877
+ stallTimerRef.current = setTimeout(() => {
878
+ setDoneStage((s) =>
879
+ s === "uploaded" ? s : s === "failed" ? s : "failed",
880
+ );
881
+ }, 120_000);
882
+ try {
883
+ const raw = window.localStorage.getItem(FINALIZING_RESULT_STORAGE_KEY);
884
+ if (raw) {
885
+ window.localStorage.removeItem(FINALIZING_RESULT_STORAGE_KEY);
886
+ const parsed = JSON.parse(raw) as NativeUploadFinished;
887
+ if (parsed && typeof parsed === "object") handleUploadFinished(parsed);
888
+ }
889
+ } catch {
890
+ // coercion-ok: storage is a best-effort race fallback; the event path is authoritative
891
+ }
892
+ void safeInvoke<NativeUploadFinished | null>(
893
+ "native_fullscreen_take_upload_finished",
894
+ ).then((payload) => {
895
+ if (payload) handleUploadFinished(payload);
896
+ });
897
+ }, [mode]);
898
+
899
+ // Persist the dragged position, debounced, only while at rest so a grown
900
+ // pill never becomes the stored anchor.
901
+ useEffect(() => {
902
+ if (!hasTauri) return;
903
+ let timer: ReturnType<typeof setTimeout> | null = null;
904
+ let unlisten: (() => void) | null = null;
905
+ void getCurrentWindow()
906
+ .onMoved(() => {
907
+ if (timer) clearTimeout(timer);
908
+ timer = setTimeout(() => {
909
+ if (
910
+ modeRef.current !== "recording" ||
911
+ revealedRef.current ||
912
+ Date.now() < animatingUntilRef.current
913
+ ) {
914
+ return;
915
+ }
916
+ void getCurrentWindow()
917
+ .outerPosition()
918
+ .then((pos) =>
919
+ safeInvoke("toolbar_save_position", { x: pos.x, y: pos.y }),
920
+ );
921
+ }, 600);
922
+ })
923
+ .then((u) => {
924
+ unlisten = u;
925
+ })
926
+ .catch(() => {});
927
+ return () => {
928
+ if (timer) clearTimeout(timer);
929
+ unlisten?.();
930
+ };
931
+ }, []);
932
+
933
+ // Demo drive for browser previews (no Tauri): tick the timer and meter.
934
+ useEffect(() => {
935
+ if (!demoMode) return;
936
+ const t = setInterval(() => {
937
+ if (modeRef.current !== "done" && !pausedRef.current) {
938
+ setElapsed((e) => e + 500);
939
+ }
940
+ }, 500);
941
+ const levels = setInterval(() => {
942
+ if (modeRef.current !== "done" && !pausedRef.current) {
943
+ applyMicLevel(0.05 + Math.random() * 0.3);
944
+ }
945
+ }, 90);
946
+ return () => {
947
+ clearInterval(t);
948
+ clearInterval(levels);
949
+ };
950
+ }, []);
951
+
952
+ // Self-healing size net: whatever strands the window at the wrong size —
953
+ // a resize racing a transition, a throttled animation clock finishing
954
+ // late, a font swap — the pill's layout size is the truth, so any drift
955
+ // outside a choreographed transition re-syncs the window to it.
956
+ useEffect(() => {
957
+ if (!hasTauri) return;
958
+ let timer: ReturnType<typeof setTimeout> | null = null;
959
+ const observer = new ResizeObserver(() => {
960
+ if (Date.now() < animatingUntilRef.current) return;
961
+ if (timer) clearTimeout(timer);
962
+ timer = setTimeout(() => syncWindowToContent(), 120);
963
+ });
964
+ const el = mode === "done" ? cardRef.current : pillRef.current;
965
+ if (el) observer.observe(el);
966
+ return () => {
967
+ if (timer) clearTimeout(timer);
968
+ observer.disconnect();
969
+ };
970
+ }, [mode]);
971
+
972
+ // The pill owns its window's visibility: hidden through pre-record and the
973
+ // countdown, shown the moment capture is live, and kept up while the
974
+ // completion card is open. Rust never shows this window itself.
975
+ const visibleRef = useRef(false);
976
+ useEffect(() => {
977
+ if (!hasTauri) return;
978
+ const visible = enabled || mode === "done";
979
+ if (visibleRef.current === visible) return;
980
+ visibleRef.current = visible;
981
+ if (visible) syncWindowToContent();
982
+ queueWindowOp(async () => {
983
+ await invoke("toolbar_set_visible", { visible });
984
+ });
985
+ }, [enabled, mode]);
986
+
987
+ // The pill is also the single writer of the menu bar's recording mode:
988
+ // stop square + ticking timer exactly while capture is live, the app logo
989
+ // otherwise. Rust infers nothing; a window-destroyed backstop covers the
990
+ // one report this effect can never send.
991
+ const trayLive = enabled && mode !== "done";
992
+ // One ordered writer. These calls used to be two independent fire-and-forget
993
+ // invokes, so a timer update issued just before a stop could land after the
994
+ // stop's inactive write and put the stop square back for a session that had
995
+ // already ended, until the dead-man cleared it seconds later.
996
+ const trayOpChainRef = useRef<Promise<unknown>>(Promise.resolve());
997
+ function writeTrayStatus(active: boolean, title: string | null) {
998
+ trayOpChainRef.current = trayOpChainRef.current.then(() =>
999
+ safeInvoke("tray_recording_status", { active, title }),
1000
+ );
1001
+ }
1002
+ function liveTrayTitle() {
1003
+ return `${pausedRef.current ? "⏸ " : ""}${formatTimer(elapsedRef.current)}`;
1004
+ }
1005
+ useEffect(() => {
1006
+ if (!hasTauri) return;
1007
+ writeTrayStatus(trayLive, trayLive ? liveTrayTitle() : null);
1008
+ }, [trayLive, elapsed, paused]);
1009
+ // The tray's dead-man clears the status item after 2.5s without a write.
1010
+ // While paused, `elapsed` stops advancing and `paused` stops changing, so
1011
+ // the effect above stops firing and the menu bar would drop a recording
1012
+ // that is still very much live. This heartbeat is what keeps it.
1013
+ useEffect(() => {
1014
+ if (!hasTauri || !trayLive) return;
1015
+ const beat = setInterval(() => writeTrayStatus(true, liveTrayTitle()), 800);
1016
+ return () => clearInterval(beat);
1017
+ }, [trayLive]);
1018
+
1019
+ // ---- interactions ----
1020
+
1021
+ function handleMouseEnter() {
1022
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
1023
+ hoverTimerRef.current = setTimeout(() => reveal(true), HOVER_INTENT_MS);
1024
+ }
1025
+ function handleMouseLeave() {
1026
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
1027
+ reveal(false);
1028
+ }
1029
+ function handleFocusCapture() {
1030
+ reveal(true);
1031
+ }
1032
+ function handleBlurCapture(e: FocusEvent<HTMLDivElement>) {
1033
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
1034
+ reveal(false);
1035
+ }
1036
+
1037
+ function handlePillMouseDown(e: React.MouseEvent) {
1038
+ if (e.button !== 0) return;
1039
+ const target = e.target as HTMLElement;
1040
+ if (target.closest("button")) return;
1041
+ // During confirm only Delete, Resume, or Esc answer the question — a
1042
+ // stray tap must never resume the recording.
1043
+ if (hasTauri) {
1044
+ getCurrentWindow()
1045
+ .startDragging()
1046
+ .catch(() => {});
1047
+ }
1048
+ }
1049
+
1050
+ const inConfirm = mode === "confirm";
1051
+ const showPaused = paused;
1052
+ const meterFlat = showPaused || !enabled;
1053
+ const timerText = formatTimer(elapsed);
1054
+ const shownBarHeights = meterFlat ? [4, 4, 4, 4, 4] : barHeights;
1055
+
1056
+ const card = completionCardState(doneStage, {
1057
+ hasLink: Boolean(viewUrl),
1058
+ savedLocally,
1059
+ });
1060
+ const cardBadgeClass =
1061
+ card.tone === "ok"
1062
+ ? "bg-[var(--pill-card-badge-bg)] text-[var(--pill-card-badge)]"
1063
+ : card.tone === "warn"
1064
+ ? "bg-[var(--pill-card-badge-warn-bg)] text-[var(--pill-card-badge-warn)]"
1065
+ : "bg-[var(--pill-card-well)] text-[var(--pill-card-ink-2)]";
1066
+ // Announce what the card actually says, when it says it. Stop announced
1067
+ // "Recording saved" the moment it was clicked, before the export or upload
1068
+ // had returned anything to say that about.
1069
+ const cardTitle = mode === "done" ? card.title : null;
1070
+ useEffect(() => {
1071
+ if (cardTitle) setAnnouncement(cardTitle);
1072
+ }, [cardTitle]);
1073
+
1074
+ return (
1075
+ <div
1076
+ data-tw-surface
1077
+ className="record-pill-scope flex h-screen w-screen select-none items-end"
1078
+ >
1079
+ <div aria-live="polite" className="sr-only">
1080
+ {announcement}
1081
+ </div>
1082
+ {mode === "done" ? (
1083
+ <div
1084
+ ref={cardRef}
1085
+ className={`w-[340px] flex-none rounded-[14px] border-[0.5px] border-[var(--pill-card-border)] bg-[var(--pill-card-surface)] p-4 ${reducedRef.current ? "" : "record-pill-card-in"}`}
1086
+ >
1087
+ <div className="mb-3 flex items-center gap-2.5">
1088
+ <span
1089
+ className={`flex size-8 flex-none items-center justify-center rounded-full ${cardBadgeClass}`}
1090
+ >
1091
+ {card.tone === "ok" ? (
1092
+ <IconCheck size={16} stroke={2.4} aria-hidden />
1093
+ ) : card.tone === "warn" ? (
1094
+ <IconAlertTriangle size={16} stroke={2.2} aria-hidden />
1095
+ ) : (
1096
+ <IconLoader2
1097
+ size={16}
1098
+ stroke={2.2}
1099
+ aria-hidden
1100
+ className={reducedRef.current ? "" : "animate-spin"}
1101
+ />
1102
+ )}
1103
+ </span>
1104
+ <div className="min-w-0">
1105
+ <div className="text-sm font-semibold text-[var(--pill-card-ink)]">
1106
+ {card.title}
1107
+ </div>
1108
+ <div className="text-xs text-[var(--pill-card-ink-2)]">
1109
+ {formatDurationCopy(doneDurationMs)}
1110
+ {card.detail ? ` · ${card.detail}` : ""}
1111
+ </div>
1112
+ </div>
1113
+ <button
1114
+ type="button"
1115
+ onClick={dismissCard}
1116
+ aria-label="Dismiss"
1117
+ className="ml-auto flex size-6 flex-none items-center justify-center rounded text-[var(--pill-card-ink-3)] hover:text-[var(--pill-card-ink)]"
1118
+ >
1119
+ <IconX size={15} aria-hidden />
1120
+ </button>
1121
+ </div>
1122
+ {viewUrl ? (
1123
+ <div className="mb-3 flex items-center gap-2 rounded-lg bg-[var(--pill-card-well)] px-2.5 py-2 text-xs">
1124
+ <IconLink
1125
+ size={14}
1126
+ className="flex-none text-[var(--pill-card-ink-2)]"
1127
+ aria-hidden
1128
+ />
1129
+ <span className="record-pill-mono min-w-0 flex-1 truncate text-[var(--pill-card-ink-2)]">
1130
+ {viewUrl.replace(/^https?:\/\//, "")}
1131
+ </span>
1132
+ <button
1133
+ type="button"
1134
+ onClick={() => void copyLink(viewUrl)}
1135
+ aria-label="Copy link"
1136
+ className={`flex size-5 flex-none items-center justify-center rounded ${copied ? "text-[var(--pill-card-badge)]" : "text-[var(--pill-card-ink-2)] hover:text-[var(--pill-card-ink)]"}`}
1137
+ >
1138
+ {copied ? (
1139
+ <IconCheck size={14} aria-hidden />
1140
+ ) : (
1141
+ <IconCopy size={14} aria-hidden />
1142
+ )}
1143
+ </button>
1144
+ </div>
1145
+ ) : null}
1146
+ <div className="flex gap-2">
1147
+ {viewUrl ? (
1148
+ <>
1149
+ <button
1150
+ type="button"
1151
+ onClick={() => {
1152
+ if (hasTauri) void openExternal(viewUrl).catch(() => {});
1153
+ else window.open(viewUrl, "_blank");
1154
+ }}
1155
+ className="h-[34px] flex-1 rounded-lg bg-[var(--pill-card-ink)] text-[13px] font-semibold text-[var(--pill-on-chrome)]"
1156
+ >
1157
+ Open
1158
+ </button>
1159
+ <button
1160
+ type="button"
1161
+ onClick={() => void copyLink(viewUrl)}
1162
+ className="h-[34px] flex-1 rounded-lg border border-[var(--pill-card-border-strong)] bg-[var(--pill-on-chrome)] text-[13px] font-semibold text-[var(--pill-card-ink)]"
1163
+ >
1164
+ {copied ? "Copied" : "Copy"}
1165
+ </button>
1166
+ </>
1167
+ ) : null}
1168
+ </div>
1169
+ </div>
1170
+ ) : (
1171
+ <div
1172
+ ref={pillRef}
1173
+ onMouseDown={handlePillMouseDown}
1174
+ onTransitionEnd={handleSegTransitionEnd}
1175
+ onMouseEnter={handleMouseEnter}
1176
+ onMouseLeave={handleMouseLeave}
1177
+ onFocusCapture={handleFocusCapture}
1178
+ onBlurCapture={handleBlurCapture}
1179
+ className={`flex h-[42px] flex-none items-center rounded-full bg-[var(--pill-chrome)] p-1.5 text-[var(--pill-on-chrome)] ${enabled ? "" : "opacity-80"}`}
1180
+ >
1181
+ <button
1182
+ type="button"
1183
+ onClick={stop}
1184
+ disabled={!enabled || inConfirm}
1185
+ aria-label="Stop and save"
1186
+ className="flex size-[30px] flex-none items-center justify-center rounded-full transition-colors duration-150 disabled:cursor-default"
1187
+ style={{
1188
+ color: showPaused ? "var(--pill-ghost-ink)" : "var(--pill-rec)",
1189
+ }}
1190
+ >
1191
+ <span
1192
+ aria-hidden
1193
+ className="size-[13px] rounded-[3px] bg-current"
1194
+ />
1195
+ </button>
1196
+ <span
1197
+ aria-live="off"
1198
+ className="record-pill-mono ml-1.5 flex-none text-sm font-medium transition-colors duration-150"
1199
+ style={{
1200
+ color: showPaused ? "var(--pill-on-chrome)" : "var(--pill-rec)",
1201
+ }}
1202
+ >
1203
+ {timerText}
1204
+ </span>
1205
+ <span
1206
+ ref={(el) => {
1207
+ segRefs.current.mid = el;
1208
+ }}
1209
+ className="record-pill-seg"
1210
+ style={{ width: "auto", opacity: 1 }}
1211
+ >
1212
+ <span className="inline-flex flex-none items-center">
1213
+ <span
1214
+ aria-hidden
1215
+ className="ml-3.5 flex h-3.5 w-[18px] flex-none items-center justify-center gap-[2px] transition-opacity duration-150"
1216
+ style={{ opacity: meterFlat ? 0.3 : 1 }}
1217
+ >
1218
+ {shownBarHeights.map((h, i) => (
1219
+ <i
1220
+ key={i}
1221
+ className="block w-[2px] rounded-full bg-[var(--pill-meter)] transition-[height] duration-100 ease-out"
1222
+ style={{ height: `${h}px` }}
1223
+ />
1224
+ ))}
1225
+ </span>
1226
+ <button
1227
+ type="button"
1228
+ onClick={togglePause}
1229
+ disabled={!enabled || inConfirm}
1230
+ aria-label={showPaused ? "Resume" : "Pause"}
1231
+ className="ml-1.5 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] transition-colors duration-150 hover:text-[var(--pill-on-chrome)] disabled:cursor-default disabled:opacity-50"
1232
+ >
1233
+ {showPaused ? (
1234
+ <IconPlayerPlayFilled size={14} aria-hidden />
1235
+ ) : (
1236
+ <IconPlayerPauseFilled size={14} aria-hidden />
1237
+ )}
1238
+ </button>
1239
+ </span>
1240
+ </span>
1241
+ <span
1242
+ ref={(el) => {
1243
+ segRefs.current.q = el;
1244
+ }}
1245
+ className="record-pill-seg"
1246
+ >
1247
+ <span className="inline-flex flex-none items-center">
1248
+ <span className="pl-2.5 text-xs whitespace-nowrap text-[var(--pill-q-ink)]">
1249
+ {confirmQuestion}
1250
+ </span>
1251
+ </span>
1252
+ </span>
1253
+ <span
1254
+ ref={(el) => {
1255
+ segRefs.current.del = el;
1256
+ }}
1257
+ className="record-pill-seg"
1258
+ >
1259
+ <span className="inline-flex flex-none items-center">
1260
+ <button
1261
+ type="button"
1262
+ onClick={confirmDestructive}
1263
+ className={`ml-2.5 flex h-7 flex-none items-center rounded-full px-3.5 text-xs font-semibold ${confirmIntent === "delete" ? "bg-[var(--pill-rec)] text-[var(--pill-on-chrome)]" : "bg-[var(--pill-on-chrome)] text-[var(--pill-chrome)]"}`}
1264
+ >
1265
+ {confirmIntent === "delete" ? "Delete" : "Restart"}
1266
+ </button>
1267
+ </span>
1268
+ </span>
1269
+ <span
1270
+ ref={(el) => {
1271
+ segRefs.current.res = el;
1272
+ }}
1273
+ className="record-pill-seg"
1274
+ >
1275
+ <span className="inline-flex flex-none items-center">
1276
+ <button
1277
+ type="button"
1278
+ onClick={() => exitConfirm(true)}
1279
+ className="ml-2 flex h-7 flex-none items-center rounded-full bg-[var(--pill-soft)] px-3.5 text-xs font-semibold text-[var(--pill-on-chrome)]"
1280
+ >
1281
+ Resume
1282
+ </button>
1283
+ </span>
1284
+ </span>
1285
+ <span
1286
+ ref={(el) => {
1287
+ segRefs.current.extras = el;
1288
+ }}
1289
+ className="record-pill-seg"
1290
+ >
1291
+ <span className="inline-flex flex-none items-center">
1292
+ <span
1293
+ aria-hidden
1294
+ className="ml-1.5 h-[18px] w-px flex-none bg-[var(--pill-soft)]"
1295
+ />
1296
+ <button
1297
+ type="button"
1298
+ onClick={() => enterConfirm("restart")}
1299
+ disabled={!enabled || !!pendingAction}
1300
+ aria-label="Restart recording"
1301
+ className="ml-1.5 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] hover:text-[var(--pill-on-chrome)]"
1302
+ >
1303
+ <IconRefresh size={14} stroke={2} aria-hidden />
1304
+ </button>
1305
+ <button
1306
+ type="button"
1307
+ onClick={() => enterConfirm("delete")}
1308
+ disabled={!enabled || !!pendingAction}
1309
+ aria-label="Delete recording"
1310
+ className="ml-0 flex size-[30px] flex-none items-center justify-center rounded-full text-[var(--pill-ghost-ink)] hover:text-[var(--pill-on-chrome)]"
1311
+ >
1312
+ <IconTrash size={14} stroke={2} aria-hidden />
1313
+ </button>
1314
+ </span>
1315
+ </span>
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ );
1320
+ }