@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
@@ -76,8 +76,10 @@ const PILL_W_EXPANDED_MEETING_LOGICAL: u32 = 480;
76
76
  /// cursor is still nowhere near it.
77
77
  const PILL_H_LOGICAL: u32 = 60;
78
78
  const PILL_H_EXPANDED_LOGICAL: u32 = 340;
79
- /// Bottom margin from the screen edge, logical px. Granola uses ~24.
80
- const PILL_BOTTOM_MARGIN_LOGICAL: u32 = 24;
79
+ /// Bottom margin from the screen edge, logical px. 42 = the old 24px window
80
+ /// margin + the removed 18px shadow gutter, so the default capsule keeps the
81
+ /// exact on-screen spot it had before the native-shadow conversion.
82
+ const PILL_BOTTOM_MARGIN_LOGICAL: u32 = 42;
81
83
 
82
84
  /// Detached / "floating" mode dimensions — anchored top-right of the primary
83
85
  /// monitor when the user focuses another app. Smaller footprint so it
@@ -88,7 +90,6 @@ const PILL_DETACHED_TOP_MARGIN_LOGICAL: u32 = 24;
88
90
  const PILL_DETACHED_RIGHT_MARGIN_LOGICAL: u32 = 24;
89
91
  /// Gap between the visible capsule and the right screen edge, logical px.
90
92
  const PILL_RIGHT_MARGIN_LOGICAL: u32 = 28;
91
- const OVERLAY_SHADOW_GUTTER_LOGICAL: f64 = 18.0;
92
93
 
93
94
  #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
94
95
  #[serde(rename_all = "lowercase")]
@@ -104,17 +105,11 @@ fn scale_factor(app: &AppHandle) -> f64 {
104
105
  .unwrap_or(2.0)
105
106
  }
106
107
 
107
- fn overlay_shadow_gutter_physical(app: &AppHandle) -> u32 {
108
- (OVERLAY_SHADOW_GUTTER_LOGICAL * scale_factor(app).max(1.0)).round() as u32
109
- }
110
-
111
- /// Screen-edge margin for the *visible* capsule. The window is padded out with
112
- /// a transparent shadow gutter, so that gutter comes off the window margin —
113
- /// measured from the window frame the pill floats a whole gutter further from
114
- /// the edge than the constant says.
108
+ /// Screen-edge margin for the visible capsule, physical px. The window is
109
+ /// sized to the capsule exactly (native shadow, no transparent gutter), so
110
+ /// the margin applies straight to the window frame.
115
111
  fn edge_margin_physical(app: &AppHandle, logical: u32) -> i32 {
116
- let margin = (logical as f64 * scale_factor(app)) as i32;
117
- (margin - overlay_shadow_gutter_physical(app) as i32).max(0)
112
+ (logical as f64 * scale_factor(app)) as i32
118
113
  }
119
114
 
120
115
  /// Persist the last-known pill position so the next `show` re-opens at the
@@ -259,7 +254,10 @@ fn default_center_right(app: &AppHandle, w: u32, h: u32) -> (i32, i32) {
259
254
  (x, y)
260
255
  }
261
256
 
262
- fn pill_content_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
257
+ /// Window size (physical px). The window is sized to the visible capsule
258
+ /// exactly — elevation is the native NSWindow shadow (see the builder's
259
+ /// `shadow(true)`), so there is no transparent shadow gutter to add here.
260
+ fn pill_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
263
261
  let scale = scale_factor(app);
264
262
  let detached = PILL_DETACHED.load(Ordering::Relaxed);
265
263
  // Detached mode ignores the `expanded` flag — the floating pill is a
@@ -284,12 +282,6 @@ fn pill_content_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
284
282
  (w, h)
285
283
  }
286
284
 
287
- fn pill_size_physical(app: &AppHandle, expanded: bool) -> (u32, u32) {
288
- let (content_w, content_h) = pill_content_size_physical(app, expanded);
289
- let gutter = overlay_shadow_gutter_physical(app);
290
- (content_w + gutter * 2, content_h + gutter * 2)
291
- }
292
-
293
285
  /// Default top-right anchor (physical px) for detached mode.
294
286
  fn default_top_right(app: &AppHandle, w: u32, _h: u32) -> (i32, i32) {
295
287
  let top_margin = edge_margin_physical(app, PILL_DETACHED_TOP_MARGIN_LOGICAL);
@@ -441,7 +433,11 @@ pub async fn recording_pill_show(
441
433
  .always_on_top(true)
442
434
  .skip_taskbar(true)
443
435
  .resizable(false)
444
- .shadow(false)
436
+ // Native elevation: on a transparent window macOS computes the
437
+ // shadow from the drawn content's alpha, so the capsule gets a
438
+ // correctly rounded OS shadow with the window sized to the capsule
439
+ // exactly — no transparent CSS-shadow apron eating clicks.
440
+ .shadow(true)
445
441
  .visible(false)
446
442
  .focused(false)
447
443
  .accept_first_mouse(true)
@@ -472,9 +468,8 @@ pub async fn recording_pill_show(
472
468
 
473
469
  /// True when the global cursor sits inside the pill window's frame. Cursor and
474
470
  /// frame both come from Tauri (physical px, desktop top-left origin), so the
475
- /// test is a plain point-in-rect with no AppKit hop. The frame is inset by the
476
- /// transparent shadow gutter the renderer pads out, so the polled hover state
477
- /// matches the capsule the user actually sees.
471
+ /// test is a plain point-in-rect with no AppKit hop. The window is sized to
472
+ /// the capsule exactly, so the frame IS the capsule the user actually sees.
478
473
  fn cursor_inside_pill_frame(window: &WebviewWindow) -> bool {
479
474
  let (Ok(c), Ok(p), Ok(s)) = (
480
475
  window.cursor_position(),
@@ -483,12 +478,9 @@ fn cursor_inside_pill_frame(window: &WebviewWindow) -> bool {
483
478
  ) else {
484
479
  return false;
485
480
  };
486
- let gutter = overlay_shadow_gutter_physical(window.app_handle()) as i32;
487
- let left = p.x + gutter;
488
- let top = p.y + gutter;
489
- let right = p.x + s.width as i32 - gutter;
490
- let bottom = p.y + s.height as i32 - gutter;
491
- c.x >= left as f64 && c.x <= right as f64 && c.y >= top as f64 && c.y <= bottom as f64
481
+ let right = p.x + s.width as i32;
482
+ let bottom = p.y + s.height as i32;
483
+ c.x >= p.x as f64 && c.x <= right as f64 && c.y >= p.y as f64 && c.y <= bottom as f64
492
484
  }
493
485
 
494
486
  /// Start polling the cursor against the pill frame and emitting
@@ -119,6 +119,11 @@ pub(crate) fn rewind_clip_prepare(
119
119
  if state.0.lock().map_err(|error| error.to_string())?.is_some() {
120
120
  return Err("a Rewind-derived clip is already prepared or active".into());
121
121
  }
122
+ // The completion slot is take-once but never expires, and the recording
123
+ // pill drains it whenever a completion card opens. The native start path
124
+ // clears it per session; this one has to as well, or a Rewind take's card
125
+ // can consume the previous recording's result and show its URL.
126
+ native_screen::reset_native_upload_completion_state();
122
127
  let temporary_audio = if include_mic || include_system_audio {
123
128
  screen_memory::acquire_temporary_audio_consumer(
124
129
  &app,
@@ -1130,24 +1130,83 @@ pub(crate) fn release_temporary_audio_consumer(
1130
1130
  };
1131
1131
  end_graph_lease(app, &consumer.graph_lease_id);
1132
1132
 
1133
- let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1134
- if !config.enabled || config.paused {
1135
- return Ok(());
1136
- }
1137
- let desired = effective_capture_mode(app, config.capture_mode);
1138
- let needs_restore = state
1139
- .inner
1140
- .lock()
1141
- .map_err(|error| error.to_string())?
1142
- .active
1143
- .as_ref()
1144
- .is_some_and(|active| active.capture_mode != desired);
1145
- if needs_restore {
1146
- rotate_segment_inner(app, &config)?;
1133
+ // Restoring the configured capture mode is a full producer stop/start
1134
+ // (the SCK source contract changed) that measures ~2s. Never charge that
1135
+ // to the releasing caller — a clip cancel must return immediately — and
1136
+ // give re-demand a grace window: a cancel-then-retake re-acquires the
1137
+ // same audio sources within a couple of seconds, which turns the pending
1138
+ // restore into a no-op instead of two stop/start cycles.
1139
+ if capture_mode_restore_needed(app) {
1140
+ schedule_capture_mode_restore(app.clone());
1147
1141
  }
1148
1142
  Ok(())
1149
1143
  }
1150
1144
 
1145
+ /// How long released audio demand may keep the producer in its upgraded mode
1146
+ /// before the configured capture mode is restored. Long enough that a rapid
1147
+ /// cancel→retake reattaches to the still-running audio producer for free;
1148
+ /// short enough that the microphone stops being captured promptly after the
1149
+ /// last consumer lets go.
1150
+ const CAPTURE_MODE_RESTORE_GRACE: Duration = Duration::from_secs(5);
1151
+
1152
+ fn schedule_capture_mode_restore(app: AppHandle) {
1153
+ std::thread::spawn(move || {
1154
+ std::thread::sleep(CAPTURE_MODE_RESTORE_GRACE);
1155
+ let state = app.state::<ScreenMemoryState>();
1156
+ let _transition = match state.transition.lock() {
1157
+ Ok(guard) => guard,
1158
+ Err(error) => {
1159
+ record_error(&app, error.to_string());
1160
+ return;
1161
+ }
1162
+ };
1163
+ // Re-evaluate under the transition lock: new audio demand, a config
1164
+ // change, or a producer stop during the grace all make this a no-op,
1165
+ // so overlapping schedules can never double-reconfigure the producer.
1166
+ if !capture_mode_restore_needed(&app) {
1167
+ return;
1168
+ }
1169
+ let config =
1170
+ normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
1171
+ if let Err(error) = rotate_segment_inner(&app, &config) {
1172
+ record_error(&app, error);
1173
+ }
1174
+ });
1175
+ }
1176
+
1177
+ fn capture_mode_restore_needed(app: &AppHandle) -> bool {
1178
+ let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1179
+ let state = app.state::<ScreenMemoryState>();
1180
+ let Ok(runtime) = state.inner.lock() else {
1181
+ return false;
1182
+ };
1183
+ capture_mode_restore_decision(
1184
+ config.enabled,
1185
+ config.paused,
1186
+ runtime.active.as_ref().map(|active| active.capture_mode),
1187
+ config.capture_mode,
1188
+ runtime.temporary_audio_consumers.len(),
1189
+ )
1190
+ }
1191
+
1192
+ /// True only while the producer is running in a capture mode the current
1193
+ /// demand (persisted config plus live temporary-audio consumers) no longer
1194
+ /// asks for. Disabled/paused configs are owned by `sync_from_config`'s stop
1195
+ /// path, and a stopped producer has nothing to restore.
1196
+ fn capture_mode_restore_decision(
1197
+ enabled: bool,
1198
+ paused: bool,
1199
+ active_mode: Option<RewindCaptureMode>,
1200
+ base_mode: RewindCaptureMode,
1201
+ temporary_audio_consumers: usize,
1202
+ ) -> bool {
1203
+ if !enabled || paused {
1204
+ return false;
1205
+ }
1206
+ let desired = effective_mode(base_mode, temporary_audio_consumers);
1207
+ active_mode.is_some_and(|mode| mode != desired)
1208
+ }
1209
+
1151
1210
  fn rollback_temporary_audio_consumer(app: &AppHandle, owner_id: &str) {
1152
1211
  let consumer = app
1153
1212
  .state::<ScreenMemoryState>()
@@ -3782,6 +3841,67 @@ command = "keep"
3782
3841
  ));
3783
3842
  }
3784
3843
 
3844
+ #[test]
3845
+ fn deferred_capture_mode_restore_fires_only_for_a_stale_upgraded_producer() {
3846
+ // The clip's audio demand is gone and the producer still runs
3847
+ // upgraded: restore.
3848
+ assert!(capture_mode_restore_decision(
3849
+ true,
3850
+ false,
3851
+ Some(RewindCaptureMode::VisualsAudio),
3852
+ RewindCaptureMode::Visuals,
3853
+ 0
3854
+ ));
3855
+ // A retake (or a meeting) re-demanded audio during the grace window:
3856
+ // the pending restore must become a no-op, not a downgrade under a
3857
+ // live consumer.
3858
+ assert!(!capture_mode_restore_decision(
3859
+ true,
3860
+ false,
3861
+ Some(RewindCaptureMode::VisualsAudio),
3862
+ RewindCaptureMode::Visuals,
3863
+ 1
3864
+ ));
3865
+ // Producer already back at (or configured for) the desired mode.
3866
+ assert!(!capture_mode_restore_decision(
3867
+ true,
3868
+ false,
3869
+ Some(RewindCaptureMode::Visuals),
3870
+ RewindCaptureMode::Visuals,
3871
+ 0
3872
+ ));
3873
+ assert!(!capture_mode_restore_decision(
3874
+ true,
3875
+ false,
3876
+ Some(RewindCaptureMode::VisualsAudio),
3877
+ RewindCaptureMode::VisualsAudio,
3878
+ 0
3879
+ ));
3880
+ // Producer stopped during the grace window: nothing to restore.
3881
+ assert!(!capture_mode_restore_decision(
3882
+ true,
3883
+ false,
3884
+ None,
3885
+ RewindCaptureMode::Visuals,
3886
+ 0
3887
+ ));
3888
+ // Disabled/paused teardown is owned by sync_from_config's stop path.
3889
+ assert!(!capture_mode_restore_decision(
3890
+ false,
3891
+ false,
3892
+ Some(RewindCaptureMode::VisualsAudio),
3893
+ RewindCaptureMode::Visuals,
3894
+ 0
3895
+ ));
3896
+ assert!(!capture_mode_restore_decision(
3897
+ true,
3898
+ true,
3899
+ Some(RewindCaptureMode::VisualsAudio),
3900
+ RewindCaptureMode::Visuals,
3901
+ 0
3902
+ ));
3903
+ }
3904
+
3785
3905
  #[test]
3786
3906
  fn terminal_handoffs_do_not_retain_agent_reason_text() {
3787
3907
  let mut object = serde_json::json!({
@@ -11,6 +11,162 @@ use crate::tray_meetings::{build_meetings_section, handle_meeting_menu_click, Me
11
11
  use crate::util::{is_meeting_active, is_recording_active};
12
12
  use crate::TRAY_PNG;
13
13
 
14
+ /// A rounded stop square rendered at the same pixel size as the normal tray
15
+ /// icon, used as the menu-bar icon while a recording is live (CleanShot's
16
+ /// pattern: the status item becomes stop + elapsed time, and clicking it
17
+ /// stops the recording). Template-style: black with alpha, so macOS tints it
18
+ /// for menu-bar appearance.
19
+ fn stop_square_icon(w: u32, h: u32) -> tauri::image::Image<'static> {
20
+ let mut rgba = vec![0u8; (w * h * 4) as usize];
21
+ let side = ((w.min(h) as f32) * 0.62).round() as i32;
22
+ let x0 = (w as i32 - side) / 2;
23
+ let y0 = (h as i32 - side) / 2;
24
+ let radius = (side as f32 * 0.28).max(1.0);
25
+ let inside_rounded = |x: i32, y: i32| -> bool {
26
+ if x < x0 || y < y0 || x >= x0 + side || y >= y0 + side {
27
+ return false;
28
+ }
29
+ let rx = radius as i32;
30
+ let corners = [
31
+ (x0 + rx, y0 + rx),
32
+ (x0 + side - 1 - rx, y0 + rx),
33
+ (x0 + rx, y0 + side - 1 - rx),
34
+ (x0 + side - 1 - rx, y0 + side - 1 - rx),
35
+ ];
36
+ for (cx, cy) in corners {
37
+ let in_corner_box =
38
+ (x < x0 + rx || x > x0 + side - 1 - rx) && (y < y0 + rx || y > y0 + side - 1 - rx);
39
+ if in_corner_box {
40
+ let dx = (x - cx) as f32;
41
+ let dy = (y - cy) as f32;
42
+ let near = (x - cx).abs() <= rx && (y - cy).abs() <= rx;
43
+ if near {
44
+ return dx * dx + dy * dy <= radius * radius;
45
+ }
46
+ }
47
+ }
48
+ true
49
+ };
50
+ for y in 0..h as i32 {
51
+ for x in 0..w as i32 {
52
+ if inside_rounded(x, y) {
53
+ let idx = ((y as u32 * w + x as u32) * 4) as usize;
54
+ rgba[idx + 3] = 255;
55
+ }
56
+ }
57
+ }
58
+ tauri::image::Image::new_owned(rgba, w, h)
59
+ }
60
+
61
+ /// Whether the status item is in recording mode (stop square + timer).
62
+ /// Written ONLY by `tray_recording_status` — the pill is the single owner of
63
+ /// this state — plus the window-destroyed backstop in lib.rs.
64
+ static TRAY_RECORDING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
65
+ /// Millis timestamp of the last recording-mode write. The pill refreshes the
66
+ /// title every 500ms while live, so 2.5s of silence means the writer is gone
67
+ /// and the dead-man loop below clears the status item — a stale menu-bar
68
+ /// timer is structurally impossible, not just handled per code path.
69
+ static TRAY_LAST_WRITE_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
70
+ /// Icon image last applied to the status item: unset, app logo, or stop
71
+ /// square. Tracked separately from `TRAY_RECORDING` because it records what
72
+ /// the status item actually accepted, not what was requested.
73
+ const TRAY_ICON_MODE_UNSET: i8 = -1;
74
+ static TRAY_ICON_MODE: std::sync::atomic::AtomicI8 =
75
+ std::sync::atomic::AtomicI8::new(TRAY_ICON_MODE_UNSET);
76
+
77
+ fn tray_icon_mode(stored: i8) -> Option<bool> {
78
+ if stored == TRAY_ICON_MODE_UNSET {
79
+ None
80
+ } else {
81
+ Some(stored != 0)
82
+ }
83
+ }
84
+
85
+ /// Whether the status item's icon image has to be re-uploaded.
86
+ ///
87
+ /// `set_icon` replaces the NSStatusItem's image, and the pill refreshes the
88
+ /// title four times a second to tick the timer. Re-uploading the same image at
89
+ /// that cadence makes the menu-bar icon visibly flash, so the image is written
90
+ /// only on the first write and on an actual mode flip. The title still goes
91
+ /// out every call — it is the part that genuinely changes.
92
+ fn tray_icon_needs_write(previous: Option<bool>, next: bool) -> bool {
93
+ previous != Some(next)
94
+ }
95
+
96
+ fn now_ms() -> u64 {
97
+ std::time::SystemTime::now()
98
+ .duration_since(std::time::UNIX_EPOCH)
99
+ .map(|d| d.as_millis() as u64)
100
+ .unwrap_or(0)
101
+ }
102
+
103
+ pub fn tray_recording_active() -> bool {
104
+ TRAY_RECORDING.load(std::sync::atomic::Ordering::SeqCst)
105
+ }
106
+
107
+ fn apply_tray_mode(app: &tauri::AppHandle, active: bool, title: Option<String>) {
108
+ TRAY_RECORDING.store(active, std::sync::atomic::Ordering::SeqCst);
109
+ TRAY_LAST_WRITE_MS.store(now_ms(), std::sync::atomic::Ordering::SeqCst);
110
+ let Some(tray) = app.tray_by_id("main") else {
111
+ return;
112
+ };
113
+ let applied_icon_mode =
114
+ tray_icon_mode(TRAY_ICON_MODE.load(std::sync::atomic::Ordering::SeqCst));
115
+ if tray_icon_needs_write(applied_icon_mode, active) {
116
+ if let Ok(base) = tauri::image::Image::from_bytes(TRAY_PNG) {
117
+ let icon = if active {
118
+ stop_square_icon(base.width(), base.height())
119
+ } else {
120
+ base
121
+ };
122
+ // Recorded only once the status item accepts the image: a failed
123
+ // write must stay pending so the next call retries it, not look
124
+ // like the mode is already on screen.
125
+ match tray.set_icon(Some(icon)) {
126
+ Ok(()) => {
127
+ let _ = tray.set_icon_as_template(true);
128
+ TRAY_ICON_MODE.store(i8::from(active), std::sync::atomic::Ordering::SeqCst);
129
+ }
130
+ Err(err) => eprintln!("[clips-tray] status item icon update failed: {err}"),
131
+ }
132
+ }
133
+ }
134
+ // ALWAYS Some(...): tray-icon's macOS set_title is a silent no-op for
135
+ // None (its Some-only branch never clears the NSStatusItem text), so a
136
+ // reset must pass an empty string or the stale timer sticks forever.
137
+ #[cfg(target_os = "macos")]
138
+ let _ = tray.set_title(Some(if active {
139
+ title.unwrap_or_default()
140
+ } else {
141
+ String::new()
142
+ }));
143
+ #[cfg(not(target_os = "macos"))]
144
+ let _ = title;
145
+ }
146
+
147
+ /// Single writer for the status item's recording mode: the pill invokes this
148
+ /// with `active` mirroring its own visibility (capture live, not the
149
+ /// completion card) and a preformatted timer title. Everything the menu bar
150
+ /// shows about recording flows through here — no inference from recorder
151
+ /// events on the Rust side.
152
+ #[tauri::command]
153
+ pub async fn tray_recording_status(
154
+ app: tauri::AppHandle,
155
+ active: bool,
156
+ title: Option<String>,
157
+ ) -> Result<(), String> {
158
+ apply_tray_mode(&app, active, title);
159
+ Ok(())
160
+ }
161
+
162
+ /// Backstop for the one failure the pill cannot report: its window dying
163
+ /// while the tray still shows recording mode.
164
+ pub fn reset_tray_recording(app: &tauri::AppHandle) {
165
+ if tray_recording_active() {
166
+ apply_tray_mode(app, false, None);
167
+ }
168
+ }
169
+
14
170
  fn physical_tray_rect(rect: tauri::Rect) -> (i32, i32, i32, i32) {
15
171
  let (x, y) = match rect.position {
16
172
  tauri::Position::Physical(position) => (position.x, position.y),
@@ -306,10 +462,20 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
306
462
  active,
307
463
  meeting_active
308
464
  );
309
- if active && !meeting_active {
310
- // Opening Clips must never double as an implicit Stop.
311
- // Keep the recording alive and restore the parked popover;
312
- // its active-recording view exposes an explicit Stop button.
465
+ if tray_recording_active() && !meeting_active {
466
+ // The status item IS the stop button while recording — it
467
+ // shows a stop square and the elapsed time, so the click
468
+ // is an explicit Stop, not an implicit one. Route through
469
+ // the pill so its finishing hold and completion card run;
470
+ // if the pill window is somehow gone, stop directly.
471
+ if app.get_webview_window("toolbar").is_some() {
472
+ let _ = app.emit("clips:tray-stop-request", ());
473
+ } else {
474
+ let _ = app.emit("clips:recorder-stop", ());
475
+ }
476
+ } else if active && !meeting_active {
477
+ // Recording session exists but capture isn't live (setup,
478
+ // countdown, finishing) — restore the parked popover.
313
479
  force_show_popover(app);
314
480
  } else {
315
481
  toggle_popover(app);
@@ -326,6 +492,21 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
326
492
  // resource table. `set_menu` is atomic — replacing the entire menu
327
493
  // is the documented Tauri 2 way to update a tray (there's no
328
494
  // partial-update API for items).
495
+ // Dead-man switch for recording mode: see TRAY_LAST_WRITE_MS.
496
+ let deadman_handle = app.handle().clone();
497
+ tauri::async_runtime::spawn(async move {
498
+ loop {
499
+ tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
500
+ if tray_recording_active()
501
+ && now_ms()
502
+ .saturating_sub(TRAY_LAST_WRITE_MS.load(std::sync::atomic::Ordering::SeqCst))
503
+ > 2_500
504
+ {
505
+ apply_tray_mode(&deadman_handle, false, None);
506
+ }
507
+ }
508
+ });
509
+
329
510
  let app_handle = app.handle().clone();
330
511
  app.handle().listen("meetings:updated", move |event| {
331
512
  #[derive(serde::Deserialize)]
@@ -352,3 +533,51 @@ pub fn build_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
352
533
 
353
534
  Ok(())
354
535
  }
536
+
537
+ #[cfg(test)]
538
+ mod tests {
539
+ use super::{tray_icon_mode, tray_icon_needs_write, TRAY_ICON_MODE_UNSET};
540
+
541
+ #[test]
542
+ fn writes_the_icon_on_the_first_call() {
543
+ assert!(tray_icon_needs_write(None, false));
544
+ assert!(tray_icon_needs_write(None, true));
545
+ }
546
+
547
+ #[test]
548
+ fn skips_the_icon_when_the_mode_is_unchanged() {
549
+ // The pill re-invokes the status update four times a second to tick
550
+ // the timer; re-uploading the same image at that rate is the flash.
551
+ assert!(!tray_icon_needs_write(Some(true), true));
552
+ assert!(!tray_icon_needs_write(Some(false), false));
553
+ }
554
+
555
+ #[test]
556
+ fn writes_the_icon_on_a_mode_flip_in_either_direction() {
557
+ assert!(tray_icon_needs_write(Some(false), true));
558
+ assert!(tray_icon_needs_write(Some(true), false));
559
+ }
560
+
561
+ #[test]
562
+ fn ten_seconds_of_ticking_writes_the_icon_once() {
563
+ // The reported flash: the pill invokes the status update every 250ms
564
+ // to tick the timer, and every one of those used to re-upload the
565
+ // image. Ten seconds of recording is one icon write, not forty-one.
566
+ let mut applied: Option<bool> = None;
567
+ let mut writes = 0;
568
+ for _ in 0..41 {
569
+ if tray_icon_needs_write(applied, true) {
570
+ writes += 1;
571
+ applied = Some(true);
572
+ }
573
+ }
574
+ assert_eq!(writes, 1);
575
+ }
576
+
577
+ #[test]
578
+ fn reads_an_unset_mode_as_never_written() {
579
+ assert_eq!(tray_icon_mode(TRAY_ICON_MODE_UNSET), None);
580
+ assert_eq!(tray_icon_mode(0), Some(false));
581
+ assert_eq!(tray_icon_mode(1), Some(true));
582
+ }
583
+ }
@@ -13,7 +13,6 @@ use crate::state::{
13
13
  VoiceWakePopover,
14
14
  };
15
15
 
16
- const POPOVER_SHADOW_GUTTER_LOGICAL: f64 = 24.0;
17
16
  static OAUTH_WINDOW_COUNTER: AtomicU64 = AtomicU64::new(0);
18
17
  const POPOVER_DEFAULT_WIDTH_LOGICAL: f64 = 320.0;
19
18
  const POPOVER_DEFAULT_HEIGHT_LOGICAL: f64 = 520.0;
@@ -110,13 +109,17 @@ pub fn set_capture_included(window: &WebviewWindow) {
110
109
  }
111
110
 
112
111
  pub fn build_popover_window(app: &mut tauri::App) -> Result<WebviewWindow, tauri::Error> {
113
- let gutter = POPOVER_SHADOW_GUTTER_LOGICAL * 2.0;
114
112
  let app_handle = app.handle().clone();
113
+ // The window is sized to the visible panel EXACTLY — elevation comes from
114
+ // the native NSWindow shadow (shadow(true) below), which macOS derives
115
+ // from the drawn rounded panel's alpha. A transparent CSS-shadow apron is
116
+ // never used here: its invisible margin eats clicks and reads as dead
117
+ // space around the UI.
115
118
  WebviewWindowBuilder::new(app, "popover", WebviewUrl::App("index.html".into()))
116
119
  .title("Clips")
117
120
  .inner_size(
118
- POPOVER_DEFAULT_WIDTH_LOGICAL + gutter,
119
- POPOVER_DEFAULT_HEIGHT_LOGICAL + gutter,
121
+ POPOVER_DEFAULT_WIDTH_LOGICAL,
122
+ POPOVER_DEFAULT_HEIGHT_LOGICAL,
120
123
  )
121
124
  .position(2.0, 2.0)
122
125
  .resizable(false)
@@ -127,7 +130,7 @@ pub fn build_popover_window(app: &mut tauri::App) -> Result<WebviewWindow, tauri
127
130
  .skip_taskbar(true)
128
131
  .visible(false)
129
132
  .focused(true)
130
- .shadow(false)
133
+ .shadow(true)
131
134
  .accept_first_mouse(true)
132
135
  // Tauri does not create a native child for window.open by default.
133
136
  // Create it here with the opener's webview configuration so Google
@@ -4210,7 +4210,7 @@ export const editorChromeBridgeScript: string = `"use strict";
4210
4210
  return null;
4211
4211
  }
4212
4212
  function stopNativeInteraction(e) {
4213
- e.preventDefault();
4213
+ if (e.cancelable) e.preventDefault();
4214
4214
  e.stopPropagation();
4215
4215
  if (e.stopImmediatePropagation) e.stopImmediatePropagation();
4216
4216
  }
@@ -51,7 +51,7 @@ export const embeddedWheelBridgeScript: string = `"use strict";
51
51
  return number;
52
52
  }
53
53
  function stopNativeInteraction(e) {
54
- e.preventDefault();
54
+ if (e.cancelable) e.preventDefault();
55
55
  e.stopPropagation();
56
56
  if (e.stopImmediatePropagation) e.stopImmediatePropagation();
57
57
  }
@@ -72,7 +72,8 @@ export const embeddedWheelBridgeScript: string = `"use strict";
72
72
  }
73
73
  }
74
74
  function onWheel(e) {
75
- if (!wheelEnabled) return;
75
+ var zoomIntent = !!(e.ctrlKey || e.metaKey);
76
+ if (!wheelEnabled && !zoomIntent) return;
76
77
  stopNativeInteraction(e);
77
78
  postToParent({
78
79
  type: "embedded-canvas-wheel",
@@ -9,14 +9,20 @@ export const zoomBridgeScript: string = `"use strict";
9
9
  var target = document.documentElement || document.body || document;
10
10
  function onWheel(e) {
11
11
  if (!(e.ctrlKey || e.metaKey)) return;
12
- e.preventDefault();
12
+ if (e.cancelable) e.preventDefault();
13
13
  try {
14
14
  window.parent.postMessage(
15
15
  {
16
16
  type: "pinch-zoom-wheel",
17
17
  deltaY: e.deltaY,
18
18
  clientX: e.clientX,
19
- clientY: e.clientY
19
+ clientY: e.clientY,
20
+ // The parent classifies and scales from these three. Dropping the
21
+ // modifiers puts every pinch on the notch curve; dropping the mode
22
+ // makes a Firefox line tick read as 3px of travel.
23
+ deltaMode: e.deltaMode,
24
+ ctrlKey: !!e.ctrlKey,
25
+ metaKey: !!e.metaKey
20
26
  },
21
27
  "*"
22
28
  );