@agent-native/core 0.178.0 → 0.178.1-nightly-20260909221943

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 (88) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +1 -0
  3. package/corpus/templates/clips/DESIGN.md +10 -7
  4. package/corpus/templates/clips/actions/list-organization-state.ts +56 -3
  5. package/corpus/templates/clips/actions/list-recordings.ts +2 -0
  6. package/corpus/templates/clips/actions/search-dictations.ts +81 -0
  7. package/corpus/templates/clips/app/components/agent-destination-logos.tsx +50 -0
  8. package/corpus/templates/clips/app/components/bug-report/bug-report-dialog.tsx +60 -0
  9. package/corpus/templates/clips/app/components/bug-report/bug-report-form.tsx +206 -0
  10. package/corpus/templates/clips/app/components/clips-command-menu.tsx +653 -0
  11. package/corpus/templates/clips/app/components/library/empty-state.tsx +52 -17
  12. package/corpus/templates/clips/app/components/library/folder-card.tsx +34 -0
  13. package/corpus/templates/clips/app/components/library/folder-tree.tsx +109 -64
  14. package/corpus/templates/clips/app/components/library/library-action-hrefs.ts +27 -0
  15. package/corpus/templates/clips/app/components/library/library-grid.tsx +295 -103
  16. package/corpus/templates/clips/app/components/library/library-layout.tsx +381 -203
  17. package/corpus/templates/clips/app/components/library/library-primary-actions.tsx +48 -0
  18. package/corpus/templates/clips/app/components/library/page-header.tsx +48 -8
  19. package/corpus/templates/clips/app/components/library/recording-card.tsx +58 -55
  20. package/corpus/templates/clips/app/components/library/search-bar.tsx +5 -19
  21. package/corpus/templates/clips/app/components/library/sidebar-feedback-button.tsx +48 -0
  22. package/corpus/templates/clips/app/components/player/comments-panel.tsx +2 -2
  23. package/corpus/templates/clips/app/components/player/share-dialog.tsx +344 -260
  24. package/corpus/templates/clips/app/components/player/viewer-controls.tsx +2 -2
  25. package/corpus/templates/clips/app/components/recorder/camera-visualizer.tsx +0 -33
  26. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -1
  27. package/corpus/templates/clips/app/components/ui/tabs.tsx +1 -1
  28. package/corpus/templates/clips/app/hooks/use-command-search.ts +53 -0
  29. package/corpus/templates/clips/app/hooks/use-navigation-state.ts +32 -13
  30. package/corpus/templates/clips/app/hooks/use-upload-video-picker.tsx +1 -0
  31. package/corpus/templates/clips/app/i18n/en-US.ts +7 -0
  32. package/corpus/templates/clips/app/lib/agent-share.ts +16 -0
  33. package/corpus/templates/clips/app/lib/command-events.ts +7 -0
  34. package/corpus/templates/clips/app/root.tsx +6 -82
  35. package/corpus/templates/clips/app/routes/_app.dictate.tsx +25 -5
  36. package/corpus/templates/clips/app/routes/_app.library._index.tsx +2 -30
  37. package/corpus/templates/clips/app/routes/_app.library.folder.$folderId.tsx +13 -2
  38. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +4 -17
  39. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +111 -114
  40. package/corpus/templates/clips/app/routes/_app.spaces.$spaceId.folder.$folderId.tsx +25 -2
  41. package/corpus/templates/clips/app/routes/_app.spaces.$spaceId.tsx +14 -61
  42. package/corpus/templates/clips/app/routes/_app.spaces._index.tsx +34 -24
  43. package/corpus/templates/clips/app/routes/_app.trash.tsx +1 -1
  44. package/corpus/templates/clips/app/routes/_app.tsx +2 -4
  45. package/corpus/templates/clips/app/routes/bug-report.tsx +11 -189
  46. package/corpus/templates/clips/app/routes/share.$shareId.tsx +148 -123
  47. package/corpus/templates/clips/changelog/2026-09-08-comments-and-desktop-window-controls.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-09-08-global-command-search.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-09-09-agent-sidebar-fits-below-toolbar.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-09-09-desktop-switches-stay-clear-in-dark-mode.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-09-09-recording-agent-uses-global-panel.md +6 -0
  52. package/corpus/templates/clips/desktop/src/app.tsx +2 -2
  53. package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +5 -3
  54. package/corpus/templates/clips/desktop/src/components/SourceRow.tsx +1 -0
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +2 -5
  56. package/corpus/templates/clips/desktop/src/components/ui/switch.tsx +21 -3
  57. package/corpus/templates/clips/desktop/src/hooks/useMediaDevices.ts +72 -52
  58. package/corpus/templates/clips/desktop/src/styles.css +18 -19
  59. package/corpus/templates/clips/desktop/src/tailwind.css +3 -0
  60. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +18 -10
  61. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +3 -5
  62. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +14 -3
  63. package/corpus/templates/design/AGENTS.md +2 -1
  64. package/corpus/templates/design/actions/export-png.ts +119 -0
  65. package/corpus/templates/design/actions/take-design-screenshot.ts +47 -13
  66. package/corpus/templates/design/server/plugins/agent-chat.ts +3 -1
  67. package/corpus/templates/factory/actions/dispatch-factory-item.ts +51 -22
  68. package/corpus/templates/factory/actions/list-triage-items.ts +21 -11
  69. package/corpus/templates/factory/actions/poll-slack-channel.ts +7 -2
  70. package/corpus/templates/factory/changelog/2026-09-09-slack-feedback-keeps-claimed-markers-after-poll.md +6 -0
  71. package/corpus/templates/factory/changelog/2026-09-09-slack-feedback-preserves-started-claimed-threads.md +6 -0
  72. package/corpus/templates/factory/changelog/2026-09-09-slack-feedback-skips-claimed-parents.md +6 -0
  73. package/corpus/templates/factory/server/lib/factory-automation-config.ts +1 -1
  74. package/corpus/templates/factory/server/lib/slack-feedback-prompt.ts +47 -0
  75. package/corpus/templates/factory/server/plugins/agent-chat.ts +5 -2
  76. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +5 -4
  77. package/corpus/templates/factory/server/triage/review-skill-alignment.ts +12 -7
  78. package/corpus/templates/factory/server/triage/slack-review-window.ts +39 -0
  79. package/dist/client/CommandMenu.js +13 -1
  80. package/dist/client/analytics.js +10 -0
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  83. package/dist/notifications/routes.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +5 -5
  85. package/dist/vite/client.js +25 -7
  86. package/dist/vite/sentry-source-maps.d.ts +12 -0
  87. package/dist/vite/sentry-source-maps.js +105 -0
  88. package/package.json +2 -1
@@ -12,32 +12,50 @@ const switchVariants = cva(
12
12
  default: "h-[1.15rem] w-8",
13
13
  sm: "h-3.5 w-6",
14
14
  },
15
+ tone: {
16
+ default: "",
17
+ success:
18
+ "data-[state=checked]:border-success data-[state=checked]:bg-success data-[state=unchecked]:border-border",
19
+ },
15
20
  },
16
21
  defaultVariants: {
17
22
  size: "default",
23
+ tone: "default",
18
24
  },
19
25
  },
20
26
  );
21
27
 
22
28
  const switchThumbVariants = cva(
23
29
  "pointer-events-none block rounded-full bg-background shadow-sm ring-1 ring-foreground/20 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
30
+ {
31
+ variants: {
32
+ tone: {
33
+ default: "",
34
+ success:
35
+ "data-[state=checked]:bg-success-foreground dark:data-[state=checked]:bg-success-foreground",
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ tone: "default",
40
+ },
41
+ },
24
42
  );
25
43
 
26
44
  const Switch = React.forwardRef<
27
45
  React.ElementRef<typeof SwitchPrimitives.Root>,
28
46
  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
29
47
  VariantProps<typeof switchVariants>
30
- >(({ className, size = "default", ...props }, ref) => (
48
+ >(({ className, size = "default", tone = "default", ...props }, ref) => (
31
49
  <SwitchPrimitives.Root
32
50
  data-slot="switch"
33
51
  data-size={size}
34
- className={cn(switchVariants({ size, className }))}
52
+ className={cn(switchVariants({ size, tone, className }))}
35
53
  {...props}
36
54
  ref={ref}
37
55
  >
38
56
  <SwitchPrimitives.Thumb
39
57
  data-slot="switch-thumb"
40
- className={cn(switchThumbVariants())}
58
+ className={cn(switchThumbVariants({ tone }))}
41
59
  />
42
60
  </SwitchPrimitives.Root>
43
61
  ));
@@ -1,11 +1,4 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- type MutableRefObject,
8
- } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
2
 
10
3
  import {
11
4
  chooseFallbackAudioInput,
@@ -13,6 +6,7 @@ import {
13
6
  isSelectableAudioInputDevice,
14
7
  normalizedMediaDeviceId,
15
8
  } from "../../../shared/media-device-selection";
9
+ import { getAudioStreamWithFallback } from "../lib/media-capture-constraints";
16
10
  import {
17
11
  isHardCapturePermissionError,
18
12
  MACOS_CAPTURE_PERMISSION_MESSAGE,
@@ -24,15 +18,6 @@ const MIC_KEY = "clips:last-mic-id";
24
18
  const CAM_LABEL_KEY = "clips:last-camera-label";
25
19
  const MIC_LABEL_KEY = "clips:last-mic-label";
26
20
 
27
- function rawMicConstraints(deviceId?: string): MediaTrackConstraints {
28
- return {
29
- ...(deviceId ? { deviceId: { exact: deviceId } } : {}),
30
- echoCancellation: false,
31
- noiseSuppression: false,
32
- autoGainControl: false,
33
- };
34
- }
35
-
36
21
  function concreteMediaDeviceId(value: string | null | undefined): string {
37
22
  const id = normalizedMediaDeviceId(value);
38
23
  return id && !isPseudoMediaDeviceId(id) ? id : "";
@@ -43,14 +28,30 @@ function isSelectableMediaDevice(device: MediaDeviceInfo): boolean {
43
28
  }
44
29
 
45
30
  interface Props {
46
- // Mirrors whether the on-screen camera bubble owns the camera grant. While
47
- // it does, this page must never probe getUserMedia itself.
48
- bubbleActiveRef: MutableRefObject<boolean>;
31
+ microphoneEnabled: boolean;
49
32
  popoverVisible: boolean;
50
33
  setCameraError: (message: string | null) => void;
51
34
  setRecError: (message: string | null) => void;
52
35
  }
53
36
 
37
+ export async function refreshMicrophoneAccess(
38
+ selectedMicId: string,
39
+ selectedMicLabel: string,
40
+ ): Promise<{ deviceId: string; label: string }> {
41
+ const stream = await getAudioStreamWithFallback(
42
+ selectedMicId,
43
+ selectedMicLabel,
44
+ false,
45
+ );
46
+ const track = stream.getAudioTracks()[0] ?? null;
47
+ const result = {
48
+ deviceId: concreteMediaDeviceId(track?.getSettings().deviceId),
49
+ label: track?.label ?? "",
50
+ };
51
+ stream.getTracks().forEach((candidate) => candidate.stop());
52
+ return result;
53
+ }
54
+
54
55
  export interface MediaDevicesState {
55
56
  cameraId: string;
56
57
  setCameraId: (id: string) => void;
@@ -69,7 +70,7 @@ export interface MediaDevicesState {
69
70
  }
70
71
 
71
72
  export function useMediaDevices({
72
- bubbleActiveRef,
73
+ microphoneEnabled,
73
74
  popoverVisible,
74
75
  setCameraError,
75
76
  setRecError,
@@ -107,8 +108,8 @@ export function useMediaDevices({
107
108
 
108
109
  // ---- device enumeration -------------------------------------------------
109
110
  // WebKit only returns full device labels after getUserMedia() has granted
110
- // access once. Enumerating itself is safe; the unlock helper below is careful
111
- // to never touch the OS default input just to populate labels.
111
+ // access once. Enumerating itself is safe; the unlock helper below only
112
+ // acquires audio while the visible microphone control is enabled.
112
113
  const loadDevices = useCallback(async () => {
113
114
  try {
114
115
  if (!navigator.mediaDevices?.enumerateDevices) return;
@@ -155,6 +156,18 @@ export function useMediaDevices({
155
156
  };
156
157
  }, [loadDevices]);
157
158
 
159
+ const refreshSelectedMicrophone = useCallback(async () => {
160
+ const activeMic = await refreshMicrophoneAccess(selectedMicId, micLabel);
161
+ if (
162
+ selectedMicId &&
163
+ activeMic.deviceId &&
164
+ activeMic.deviceId !== selectedMicId
165
+ ) {
166
+ setMicId("");
167
+ setMicLabel("");
168
+ }
169
+ }, [micLabel, selectedMicId]);
170
+
158
171
  const unlockDeviceLabels = useCallback(async () => {
159
172
  // Audio-only probe to unlock mic labels. We INTENTIONALLY skip video —
160
173
  // the on-screen camera bubble window owns the camera, and probing
@@ -163,26 +176,18 @@ export function useMediaDevices({
163
176
  // WebViews in the same process). Camera-label text is low-value
164
177
  // anyway; most machines have one.
165
178
  //
166
- // Do not probe `audio: true` or WebKit's pseudo `default` device here.
167
- // On macOS that opens the system default input, which can shove
168
- // Bluetooth headphones into hands-free mode just from opening the Clips
169
- // popover. If the user has picked a concrete mic, use that exact device;
170
- // otherwise leave labels locked until a real user action needs access.
179
+ // The meter already needs an input while this control is visible. Reuse the
180
+ // recorder's fallback chain so a disconnected saved device cannot leave
181
+ // both the meter and the device list stranded until the user changes a
182
+ // selection. This may use the system default only after an exact saved
183
+ // device fails, or when the user has explicitly left the picker on Default.
171
184
  try {
172
- if (!selectedMicId) {
173
- await loadDevices();
174
- return;
175
- }
176
- const s = await navigator.mediaDevices.getUserMedia({
177
- audio: rawMicConstraints(selectedMicId),
178
- video: false,
179
- });
180
- s.getTracks().forEach((t) => t.stop());
185
+ await refreshSelectedMicrophone();
181
186
  } catch {
182
187
  // permission denied — labels stay empty until the user grants
183
188
  }
184
189
  await loadDevices();
185
- }, [loadDevices, selectedMicId]);
190
+ }, [loadDevices, refreshSelectedMicrophone]);
186
191
 
187
192
  const requestDeviceAccess = useCallback(
188
193
  async (kind: "camera" | "mic") => {
@@ -190,15 +195,15 @@ export function useMediaDevices({
190
195
  if (!navigator.mediaDevices?.getUserMedia) {
191
196
  throw new Error("Device selection is not available in this WebView.");
192
197
  }
193
- const stream = await navigator.mediaDevices.getUserMedia(
194
- kind === "camera"
195
- ? { video: true, audio: false }
196
- : {
197
- audio: rawMicConstraints(selectedMicId || undefined),
198
- video: false,
199
- },
200
- );
201
- stream.getTracks().forEach((track) => track.stop());
198
+ if (kind === "camera") {
199
+ const stream = await navigator.mediaDevices.getUserMedia({
200
+ video: true,
201
+ audio: false,
202
+ });
203
+ stream.getTracks().forEach((track) => track.stop());
204
+ } else {
205
+ await refreshSelectedMicrophone();
206
+ }
202
207
  await loadDevices();
203
208
  } catch (err) {
204
209
  const message = err instanceof Error ? err.message : String(err);
@@ -221,20 +226,35 @@ export function useMediaDevices({
221
226
  await loadDevices();
222
227
  }
223
228
  },
224
- [bubbleActiveRef, loadDevices, selectedMicId, setCameraError, setRecError],
229
+ [loadDevices, refreshSelectedMicrophone, setCameraError, setRecError],
225
230
  );
226
231
 
227
- // Defer device-label unlocking until the popover is first shown. Even the
232
+ // Defer device-label unlocking until each visible popover session. Even the
228
233
  // selected-device probe can trigger a macOS permission dialog, so keep it
229
- // attached to visible UI instead of firing on hidden webview mount.
234
+ // attached to visible UI instead of firing on hidden WebView mount.
230
235
  const deviceLabelsUnlocked = useRef(false);
231
236
  useEffect(() => {
232
237
  void loadDevices();
233
- if (popoverVisible && !deviceLabelsUnlocked.current) {
238
+ if (!popoverVisible || !microphoneEnabled) {
239
+ deviceLabelsUnlocked.current = false;
240
+ return;
241
+ }
242
+ if (!deviceLabelsUnlocked.current) {
234
243
  deviceLabelsUnlocked.current = true;
235
244
  void unlockDeviceLabels();
236
245
  }
237
- }, [loadDevices, unlockDeviceLabels, popoverVisible]);
246
+ }, [loadDevices, microphoneEnabled, popoverVisible, unlockDeviceLabels]);
247
+
248
+ // WebKit does not consistently emit `devicechange` inside a reused Tauri
249
+ // popover WebView. Keep the visible picker current without polling while the
250
+ // recorder UI is hidden.
251
+ useEffect(() => {
252
+ if (!popoverVisible) return;
253
+ const interval = window.setInterval(() => {
254
+ void loadDevices();
255
+ }, 2_000);
256
+ return () => window.clearInterval(interval);
257
+ }, [loadDevices, popoverVisible]);
238
258
 
239
259
  useEffect(() => {
240
260
  if (!micId || !isPseudoMediaDeviceId(micId) || mics.length === 0) return;
@@ -65,10 +65,8 @@
65
65
  box-sizing: border-box;
66
66
  }
67
67
 
68
- /* Tauri window is `transparent: true` + `decorations: false`, so the HTML
69
- paints the visible shape. The popover window is sized to the rounded panel
70
- exactly; macOS derives a native shadow from the panel's alpha, giving us
71
- the round-cornered chromeless popover with no transparent apron. */
68
+ /* The main Tauri window is transparent and chromeless, so the HTML paints the
69
+ visible rounded popover while macOS supplies its shadow. */
72
70
  html,
73
71
  body {
74
72
  margin: 0;
@@ -519,8 +517,11 @@ body[data-clips-route]:not([data-clips-route="popover"]) #root {
519
517
  background: hsl(var(--ui-muted) / 0.55);
520
518
  }
521
519
 
522
- .row-off .row-icon,
523
- .row-label-muted {
520
+ .row-on {
521
+ color: var(--fg);
522
+ }
523
+
524
+ .row-off {
524
525
  color: var(--fg-subtle);
525
526
  }
526
527
 
@@ -530,13 +531,15 @@ body[data-clips-route]:not([data-clips-route="popover"]) #root {
530
531
  display: inline-flex;
531
532
  align-items: center;
532
533
  justify-content: center;
533
- color: var(--fg-muted);
534
+ color: inherit;
534
535
  flex-shrink: 0;
535
536
  }
536
537
 
537
- .media-device-row .row {
538
+ .media-device-row .row,
539
+ .system-audio-row {
538
540
  display: grid;
539
541
  grid-template-columns: 20px minmax(0, 1fr) 36px;
542
+ column-gap: 10px;
540
543
  }
541
544
 
542
545
  .row-main {
@@ -551,12 +554,13 @@ body[data-clips-route]:not([data-clips-route="popover"]) #root {
551
554
  }
552
555
 
553
556
  .system-audio-row {
554
- display: grid;
555
- grid-template-columns: 20px minmax(0, 1fr) 36px;
556
557
  align-items: center;
557
- min-height: 36px;
558
- padding: 0 10px 0 0;
559
- color: var(--fg-muted);
558
+ min-height: 30px;
559
+ padding: 5px 10px;
560
+ }
561
+
562
+ .system-audio-icon {
563
+ transform: translateX(2px);
560
564
  }
561
565
 
562
566
  .system-audio-label {
@@ -567,21 +571,16 @@ body[data-clips-route]:not([data-clips-route="popover"]) #root {
567
571
  font-size: 13px;
568
572
  }
569
573
 
570
- .row-on .row-icon {
571
- color: var(--fg);
572
- }
573
-
574
574
  /* Radix device picker trigger — the menu owns keyboard and focus behavior. */
575
575
  .row-button {
576
576
  width: 100%;
577
577
  min-width: 0;
578
- min-width: 0;
579
578
  display: flex;
580
579
  align-items: center;
581
580
  gap: 6px;
582
581
  border: none;
583
582
  background: transparent;
584
- color: var(--fg);
583
+ color: inherit;
585
584
  font-size: 13px;
586
585
  font-weight: 400;
587
586
  padding: 0;
@@ -245,6 +245,7 @@
245
245
  --ui-ring: 0 0% 40%;
246
246
  /* macOS convention for an engaged switch, and the one accent the tray uses. */
247
247
  --ui-success: 142 71% 45%;
248
+ --ui-success-foreground: 0 0% 100%;
248
249
  /* Reserved for explicit "there is something new here" alerts and notices. */
249
250
  --ui-info: 217 91% 55%;
250
251
  }
@@ -271,6 +272,7 @@
271
272
  --ui-input: 0 0% 32%;
272
273
  --ui-ring: 0 0% 60%;
273
274
  --ui-success: 142 60% 52%;
275
+ --ui-success-foreground: 0 0% 100%;
274
276
  --ui-info: 217 91% 65%;
275
277
  }
276
278
  }
@@ -333,6 +335,7 @@
333
335
  --color-card-foreground: hsl(var(--ui-card-foreground));
334
336
 
335
337
  --color-success: hsl(var(--ui-success));
338
+ --color-success-foreground: hsl(var(--ui-success-foreground));
336
339
  --color-info: hsl(var(--ui-info));
337
340
 
338
341
  --radius-lg: var(--radius);
@@ -100,16 +100,24 @@ pub fn run() {
100
100
  _ => {}
101
101
  }
102
102
  }
103
- // The popover and camera bubble are separate native windows. If
104
- // the popover is closed before its webview emits the visibility
105
- // event, tear down the bubble from the native lifecycle instead.
106
- if window.label() == "popover"
107
- && matches!(
108
- event,
109
- tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
110
- )
111
- {
112
- clips::close_bubble_if_idle(window.app_handle());
103
+ // The popover and camera bubble are separate native windows. A
104
+ // native close hides the panel back to the tray instead of
105
+ // destroying the webview, so tray clicks and the app's second
106
+ // launch can show the same window again.
107
+ if window.label() == "popover" {
108
+ match event {
109
+ tauri::WindowEvent::CloseRequested { api, .. } => {
110
+ api.prevent_close();
111
+ let app = window.app_handle();
112
+ let _ = window.hide();
113
+ clips::close_bubble_if_idle(&app);
114
+ let _ = app.emit("clips:popover-visible", false);
115
+ }
116
+ tauri::WindowEvent::Destroyed => {
117
+ clips::close_bubble_if_idle(window.app_handle());
118
+ }
119
+ _ => {}
120
+ }
113
121
  }
114
122
  })
115
123
  .invoke_handler(tauri::generate_handler![
@@ -111,11 +111,9 @@ pub fn set_capture_included(window: &WebviewWindow) {
111
111
 
112
112
  pub fn build_popover_window(app: &mut tauri::App) -> Result<WebviewWindow, tauri::Error> {
113
113
  let app_handle = app.handle().clone();
114
- // The window is sized to the visible panel EXACTLY — elevation comes from
115
- // the native NSWindow shadow (shadow(true) below), which macOS derives
116
- // from the drawn rounded panel's alpha. A transparent CSS-shadow apron is
117
- // never used here: its invisible margin eats clicks and reads as dead
118
- // space around the UI.
114
+ // The window is sized to the visible panel exactly. The HTML paints the
115
+ // rounded shape and macOS derives the native shadow from that alpha mask,
116
+ // so adding native decorations would create a second frame above it.
119
117
  WebviewWindowBuilder::new(app, "popover", WebviewUrl::App("index.html".into()))
120
118
  .title("Clips")
121
119
  .inner_size(
@@ -20,9 +20,20 @@ How to export designs and generate handoff documentation for developers converti
20
20
  **This is not importable into Figma as editable vectors** — Figma cannot
21
21
  parse `foreignObject` content, so it stays an opaque embedded HTML blob.
22
22
  Use `export-design-as-figma-svg` (below) when the destination is Figma.
23
- - **PNG**: there is no PNG export action. Point the user to the editor's
24
- download menu (Download PNG) — PNG export is a client-side rasterization of
25
- the live canvas and is not exposed as an agent action.
23
+ - **PNG**: `export-png` renders one stored HTML screen in headless Chromium and
24
+ uploads the PNG through the configured file provider. Pass `fileId`, or
25
+ `designId` plus `filename`, to select the screen. It returns a durable `url`
26
+ and suggested `filename`; `width` defaults to 1440px and `height` only sets
27
+ the responsive viewport because the export includes the screen's full page.
28
+
29
+ ```bash
30
+ pnpm action export-png --designId <designId> --fileId <fileId>
31
+ ```
32
+
33
+ If Chromium or file storage is unavailable, the action returns an explicit
34
+ failure instead of pretending that a downloadable image exists. The editor's
35
+ Download PNG remains the faithful client-side path for localhost/fusion
36
+ screens that are not stored HTML.
26
37
  - **Deploy preview**: `deploy-design-preview` triggers a preview deploy for a
27
38
  fusion-backed design branch. It requires the design's source to advertise
28
39
  the `deployPreview` capability (fusion tier) and Builder.io to be connected;
@@ -40,7 +40,8 @@ Read the relevant skill before deeper work in that area.
40
40
  | `present-design-variants` | Generate 2-5 variants for the user to pick and refine |
41
41
  | `view-screen` | Re-read the current design or selected file when context is stale |
42
42
  | `navigate` | Move the UI to a design, file, or panel |
43
- | `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Hand off a finished design |
43
+ | `export-png` | Export screen as PNG |
44
+ | `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export a finished design |
44
45
 
45
46
  ## Core Rules
46
47
 
@@ -0,0 +1,119 @@
1
+ import { defineAction, fail } from "@agent-native/core/action";
2
+ import { z } from "zod";
3
+
4
+ import takeDesignScreenshot from "./take-design-screenshot.js";
5
+
6
+ function suggestedPngFilename(filename: string): string {
7
+ const base = filename
8
+ .replace(/\\/g, "/")
9
+ .split("/")
10
+ .pop()
11
+ ?.replace(/\.[^.]+$/, "")
12
+ .replace(/[^a-zA-Z0-9_-]/g, "-");
13
+ return `${base || "screen"}.png`;
14
+ }
15
+
16
+ export default defineAction({
17
+ description:
18
+ "Export one Design screen as a PNG image. Pass fileId, or pass designId " +
19
+ "and filename, to choose the screen. Returns a durable image URL; the " +
20
+ "screen is rendered at 1440px wide by default and full-page height.",
21
+ schema: z
22
+ .object({
23
+ designId: z
24
+ .string()
25
+ .optional()
26
+ .describe("Design project id. Required unless fileId is provided."),
27
+ fileId: z
28
+ .string()
29
+ .optional()
30
+ .describe(
31
+ "Specific design_files.id to export. Takes priority over designId and filename.",
32
+ ),
33
+ filename: z
34
+ .string()
35
+ .optional()
36
+ .default("index.html")
37
+ .describe(
38
+ "Screen filename when fileId is not provided. Defaults to index.html.",
39
+ ),
40
+ width: z.coerce
41
+ .number()
42
+ .int()
43
+ .min(200)
44
+ .max(3840)
45
+ .optional()
46
+ .describe("Render viewport width in pixels. Defaults to 1440."),
47
+ height: z.coerce
48
+ .number()
49
+ .int()
50
+ .min(200)
51
+ .max(4096)
52
+ .optional()
53
+ .describe(
54
+ "Optional viewport height in pixels. Full-page export is not cropped to this height.",
55
+ ),
56
+ })
57
+ .refine(({ designId, fileId }) => Boolean(designId || fileId), {
58
+ message: "Provide designId or fileId to select a screen.",
59
+ path: ["designId"],
60
+ }),
61
+ readOnly: false,
62
+ http: { method: "POST" },
63
+ run: async ({ designId, fileId, filename, width, height }, ctx) => {
64
+ const result = await takeDesignScreenshot.run(
65
+ {
66
+ ...(designId ? { designId } : {}),
67
+ ...(fileId ? { fileId } : {}),
68
+ filename,
69
+ widths: [width ?? 1440],
70
+ ...(height === undefined ? {} : { heights: [height] }),
71
+ },
72
+ ctx,
73
+ );
74
+
75
+ if (result.ok !== true) return result;
76
+
77
+ const screenshot = result.screenshots?.[0];
78
+ if (!screenshot) {
79
+ fail("The PNG renderer did not return an image.", {
80
+ errorCode: "png_export_empty",
81
+ });
82
+ }
83
+ if (!screenshot.url) {
84
+ if (screenshot.uploadError?.code === "file_upload_failed") {
85
+ fail(
86
+ "The PNG was rendered but its upload failed. Check the configured file storage provider and retry.",
87
+ {
88
+ errorCode: "file_upload_failed",
89
+ details: { designId: result.designId, fileId: result.fileId },
90
+ },
91
+ );
92
+ }
93
+ fail(
94
+ "The PNG was rendered but could not be returned because file storage is not configured. " +
95
+ "Connect or reconnect Builder.io in Settings → File uploads, or register a custom file provider.",
96
+ {
97
+ errorCode: "file_storage_not_configured",
98
+ details: { designId: result.designId, fileId: result.fileId },
99
+ },
100
+ );
101
+ }
102
+
103
+ const screenFilename = result.filename ?? filename ?? "screen.html";
104
+
105
+ return {
106
+ ok: true,
107
+ designId: result.designId,
108
+ fileId: result.fileId,
109
+ screenFilename,
110
+ filename: suggestedPngFilename(screenFilename),
111
+ mimeType: "image/png",
112
+ url: screenshot.url,
113
+ bytes: screenshot.bytes,
114
+ viewport: screenshot.viewport,
115
+ diagnostics: screenshot.diagnostics,
116
+ capturedAt: result.capturedAt,
117
+ };
118
+ },
119
+ });