@agent-native/core 0.70.3 → 0.71.0
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 +2 -2
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/docs/content/multi-app-workspace.md +2 -2
- package/corpus/core/docs/design/durable-agent-runs.md +458 -4
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +192 -0
- package/corpus/core/src/agent/production-agent.ts +330 -13
- package/corpus/core/src/agent/run-manager.ts +66 -3
- package/corpus/core/src/agent/run-store.ts +129 -23
- package/corpus/core/src/agent/types.ts +20 -0
- package/corpus/core/src/client/AgentPanel.tsx +12 -9
- package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
- package/corpus/core/src/deploy/build.ts +96 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
- package/corpus/core/src/mcp/build-server.ts +22 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
- package/corpus/templates/analytics/AGENTS.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
- package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
- package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +130 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +195 -34
- package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
- package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
- package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
- package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
- package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
- package/corpus/templates/clips/desktop/src/styles.css +33 -5
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/dist/agent/durable-background.d.ts +60 -0
- package/dist/agent/durable-background.d.ts.map +1 -0
- package/dist/agent/durable-background.js +144 -0
- package/dist/agent/durable-background.js.map +1 -0
- package/dist/agent/production-agent.d.ts +20 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +292 -14
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +48 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +45 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +30 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +124 -24
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +20 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +37 -4
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/deploy/build.d.ts +29 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +85 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +108 -0
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +23 -9
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +68 -13
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/docs/content/multi-app-workspace.md +2 -2
- package/docs/design/durable-agent-runs.md +458 -4
- package/package.json +1 -1
|
@@ -162,6 +162,31 @@
|
|
|
162
162
|
</header>
|
|
163
163
|
|
|
164
164
|
<div id="idle-content">
|
|
165
|
+
<div id="unsupported-notice" class="notice" role="alert" hidden>
|
|
166
|
+
<svg
|
|
167
|
+
class="notice-icon"
|
|
168
|
+
viewBox="0 0 24 24"
|
|
169
|
+
fill="none"
|
|
170
|
+
aria-hidden="true"
|
|
171
|
+
>
|
|
172
|
+
<path
|
|
173
|
+
d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"
|
|
174
|
+
stroke="currentColor"
|
|
175
|
+
stroke-width="1.75"
|
|
176
|
+
stroke-linecap="round"
|
|
177
|
+
stroke-linejoin="round"
|
|
178
|
+
/>
|
|
179
|
+
</svg>
|
|
180
|
+
<div class="notice-body">
|
|
181
|
+
<div class="notice-title">Clips can't draw controls here</div>
|
|
182
|
+
<div class="notice-text">
|
|
183
|
+
This is a browser page (like New Tab), so the countdown and
|
|
184
|
+
on-screen controls won't appear. You can still record a window or
|
|
185
|
+
full screen — stop from the Clips icon — or open a website for the
|
|
186
|
+
full experience.
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
165
190
|
<section class="panel" aria-label="Recorder setup">
|
|
166
191
|
<div id="source-row" class="row row-on">
|
|
167
192
|
<span class="row-icon" aria-hidden="true">
|
|
@@ -242,10 +267,24 @@
|
|
|
242
267
|
/>
|
|
243
268
|
</svg>
|
|
244
269
|
</span>
|
|
245
|
-
<button type="button" class="row-button"
|
|
246
|
-
<span class="row-label"
|
|
270
|
+
<button id="camera-device-button" type="button" class="row-button">
|
|
271
|
+
<span id="camera-device-label" class="row-label"
|
|
272
|
+
>Default camera</span
|
|
273
|
+
>
|
|
247
274
|
<span class="row-flex" aria-hidden="true"></span>
|
|
275
|
+
<span class="row-chev" aria-hidden="true">
|
|
276
|
+
<svg viewBox="0 0 24 24" fill="none">
|
|
277
|
+
<path
|
|
278
|
+
d="M6 9l6 6 6-6"
|
|
279
|
+
stroke="currentColor"
|
|
280
|
+
stroke-width="2"
|
|
281
|
+
stroke-linecap="round"
|
|
282
|
+
stroke-linejoin="round"
|
|
283
|
+
/>
|
|
284
|
+
</svg>
|
|
285
|
+
</span>
|
|
248
286
|
</button>
|
|
287
|
+
<div id="camera-device-menu" class="row-menu" hidden></div>
|
|
249
288
|
<button
|
|
250
289
|
id="include-camera"
|
|
251
290
|
class="toggle toggle-on"
|
|
@@ -257,7 +296,7 @@
|
|
|
257
296
|
</button>
|
|
258
297
|
</div>
|
|
259
298
|
|
|
260
|
-
<div class="row row-on">
|
|
299
|
+
<div id="microphone-row" class="row row-on">
|
|
261
300
|
<span class="row-icon" aria-hidden="true">
|
|
262
301
|
<svg viewBox="0 0 24 24" fill="none">
|
|
263
302
|
<rect
|
|
@@ -277,10 +316,28 @@
|
|
|
277
316
|
/>
|
|
278
317
|
</svg>
|
|
279
318
|
</span>
|
|
280
|
-
<button
|
|
281
|
-
|
|
319
|
+
<button
|
|
320
|
+
id="microphone-device-button"
|
|
321
|
+
type="button"
|
|
322
|
+
class="row-button"
|
|
323
|
+
>
|
|
324
|
+
<span id="microphone-device-label" class="row-label"
|
|
325
|
+
>Default mic</span
|
|
326
|
+
>
|
|
282
327
|
<span class="row-flex" aria-hidden="true"></span>
|
|
328
|
+
<span class="row-chev" aria-hidden="true">
|
|
329
|
+
<svg viewBox="0 0 24 24" fill="none">
|
|
330
|
+
<path
|
|
331
|
+
d="M6 9l6 6 6-6"
|
|
332
|
+
stroke="currentColor"
|
|
333
|
+
stroke-width="2"
|
|
334
|
+
stroke-linecap="round"
|
|
335
|
+
stroke-linejoin="round"
|
|
336
|
+
/>
|
|
337
|
+
</svg>
|
|
338
|
+
</span>
|
|
283
339
|
</button>
|
|
340
|
+
<div id="microphone-device-menu" class="row-menu" hidden></div>
|
|
284
341
|
<button
|
|
285
342
|
id="include-microphone"
|
|
286
343
|
class="toggle toggle-on"
|
|
@@ -7,6 +7,13 @@ type ExtensionSettings = {
|
|
|
7
7
|
includeCamera: boolean;
|
|
8
8
|
includeMicrophone: boolean;
|
|
9
9
|
includeDeveloperLogs: boolean;
|
|
10
|
+
videoDeviceId: string;
|
|
11
|
+
audioDeviceId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type InputDevice = {
|
|
15
|
+
deviceId: string;
|
|
16
|
+
label: string;
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
type PopupStartResponse = {
|
|
@@ -67,6 +74,8 @@ const DEFAULT_SETTINGS: ExtensionSettings = {
|
|
|
67
74
|
includeCamera: true,
|
|
68
75
|
includeMicrophone: true,
|
|
69
76
|
includeDeveloperLogs: true,
|
|
77
|
+
videoDeviceId: "",
|
|
78
|
+
audioDeviceId: "",
|
|
70
79
|
};
|
|
71
80
|
|
|
72
81
|
const SOURCE_LABELS: Record<Exclude<CaptureSurface, "camera">, string> = {
|
|
@@ -167,6 +176,37 @@ async function loadFeedbackSchema(
|
|
|
167
176
|
return pending;
|
|
168
177
|
}
|
|
169
178
|
|
|
179
|
+
// Enumerate the user's input devices for the camera/mic pickers. Labels only
|
|
180
|
+
// populate after camera/mic permission is granted (the extension's permission
|
|
181
|
+
// onboarding page handles that), so fall back to a generic label otherwise.
|
|
182
|
+
async function enumerateInputDevices(): Promise<{
|
|
183
|
+
cameras: InputDevice[];
|
|
184
|
+
microphones: InputDevice[];
|
|
185
|
+
}> {
|
|
186
|
+
try {
|
|
187
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
188
|
+
const cameras: InputDevice[] = [];
|
|
189
|
+
const microphones: InputDevice[] = [];
|
|
190
|
+
for (const device of devices) {
|
|
191
|
+
if (!device.deviceId) continue;
|
|
192
|
+
if (device.kind === "videoinput") {
|
|
193
|
+
cameras.push({
|
|
194
|
+
deviceId: device.deviceId,
|
|
195
|
+
label: device.label.trim() || `Camera ${cameras.length + 1}`,
|
|
196
|
+
});
|
|
197
|
+
} else if (device.kind === "audioinput") {
|
|
198
|
+
microphones.push({
|
|
199
|
+
deviceId: device.deviceId,
|
|
200
|
+
label: device.label.trim() || `Microphone ${microphones.length + 1}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { cameras, microphones };
|
|
205
|
+
} catch {
|
|
206
|
+
return { cameras: [], microphones: [] };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
function readSettings(): Promise<ExtensionSettings> {
|
|
171
211
|
return new Promise((resolve) => {
|
|
172
212
|
chrome.storage.sync.get(DEFAULT_SETTINGS, (value) => {
|
|
@@ -188,6 +228,14 @@ function readSettings(): Promise<ExtensionSettings> {
|
|
|
188
228
|
typeof value.includeDeveloperLogs === "boolean"
|
|
189
229
|
? value.includeDeveloperLogs
|
|
190
230
|
: DEFAULT_SETTINGS.includeDeveloperLogs,
|
|
231
|
+
videoDeviceId:
|
|
232
|
+
typeof value.videoDeviceId === "string"
|
|
233
|
+
? value.videoDeviceId
|
|
234
|
+
: DEFAULT_SETTINGS.videoDeviceId,
|
|
235
|
+
audioDeviceId:
|
|
236
|
+
typeof value.audioDeviceId === "string"
|
|
237
|
+
? value.audioDeviceId
|
|
238
|
+
: DEFAULT_SETTINGS.audioDeviceId,
|
|
191
239
|
});
|
|
192
240
|
});
|
|
193
241
|
});
|
|
@@ -401,6 +449,28 @@ function comparableLabel(value: string): string {
|
|
|
401
449
|
.replace(/[^a-z0-9]+/g, "");
|
|
402
450
|
}
|
|
403
451
|
|
|
452
|
+
// Pages where Chrome forbids content-script / overlay injection, so the on-page
|
|
453
|
+
// countdown + controls can't render. Recording the screen still works; we just
|
|
454
|
+
// warn the user in the popup instead of letting it fail silently.
|
|
455
|
+
function isUnsupportedPage(url: string | undefined | null): boolean {
|
|
456
|
+
if (!url) return true;
|
|
457
|
+
const u = url.toLowerCase();
|
|
458
|
+
return (
|
|
459
|
+
u.startsWith("chrome://") ||
|
|
460
|
+
u.startsWith("chrome-extension://") ||
|
|
461
|
+
u.startsWith("edge://") ||
|
|
462
|
+
u.startsWith("brave://") ||
|
|
463
|
+
u.startsWith("arc://") ||
|
|
464
|
+
u.startsWith("about:") ||
|
|
465
|
+
u.startsWith("view-source:") ||
|
|
466
|
+
u.startsWith("devtools://") ||
|
|
467
|
+
u.startsWith("chrome-search://") ||
|
|
468
|
+
u.startsWith("chrome-untrusted://") ||
|
|
469
|
+
u.startsWith("https://chromewebstore.google.com") ||
|
|
470
|
+
u.startsWith("https://chrome.google.com/webstore")
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
404
474
|
function targetCopy(tab: chrome.tabs.Tab | null): {
|
|
405
475
|
title: string;
|
|
406
476
|
subtitle: string;
|
|
@@ -464,9 +534,111 @@ function renderSource(settings: ExtensionSettings): void {
|
|
|
464
534
|
}
|
|
465
535
|
}
|
|
466
536
|
|
|
537
|
+
// Holds the most recent device enumeration so the menus and labels can render
|
|
538
|
+
// without re-querying on every paint. Refreshed by refreshDevices().
|
|
539
|
+
let inputDevices: { cameras: InputDevice[]; microphones: InputDevice[] } = {
|
|
540
|
+
cameras: [],
|
|
541
|
+
microphones: [],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
function deviceLabel(
|
|
545
|
+
devices: InputDevice[],
|
|
546
|
+
deviceId: string,
|
|
547
|
+
fallback: string,
|
|
548
|
+
): string {
|
|
549
|
+
if (!deviceId) return fallback;
|
|
550
|
+
const match = devices.find((device) => device.deviceId === deviceId);
|
|
551
|
+
return match ? match.label : fallback;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderDeviceMenu(
|
|
555
|
+
menu: HTMLDivElement,
|
|
556
|
+
devices: InputDevice[],
|
|
557
|
+
selectedId: string,
|
|
558
|
+
defaultLabel: string,
|
|
559
|
+
): void {
|
|
560
|
+
menu.replaceChildren();
|
|
561
|
+
const options: InputDevice[] = [
|
|
562
|
+
{ deviceId: "", label: defaultLabel },
|
|
563
|
+
...devices,
|
|
564
|
+
];
|
|
565
|
+
for (const option of options) {
|
|
566
|
+
const item = document.createElement("button");
|
|
567
|
+
item.type = "button";
|
|
568
|
+
item.className = "row-menu-item";
|
|
569
|
+
item.dataset.deviceId = option.deviceId;
|
|
570
|
+
const selected = option.deviceId === selectedId;
|
|
571
|
+
if (selected) {
|
|
572
|
+
item.classList.add("selected");
|
|
573
|
+
item.setAttribute("aria-checked", "true");
|
|
574
|
+
} else {
|
|
575
|
+
item.setAttribute("aria-checked", "false");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const check = document.createElement("span");
|
|
579
|
+
check.className = "row-menu-check";
|
|
580
|
+
check.setAttribute("aria-hidden", "true");
|
|
581
|
+
check.textContent = selected ? "✓" : "";
|
|
582
|
+
|
|
583
|
+
const label = document.createElement("span");
|
|
584
|
+
label.className = "row-menu-label";
|
|
585
|
+
label.textContent = option.label;
|
|
586
|
+
|
|
587
|
+
item.append(check, label);
|
|
588
|
+
menu.append(item);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderDevicePickers(settings: ExtensionSettings): void {
|
|
593
|
+
const cameraButton = byId<HTMLButtonElement>("camera-device-button");
|
|
594
|
+
const cameraLabel = byId<HTMLSpanElement>("camera-device-label");
|
|
595
|
+
const cameraMenu = byId<HTMLDivElement>("camera-device-menu");
|
|
596
|
+
const showCamera =
|
|
597
|
+
settings.captureSurface === "camera" || settings.includeCamera;
|
|
598
|
+
cameraButton.hidden = !showCamera;
|
|
599
|
+
if (showCamera) {
|
|
600
|
+
cameraLabel.textContent = deviceLabel(
|
|
601
|
+
inputDevices.cameras,
|
|
602
|
+
settings.videoDeviceId,
|
|
603
|
+
"Default camera",
|
|
604
|
+
);
|
|
605
|
+
renderDeviceMenu(
|
|
606
|
+
cameraMenu,
|
|
607
|
+
inputDevices.cameras,
|
|
608
|
+
settings.videoDeviceId,
|
|
609
|
+
"Default camera",
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
cameraMenu.hidden = true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const micRow = byId<HTMLDivElement>("microphone-row");
|
|
616
|
+
const micButton = byId<HTMLButtonElement>("microphone-device-button");
|
|
617
|
+
const micLabel = byId<HTMLSpanElement>("microphone-device-label");
|
|
618
|
+
const micMenu = byId<HTMLDivElement>("microphone-device-menu");
|
|
619
|
+
micButton.hidden = !settings.includeMicrophone;
|
|
620
|
+
if (settings.includeMicrophone) {
|
|
621
|
+
micLabel.textContent = deviceLabel(
|
|
622
|
+
inputDevices.microphones,
|
|
623
|
+
settings.audioDeviceId,
|
|
624
|
+
"Default mic",
|
|
625
|
+
);
|
|
626
|
+
renderDeviceMenu(
|
|
627
|
+
micMenu,
|
|
628
|
+
inputDevices.microphones,
|
|
629
|
+
settings.audioDeviceId,
|
|
630
|
+
"Default mic",
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
micMenu.hidden = true;
|
|
634
|
+
}
|
|
635
|
+
micRow.hidden = false;
|
|
636
|
+
}
|
|
637
|
+
|
|
467
638
|
function render(settings: ExtensionSettings): void {
|
|
468
639
|
renderMode(settings);
|
|
469
640
|
renderSource(settings);
|
|
641
|
+
renderDevicePickers(settings);
|
|
470
642
|
const includeCamera = byId<HTMLButtonElement>("include-camera");
|
|
471
643
|
const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
|
|
472
644
|
const cameraRow = byId<HTMLDivElement>("camera-row");
|
|
@@ -550,6 +722,12 @@ async function init(): Promise<void> {
|
|
|
550
722
|
}
|
|
551
723
|
const sourceButton = byId<HTMLButtonElement>("source-button");
|
|
552
724
|
const sourceMenu = byId<HTMLDivElement>("source-menu");
|
|
725
|
+
const cameraDeviceButton = byId<HTMLButtonElement>("camera-device-button");
|
|
726
|
+
const cameraDeviceMenu = byId<HTMLDivElement>("camera-device-menu");
|
|
727
|
+
const microphoneDeviceButton = byId<HTMLButtonElement>(
|
|
728
|
+
"microphone-device-button",
|
|
729
|
+
);
|
|
730
|
+
const microphoneDeviceMenu = byId<HTMLDivElement>("microphone-device-menu");
|
|
553
731
|
const includeCamera = byId<HTMLButtonElement>("include-camera");
|
|
554
732
|
const includeMicrophone = byId<HTMLButtonElement>("include-microphone");
|
|
555
733
|
const start = byId<HTMLButtonElement>("start");
|
|
@@ -624,8 +802,30 @@ async function init(): Promise<void> {
|
|
|
624
802
|
window.setTimeout(() => feedbackTextarea.focus(), 30);
|
|
625
803
|
};
|
|
626
804
|
|
|
627
|
-
|
|
805
|
+
// Re-enumerate devices and repaint the pickers. Labels only appear once the
|
|
806
|
+
// user has granted camera/mic access (via the permission onboarding page), so
|
|
807
|
+
// this is also re-run when the device list changes.
|
|
808
|
+
const refreshDevices = async (): Promise<void> => {
|
|
809
|
+
inputDevices = await enumerateInputDevices();
|
|
810
|
+
renderDevicePickers(settings);
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const closeDeviceMenus = (): void => {
|
|
814
|
+
cameraDeviceMenu.hidden = true;
|
|
815
|
+
microphoneDeviceMenu.hidden = true;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const activeTab = await queryActiveTab();
|
|
819
|
+
byId<HTMLDivElement>("unsupported-notice").hidden = !isUnsupportedPage(
|
|
820
|
+
activeTab?.url,
|
|
821
|
+
);
|
|
628
822
|
render(settings);
|
|
823
|
+
void refreshDevices();
|
|
824
|
+
if (navigator.mediaDevices) {
|
|
825
|
+
navigator.mediaDevices.addEventListener("devicechange", () => {
|
|
826
|
+
void refreshDevices();
|
|
827
|
+
});
|
|
828
|
+
}
|
|
629
829
|
const status =
|
|
630
830
|
await sendSimpleMessage<PopupStatusResponse>("CLIPS_POPUP_STATUS");
|
|
631
831
|
activeRecording = status.activeRecording ?? null;
|
|
@@ -634,17 +834,42 @@ async function init(): Promise<void> {
|
|
|
634
834
|
window.setInterval(() => renderActiveRecording(activeRecording), 1000);
|
|
635
835
|
}
|
|
636
836
|
|
|
837
|
+
// Pre-record preview: while this popup is open and idle, show the live face
|
|
838
|
+
// bubble on the active tab (desktop-app style). The port stays open for the
|
|
839
|
+
// popup's lifetime; its disconnect (popup closed) tells the worker to remove
|
|
840
|
+
// the preview. Keep the reference so the port isn't garbage-collected early.
|
|
841
|
+
const previewPort = chrome.runtime.connect({ name: "clips-preview" });
|
|
842
|
+
const syncPreview = (): void => {
|
|
843
|
+
const wantsCamera =
|
|
844
|
+
!activeRecording &&
|
|
845
|
+
!isUnsupportedPage(activeTab?.url) &&
|
|
846
|
+
(settings.captureSurface === "camera" || settings.includeCamera);
|
|
847
|
+
try {
|
|
848
|
+
chrome.runtime.sendMessage(
|
|
849
|
+
{ type: "CLIPS_PREVIEW", camera: wantsCamera },
|
|
850
|
+
() => void chrome.runtime.lastError,
|
|
851
|
+
);
|
|
852
|
+
} catch {
|
|
853
|
+
/* worker asleep; will re-sync on next interaction */
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
void previewPort;
|
|
857
|
+
syncPreview();
|
|
858
|
+
|
|
637
859
|
for (const button of document.querySelectorAll<HTMLButtonElement>(
|
|
638
860
|
".mode-option",
|
|
639
861
|
)) {
|
|
640
862
|
button.addEventListener("click", () => {
|
|
641
863
|
applyMode(settings, button.dataset.mode as RecordingModeChoice);
|
|
864
|
+
closeDeviceMenus();
|
|
642
865
|
render(settings);
|
|
643
866
|
void saveSettings(settings);
|
|
867
|
+
syncPreview();
|
|
644
868
|
});
|
|
645
869
|
}
|
|
646
870
|
|
|
647
871
|
sourceButton.addEventListener("click", () => {
|
|
872
|
+
closeDeviceMenus();
|
|
648
873
|
sourceMenu.hidden = !sourceMenu.hidden;
|
|
649
874
|
});
|
|
650
875
|
|
|
@@ -656,9 +881,70 @@ async function init(): Promise<void> {
|
|
|
656
881
|
sourceMenu.hidden = true;
|
|
657
882
|
render(settings);
|
|
658
883
|
void saveSettings(settings);
|
|
884
|
+
syncPreview();
|
|
659
885
|
});
|
|
660
886
|
}
|
|
661
887
|
|
|
888
|
+
cameraDeviceButton.addEventListener("click", () => {
|
|
889
|
+
sourceMenu.hidden = true;
|
|
890
|
+
microphoneDeviceMenu.hidden = true;
|
|
891
|
+
cameraDeviceMenu.hidden = !cameraDeviceMenu.hidden;
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
cameraDeviceMenu.addEventListener("click", (event) => {
|
|
895
|
+
const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
|
896
|
+
".row-menu-item",
|
|
897
|
+
);
|
|
898
|
+
if (!item) return;
|
|
899
|
+
settings.videoDeviceId = item.dataset.deviceId ?? "";
|
|
900
|
+
cameraDeviceMenu.hidden = true;
|
|
901
|
+
renderDevicePickers(settings);
|
|
902
|
+
void saveSettings(settings);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
microphoneDeviceButton.addEventListener("click", () => {
|
|
906
|
+
sourceMenu.hidden = true;
|
|
907
|
+
cameraDeviceMenu.hidden = true;
|
|
908
|
+
microphoneDeviceMenu.hidden = !microphoneDeviceMenu.hidden;
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
microphoneDeviceMenu.addEventListener("click", (event) => {
|
|
912
|
+
const item = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
|
913
|
+
".row-menu-item",
|
|
914
|
+
);
|
|
915
|
+
if (!item) return;
|
|
916
|
+
settings.audioDeviceId = item.dataset.deviceId ?? "";
|
|
917
|
+
microphoneDeviceMenu.hidden = true;
|
|
918
|
+
renderDevicePickers(settings);
|
|
919
|
+
void saveSettings(settings);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
document.addEventListener("click", (event) => {
|
|
923
|
+
const target = event.target;
|
|
924
|
+
if (!(target instanceof Node)) return;
|
|
925
|
+
if (
|
|
926
|
+
!sourceMenu.hidden &&
|
|
927
|
+
!sourceMenu.contains(target) &&
|
|
928
|
+
!sourceButton.contains(target)
|
|
929
|
+
) {
|
|
930
|
+
sourceMenu.hidden = true;
|
|
931
|
+
}
|
|
932
|
+
if (
|
|
933
|
+
!cameraDeviceMenu.hidden &&
|
|
934
|
+
!cameraDeviceMenu.contains(target) &&
|
|
935
|
+
!cameraDeviceButton.contains(target)
|
|
936
|
+
) {
|
|
937
|
+
cameraDeviceMenu.hidden = true;
|
|
938
|
+
}
|
|
939
|
+
if (
|
|
940
|
+
!microphoneDeviceMenu.hidden &&
|
|
941
|
+
!microphoneDeviceMenu.contains(target) &&
|
|
942
|
+
!microphoneDeviceButton.contains(target)
|
|
943
|
+
) {
|
|
944
|
+
microphoneDeviceMenu.hidden = true;
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
662
948
|
includeCamera.addEventListener("click", () => {
|
|
663
949
|
settings.includeCamera = !settings.includeCamera;
|
|
664
950
|
if (settings.includeCamera && settings.captureSurface === "monitor") {
|
|
@@ -667,12 +953,15 @@ async function init(): Promise<void> {
|
|
|
667
953
|
if (!settings.includeCamera && settings.captureSurface === "camera") {
|
|
668
954
|
settings.captureSurface = "browser";
|
|
669
955
|
}
|
|
956
|
+
closeDeviceMenus();
|
|
670
957
|
render(settings);
|
|
671
958
|
void saveSettings(settings);
|
|
959
|
+
syncPreview();
|
|
672
960
|
});
|
|
673
961
|
|
|
674
962
|
includeMicrophone.addEventListener("click", () => {
|
|
675
963
|
settings.includeMicrophone = !settings.includeMicrophone;
|
|
964
|
+
closeDeviceMenus();
|
|
676
965
|
render(settings);
|
|
677
966
|
void saveSettings(settings);
|
|
678
967
|
});
|
|
@@ -855,3 +855,37 @@ h1 {
|
|
|
855
855
|
.primary-action:hover {
|
|
856
856
|
opacity: 0.92;
|
|
857
857
|
}
|
|
858
|
+
|
|
859
|
+
/* Unsupported-page notice (shown in the popup when the active tab can't host
|
|
860
|
+
the on-page overlay, e.g. chrome:// / New Tab / Web Store pages). */
|
|
861
|
+
.notice {
|
|
862
|
+
display: flex;
|
|
863
|
+
gap: 10px;
|
|
864
|
+
align-items: flex-start;
|
|
865
|
+
margin: 0 0 12px;
|
|
866
|
+
padding: 11px 12px;
|
|
867
|
+
border: 1px solid var(--border);
|
|
868
|
+
border-radius: var(--radius-sm);
|
|
869
|
+
background: var(--surface);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.notice-icon {
|
|
873
|
+
width: 18px;
|
|
874
|
+
height: 18px;
|
|
875
|
+
flex: none;
|
|
876
|
+
margin-top: 1px;
|
|
877
|
+
color: var(--status-warn);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.notice-title {
|
|
881
|
+
font-size: 12.5px;
|
|
882
|
+
font-weight: 600;
|
|
883
|
+
color: var(--fg);
|
|
884
|
+
margin-bottom: 2px;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
.notice-text {
|
|
888
|
+
font-size: 11.5px;
|
|
889
|
+
line-height: 1.45;
|
|
890
|
+
color: var(--fg-muted);
|
|
891
|
+
}
|
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export interface AudioCue {
|
|
17
|
-
/** Play the cue during the countdown once recording is about one second out. */
|
|
18
|
-
playCountdownCue(): Promise<void>;
|
|
19
17
|
/**
|
|
20
18
|
* Play the cue (bounded by a timeout) and wait a short settle so the tone
|
|
21
|
-
* sits just before capture rather than inside the recording.
|
|
22
|
-
*
|
|
19
|
+
* sits just before capture rather than inside the recording. Call this at the
|
|
20
|
+
* exact moment recording starts (right before the recorder/native capture is
|
|
21
|
+
* kicked off) so the chime lines up with the real start, not the countdown.
|
|
23
22
|
*/
|
|
24
23
|
playBeforeCapture(): Promise<void>;
|
|
25
24
|
cleanup(): void;
|
|
@@ -38,7 +37,6 @@ function wait(ms: number): Promise<void> {
|
|
|
38
37
|
|
|
39
38
|
/** A cue that does nothing — used when Web Audio is unavailable. */
|
|
40
39
|
const noopAudioCue: AudioCue = {
|
|
41
|
-
async playCountdownCue() {},
|
|
42
40
|
async playBeforeCapture() {},
|
|
43
41
|
cleanup() {},
|
|
44
42
|
};
|
|
@@ -68,19 +66,26 @@ async function playBeforeCapture(
|
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
/**
|
|
71
|
-
* Schedule the recording-start chime on an already-running context
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
69
|
+
* Schedule the recording-start chime on an already-running context.
|
|
70
|
+
*
|
|
71
|
+
* A warm, bell-like rising two-note "ding-dong" (G5 → C6, a perfect fourth)
|
|
72
|
+
* voiced on triangle oscillators for a rounder, less electronic timbre, with a
|
|
73
|
+
* quiet octave overtone (C7) sparkling on top of the second note. Each note has
|
|
74
|
+
* a soft 18ms attack and a long exponential decay so it rings out gently — a
|
|
75
|
+
* confident "you're rolling" confirmation that sounds nothing like a flat
|
|
76
|
+
* countdown blip. Kept under ~420ms so it still fits inside
|
|
77
|
+
* `CUE_PLAY_TIMEOUT_MS` and lands right as capture begins.
|
|
75
78
|
*/
|
|
76
79
|
function scheduleTone(ctx: AudioContext): Promise<void> {
|
|
77
80
|
return new Promise<void>((resolve) => {
|
|
78
81
|
const t0 = ctx.currentTime + 0.005;
|
|
79
82
|
const voices = [
|
|
80
|
-
|
|
81
|
-
{ freq:
|
|
82
|
-
{ freq:
|
|
83
|
-
|
|
83
|
+
// Rising perfect fourth: a gentle "ding … dong".
|
|
84
|
+
{ freq: 783.99, at: 0.0, dur: 0.26, peak: 0.07, type: "triangle" }, // G5
|
|
85
|
+
{ freq: 1046.5, at: 0.11, dur: 0.34, peak: 0.085, type: "triangle" }, // C6
|
|
86
|
+
// Faint octave overtone gives the second note a soft bell shimmer.
|
|
87
|
+
{ freq: 2093.0, at: 0.115, dur: 0.18, peak: 0.022, type: "sine" }, // C7
|
|
88
|
+
] as const;
|
|
84
89
|
|
|
85
90
|
let lastStop = t0;
|
|
86
91
|
for (const voice of voices) {
|
|
@@ -89,12 +94,13 @@ function scheduleTone(ctx: AudioContext): Promise<void> {
|
|
|
89
94
|
lastStop = Math.max(lastStop, stopAt);
|
|
90
95
|
|
|
91
96
|
const oscillator = ctx.createOscillator();
|
|
92
|
-
oscillator.type =
|
|
97
|
+
oscillator.type = voice.type;
|
|
93
98
|
oscillator.frequency.setValueAtTime(voice.freq, startAt);
|
|
94
99
|
|
|
95
100
|
const gain = ctx.createGain();
|
|
101
|
+
// Smooth attack/decay envelope — ramps avoid the click of a hard gate.
|
|
96
102
|
gain.gain.setValueAtTime(0.0001, startAt);
|
|
97
|
-
gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.
|
|
103
|
+
gain.gain.exponentialRampToValueAtTime(voice.peak, startAt + 0.018);
|
|
98
104
|
gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
|
|
99
105
|
|
|
100
106
|
oscillator.connect(gain);
|
|
@@ -165,7 +171,6 @@ export function createAudioCue(): AudioCue {
|
|
|
165
171
|
idleTimer = window.setTimeout(cleanup, CUE_IDLE_CLEANUP_MS);
|
|
166
172
|
|
|
167
173
|
return {
|
|
168
|
-
playCountdownCue: play,
|
|
169
174
|
playBeforeCapture: () => playBeforeCapture(play, cleanup),
|
|
170
175
|
cleanup,
|
|
171
176
|
};
|
|
@@ -1398,10 +1398,7 @@ async function selectRegionForRecording(): Promise<RegionCaptureRect> {
|
|
|
1398
1398
|
}
|
|
1399
1399
|
}
|
|
1400
1400
|
|
|
1401
|
-
function waitForCountdownEvent(
|
|
1402
|
-
timeoutMs = 4000,
|
|
1403
|
-
onOneSecond?: () => void,
|
|
1404
|
-
): Promise<void> {
|
|
1401
|
+
function waitForCountdownEvent(timeoutMs = 4000): Promise<void> {
|
|
1405
1402
|
return new Promise((resolve, reject) => {
|
|
1406
1403
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1407
1404
|
const unlistens: UnlistenFn[] = [];
|
|
@@ -1457,9 +1454,6 @@ function waitForCountdownEvent(
|
|
|
1457
1454
|
|
|
1458
1455
|
track(listen("clips:countdown-done", () => finish("done")));
|
|
1459
1456
|
track(listen("clips:countdown-cancel", () => finish("cancel")));
|
|
1460
|
-
if (onOneSecond) {
|
|
1461
|
-
track(listen("clips:countdown-one", () => onOneSecond()));
|
|
1462
|
-
}
|
|
1463
1457
|
|
|
1464
1458
|
timer = setTimeout(() => {
|
|
1465
1459
|
if (done) return;
|
|
@@ -1493,13 +1487,12 @@ async function showRegionRecordBorder(region: RegionCaptureRect | null) {
|
|
|
1493
1487
|
});
|
|
1494
1488
|
}
|
|
1495
1489
|
|
|
1496
|
-
async function runRecordingCountdown(wantsScreen: boolean
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
);
|
|
1490
|
+
async function runRecordingCountdown(wantsScreen: boolean) {
|
|
1491
|
+
// The recording-start chime is intentionally NOT played here. It fires from
|
|
1492
|
+
// `audioCue.playBeforeCapture()` at the real capture-start (right before the
|
|
1493
|
+
// recorder/native capture is kicked off) so the beep lines up with the moment
|
|
1494
|
+
// recording actually begins — not one second early on the countdown's "1".
|
|
1495
|
+
const countdownEvent = waitForCountdownEvent(COUNTDOWN_EVENT_TIMEOUT_MS);
|
|
1503
1496
|
await showRegionGuidesForRecording(wantsScreen);
|
|
1504
1497
|
try {
|
|
1505
1498
|
await invoke("show_countdown");
|
|
@@ -1680,7 +1673,7 @@ async function startNativeFullscreenRecording(
|
|
|
1680
1673
|
}).catch((err) => {
|
|
1681
1674
|
console.warn("[clips-recorder] mic warm failed:", err);
|
|
1682
1675
|
});
|
|
1683
|
-
const countdownPromise = runRecordingCountdown(true
|
|
1676
|
+
const countdownPromise = runRecordingCountdown(true);
|
|
1684
1677
|
if (localOnly) {
|
|
1685
1678
|
id = localFolderName;
|
|
1686
1679
|
await Promise.all([countdownPromise, warmMic(id)]);
|
|
@@ -2460,7 +2453,7 @@ async function startRecordingInner(
|
|
|
2460
2453
|
combined,
|
|
2461
2454
|
});
|
|
2462
2455
|
|
|
2463
|
-
const countdownPromise = runRecordingCountdown(wantsScreen
|
|
2456
|
+
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
2464
2457
|
const localExportPromise = prepareLocalRecordingExport(targets);
|
|
2465
2458
|
let localExport: Awaited<ReturnType<typeof prepareLocalRecordingExport>>;
|
|
2466
2459
|
try {
|
|
@@ -2636,7 +2629,7 @@ async function startRecordingInner(
|
|
|
2636
2629
|
console.log(
|
|
2637
2630
|
"[clips-recorder] invoking show_countdown + createServerRecording",
|
|
2638
2631
|
);
|
|
2639
|
-
const countdownPromise = runRecordingCountdown(wantsScreen
|
|
2632
|
+
const countdownPromise = runRecordingCountdown(wantsScreen);
|
|
2640
2633
|
console.time("[clips-recorder] createServerRecording duration");
|
|
2641
2634
|
const recordingPromise = createServerRecording(
|
|
2642
2635
|
params.serverUrl,
|