@agent-native/core 0.91.0 → 0.91.2

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 (219) hide show
  1. package/blueprints/action/crud.md +1 -1
  2. package/blueprints/channel/discord.md +1 -1
  3. package/blueprints/provider/stripe.md +1 -1
  4. package/blueprints/sandbox/docker.md +1 -1
  5. package/corpus/README.md +1 -1
  6. package/corpus/core/CHANGELOG.md +26 -0
  7. package/corpus/core/blueprints/action/crud.md +1 -1
  8. package/corpus/core/blueprints/channel/discord.md +1 -1
  9. package/corpus/core/blueprints/provider/stripe.md +1 -1
  10. package/corpus/core/blueprints/sandbox/docker.md +1 -1
  11. package/corpus/core/package.json +6 -5
  12. package/corpus/core/src/agent/run-manager.ts +23 -2
  13. package/corpus/core/src/agent/run-store.ts +2 -2
  14. package/corpus/core/src/cli/add.ts +1 -1
  15. package/corpus/core/src/cli/create.ts +8 -3
  16. package/corpus/core/src/cli/design-connect.ts +53 -9
  17. package/corpus/core/src/cli/index.ts +5 -4
  18. package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
  19. package/corpus/core/src/client/session-replay.ts +97 -3
  20. package/corpus/core/src/jobs/scheduler.ts +30 -1
  21. package/corpus/core/src/localization/default-messages.ts +21 -0
  22. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
  23. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
  24. package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
  25. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
  26. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
  27. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
  28. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
  29. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
  30. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
  31. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
  32. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
  33. package/corpus/core/src/templates/default/package.json +2 -2
  34. package/corpus/core/src/templates/headless/package.json +3 -3
  35. package/corpus/core/src/templates/workspace-core/package.json +6 -6
  36. package/corpus/core/src/templates/workspace-root/package.json +2 -2
  37. package/corpus/core/src/triggers/dispatcher.ts +30 -2
  38. package/corpus/core/src/vite/client.ts +96 -1
  39. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -2
  40. package/corpus/templates/analytics/app/global.css +26 -10
  41. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  42. package/corpus/templates/analytics/app/i18n-data.ts +25 -0
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +548 -237
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
  45. package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
  46. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-clamps-ultra-wide-viewports-while-keeping-the.md +6 -0
  47. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
  50. package/corpus/templates/analytics/package.json +2 -2
  51. package/corpus/templates/assets/package.json +2 -2
  52. package/corpus/templates/brain/package.json +2 -2
  53. package/corpus/templates/calendar/AGENTS.md +10 -0
  54. package/corpus/templates/calendar/actions/create-event.ts +5 -1
  55. package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
  56. package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
  57. package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
  58. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
  59. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
  60. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
  61. package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
  62. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  63. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  64. package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
  65. package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
  66. package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
  67. package/corpus/templates/calendar/package.json +2 -2
  68. package/corpus/templates/calendar/server/handlers/events.ts +5 -1
  69. package/corpus/templates/calendar/shared/api.ts +6 -0
  70. package/corpus/templates/chat/package.json +2 -2
  71. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
  72. package/corpus/templates/clips/actions/create-meeting.ts +8 -3
  73. package/corpus/templates/clips/actions/list-meetings.ts +31 -3
  74. package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
  75. package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
  76. package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
  79. package/corpus/templates/clips/chrome-extension/package.json +2 -2
  80. package/corpus/templates/clips/desktop/package.json +2 -2
  81. package/corpus/templates/clips/desktop/src/app.tsx +91 -34
  82. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +15 -5
  83. package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
  84. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
  85. package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
  86. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +174 -50
  87. package/corpus/templates/clips/desktop/src/styles.css +128 -53
  88. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +364 -0
  89. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  90. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +5 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +91 -11
  92. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +69 -19
  93. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
  94. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
  95. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -24
  96. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
  97. package/corpus/templates/clips/package.json +2 -2
  98. package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
  99. package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
  100. package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
  101. package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
  102. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  103. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2584 -546
  104. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
  105. package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
  106. package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
  107. package/corpus/templates/content/app/i18n-data.ts +154 -0
  108. package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
  109. package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
  110. package/corpus/templates/content/package.json +2 -2
  111. package/corpus/templates/content/parity/matrix.md +1 -1
  112. package/corpus/templates/content/parity/matrix.ts +2 -0
  113. package/corpus/templates/content/shared/api.ts +24 -0
  114. package/corpus/templates/design/AGENTS.md +9 -0
  115. package/corpus/templates/design/actions/request-localhost-write-consent.ts +141 -0
  116. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -19
  117. package/corpus/templates/design/package.json +2 -2
  118. package/corpus/templates/design/server/lib/verify-write-grant.ts +4 -2
  119. package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
  120. package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
  121. package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
  122. package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
  123. package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
  124. package/corpus/templates/dispatch/package.json +2 -2
  125. package/corpus/templates/forms/package.json +2 -2
  126. package/corpus/templates/macros/package.json +2 -2
  127. package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
  128. package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
  129. package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
  130. package/corpus/templates/mail/package.json +2 -2
  131. package/corpus/templates/plan/package.json +2 -2
  132. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
  133. package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
  134. package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
  135. package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
  136. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  137. package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
  138. package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
  139. package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
  140. package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
  141. package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
  142. package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
  143. package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
  144. package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
  145. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
  146. package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
  147. package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
  148. package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
  149. package/corpus/templates/slides/package.json +2 -2
  150. package/dist/agent/run-manager.d.ts.map +1 -1
  151. package/dist/agent/run-manager.js +23 -2
  152. package/dist/agent/run-manager.js.map +1 -1
  153. package/dist/agent/run-store.js +2 -2
  154. package/dist/agent/run-store.js.map +1 -1
  155. package/dist/catalog.json +2 -2
  156. package/dist/cli/add.js +1 -1
  157. package/dist/cli/add.js.map +1 -1
  158. package/dist/cli/create.d.ts.map +1 -1
  159. package/dist/cli/create.js +8 -3
  160. package/dist/cli/create.js.map +1 -1
  161. package/dist/cli/design-connect.d.ts.map +1 -1
  162. package/dist/cli/design-connect.js +40 -10
  163. package/dist/cli/design-connect.js.map +1 -1
  164. package/dist/cli/index.js +5 -4
  165. package/dist/cli/index.js.map +1 -1
  166. package/dist/client/extensions/ExtensionSlot.js +6 -0
  167. package/dist/client/extensions/ExtensionSlot.js.map +1 -1
  168. package/dist/client/session-replay.d.ts +8 -0
  169. package/dist/client/session-replay.d.ts.map +1 -1
  170. package/dist/client/session-replay.js +80 -3
  171. package/dist/client/session-replay.js.map +1 -1
  172. package/dist/collab/routes.d.ts +1 -1
  173. package/dist/jobs/scheduler.js +26 -1
  174. package/dist/jobs/scheduler.js.map +1 -1
  175. package/dist/localization/default-messages.d.ts +19 -0
  176. package/dist/localization/default-messages.d.ts.map +1 -1
  177. package/dist/localization/default-messages.js +19 -0
  178. package/dist/localization/default-messages.js.map +1 -1
  179. package/dist/notifications/routes.d.ts +1 -1
  180. package/dist/observability/routes.d.ts +1 -1
  181. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  182. package/dist/resources/handlers.d.ts +1 -1
  183. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  184. package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
  185. package/dist/templates/default/app/i18n/de-DE.ts +21 -0
  186. package/dist/templates/default/app/i18n/en-US.ts +21 -0
  187. package/dist/templates/default/app/i18n/es-ES.ts +21 -0
  188. package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
  189. package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
  190. package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
  191. package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
  192. package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
  193. package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
  194. package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
  195. package/dist/templates/default/package.json +2 -2
  196. package/dist/templates/headless/package.json +3 -3
  197. package/dist/templates/workspace-core/package.json +6 -6
  198. package/dist/templates/workspace-root/package.json +2 -2
  199. package/dist/triggers/dispatcher.js +27 -2
  200. package/dist/triggers/dispatcher.js.map +1 -1
  201. package/dist/vite/client.d.ts.map +1 -1
  202. package/dist/vite/client.js +70 -1
  203. package/dist/vite/client.js.map +1 -1
  204. package/package.json +7 -6
  205. package/src/templates/default/app/i18n/ar-SA.ts +21 -0
  206. package/src/templates/default/app/i18n/de-DE.ts +21 -0
  207. package/src/templates/default/app/i18n/en-US.ts +21 -0
  208. package/src/templates/default/app/i18n/es-ES.ts +21 -0
  209. package/src/templates/default/app/i18n/fr-FR.ts +21 -0
  210. package/src/templates/default/app/i18n/hi-IN.ts +20 -0
  211. package/src/templates/default/app/i18n/ja-JP.ts +21 -0
  212. package/src/templates/default/app/i18n/ko-KR.ts +20 -0
  213. package/src/templates/default/app/i18n/pt-BR.ts +21 -0
  214. package/src/templates/default/app/i18n/zh-CN.ts +20 -0
  215. package/src/templates/default/app/i18n/zh-TW.ts +19 -0
  216. package/src/templates/default/package.json +2 -2
  217. package/src/templates/headless/package.json +3 -3
  218. package/src/templates/workspace-core/package.json +6 -6
  219. package/src/templates/workspace-root/package.json +2 -2
@@ -0,0 +1,364 @@
1
+ //! Granola-style adhoc Zoom / Teams detection.
2
+ //!
3
+ //! Polls the frontmost macOS app every few seconds. When Zoom or Teams stays
4
+ //! frontmost for a short dwell window, creates a meeting row via
5
+ //! `create-meeting` and shows the same meeting-notification overlay used for
6
+ //! calendar reminders — with `type: "adhoc"`.
7
+ //!
8
+ //! Reuses `MeetingsWatcherState` session (server URL + cookie + auth token)
9
+ //! so the popover only needs to push credentials once.
10
+
11
+ use std::collections::HashMap;
12
+ use std::sync::Mutex;
13
+ use std::time::{Duration, Instant};
14
+
15
+ use tauri::{AppHandle, Emitter, Manager};
16
+
17
+ use crate::config::{feature_config, MeetingTranscriptionMode};
18
+ use crate::dlog;
19
+ use crate::meetings_watcher::MeetingsWatcherState;
20
+
21
+ /// How often to sample the frontmost app.
22
+ const POLL_SECS: u64 = 4;
23
+
24
+ /// Require this many consecutive frontmost seconds before firing.
25
+ const DWELL_SECS: u64 = 9;
26
+
27
+ /// After dismiss / fire, suppress re-prompts for this platform until the
28
+ /// cooldown elapses (or the VC app leaves front — see tick logic).
29
+ const COOLDOWN_SECS: i64 = 30 * 60;
30
+
31
+ /// Soft guard: skip adhoc if a calendar reminder for the same platform fired
32
+ /// this recently.
33
+ const CALENDAR_SOFT_GUARD_SECS: i64 = 3 * 60;
34
+
35
+ const STRONG_VC_BUNDLES: &[(&str, &str, &str)] = &[
36
+ // (bundle_id, platform, display title)
37
+ ("us.zoom.xos", "zoom", "Zoom meeting detected"),
38
+ ("us.zoom.ZoomClips", "zoom", "Zoom meeting detected"),
39
+ ("com.microsoft.teams2", "teams", "Teams meeting detected"),
40
+ ("com.microsoft.teams", "teams", "Teams meeting detected"),
41
+ ];
42
+
43
+ #[derive(Default)]
44
+ pub struct AdhocMeetingsWatcherState {
45
+ inner: Mutex<AdhocMeetingsWatcherInner>,
46
+ }
47
+
48
+ #[derive(Default)]
49
+ struct AdhocMeetingsWatcherInner {
50
+ /// platform -> unix-seconds when we last fired (or dismissed via cooldown).
51
+ cooldown_until: HashMap<String, i64>,
52
+ /// Current dwell: which platform is accumulating frontmost time.
53
+ dwell_platform: Option<String>,
54
+ dwell_since: Option<Instant>,
55
+ /// Platforms already notified for the current continuous foreground session.
56
+ session_notified: HashMap<String, bool>,
57
+ }
58
+
59
+ /// Spawn the long-running adhoc watcher. Idempotent — gated by OnceLock.
60
+ pub fn spawn_watcher(app: AppHandle) {
61
+ use std::sync::OnceLock;
62
+ static STARTED: OnceLock<()> = OnceLock::new();
63
+ if STARTED.set(()).is_err() {
64
+ return;
65
+ }
66
+ tauri::async_runtime::spawn(async move {
67
+ run_watcher(app).await;
68
+ });
69
+ }
70
+
71
+ async fn run_watcher(app: AppHandle) {
72
+ let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
73
+ // Skip the first tick — give the frontend time to push session creds.
74
+ interval.tick().await;
75
+ let client = match reqwest::Client::builder()
76
+ .timeout(Duration::from_secs(10))
77
+ .build()
78
+ {
79
+ Ok(c) => c,
80
+ Err(err) => {
81
+ eprintln!("[clips-tray] adhoc_meetings_watcher: reqwest build failed: {err}");
82
+ return;
83
+ }
84
+ };
85
+ loop {
86
+ interval.tick().await;
87
+ if let Err(err) = tick_once(&app, &client).await {
88
+ eprintln!("[clips-tray] adhoc_meetings_watcher tick failed: {err}");
89
+ }
90
+ }
91
+ }
92
+
93
+ fn match_vc_bundle(bundle: &str) -> Option<(&'static str, &'static str)> {
94
+ STRONG_VC_BUNDLES
95
+ .iter()
96
+ .find(|(id, _, _)| *id == bundle)
97
+ .map(|(_, platform, title)| (*platform, *title))
98
+ }
99
+
100
+ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), String> {
101
+ let config = feature_config(app);
102
+ if !config.meetings_enabled {
103
+ reset_dwell(app);
104
+ return Ok(());
105
+ }
106
+
107
+ #[cfg(not(target_os = "macos"))]
108
+ {
109
+ let _ = (app, client);
110
+ return Ok(());
111
+ }
112
+
113
+ #[cfg(target_os = "macos")]
114
+ {
115
+ tick_macos(app, client, &config).await
116
+ }
117
+ }
118
+
119
+ #[cfg(target_os = "macos")]
120
+ async fn tick_macos(
121
+ app: &AppHandle,
122
+ client: &reqwest::Client,
123
+ config: &crate::config::FeatureConfig,
124
+ ) -> Result<(), String> {
125
+ let front = crate::util::frontmost_bundle_id();
126
+ let matched = front
127
+ .as_deref()
128
+ .and_then(match_vc_bundle);
129
+
130
+ let Some((platform, title)) = matched else {
131
+ // VC app left front — clear session dedupe so a later re-focus can
132
+ // fire again (cooldown still applies).
133
+ clear_session_if_left(app, None);
134
+ reset_dwell(app);
135
+ return Ok(());
136
+ };
137
+
138
+ clear_session_if_left(app, Some(platform));
139
+
140
+ // Skip while already transcribing a meeting.
141
+ if crate::util::is_meeting_active(app) {
142
+ reset_dwell(app);
143
+ return Ok(());
144
+ }
145
+
146
+ if config.meeting_transcription_mode == MeetingTranscriptionMode::Manual
147
+ && !config.show_meeting_widget_enabled
148
+ {
149
+ reset_dwell(app);
150
+ return Ok(());
151
+ }
152
+
153
+ // Soft guard against double-prompting after a calendar reminder.
154
+ if let Some(state) = app.try_state::<MeetingsWatcherState>() {
155
+ if state.recent_calendar_notify(platform, CALENDAR_SOFT_GUARD_SECS) {
156
+ dlog!(
157
+ "[clips-tray] adhoc skip: recent calendar notify for {}",
158
+ platform
159
+ );
160
+ reset_dwell(app);
161
+ return Ok(());
162
+ }
163
+ }
164
+
165
+ let now_ts = chrono::Utc::now().timestamp();
166
+ let should_fire = {
167
+ let state = app
168
+ .try_state::<AdhocMeetingsWatcherState>()
169
+ .ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
170
+ let mut g = state.inner.lock().map_err(|e| e.to_string())?;
171
+
172
+ // Prune expired cooldowns.
173
+ g.cooldown_until.retain(|_, until| *until > now_ts);
174
+
175
+ if g.cooldown_until.get(platform).copied().unwrap_or(0) > now_ts {
176
+ return Ok(());
177
+ }
178
+ if g.session_notified.get(platform).copied().unwrap_or(false) {
179
+ return Ok(());
180
+ }
181
+
182
+ match (g.dwell_platform.as_deref(), g.dwell_since) {
183
+ (Some(p), Some(since)) if p == platform => {
184
+ if since.elapsed() >= Duration::from_secs(DWELL_SECS) {
185
+ g.session_notified.insert(platform.to_string(), true);
186
+ g.cooldown_until
187
+ .insert(platform.to_string(), now_ts + COOLDOWN_SECS);
188
+ g.dwell_platform = None;
189
+ g.dwell_since = None;
190
+ true
191
+ } else {
192
+ false
193
+ }
194
+ }
195
+ _ => {
196
+ g.dwell_platform = Some(platform.to_string());
197
+ g.dwell_since = Some(Instant::now());
198
+ false
199
+ }
200
+ }
201
+ };
202
+
203
+ if !should_fire {
204
+ return Ok(());
205
+ }
206
+
207
+ dlog!(
208
+ "[clips-tray] adhoc dwell met for {} — creating meeting",
209
+ platform
210
+ );
211
+
212
+ let meeting_id = match create_adhoc_meeting(app, client, platform).await {
213
+ Ok(id) => id,
214
+ Err(err) => {
215
+ // Allow retry on next dwell if create failed.
216
+ if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
217
+ if let Ok(mut g) = state.inner.lock() {
218
+ g.session_notified.remove(platform);
219
+ g.cooldown_until.remove(platform);
220
+ }
221
+ }
222
+ return Err(err);
223
+ }
224
+ };
225
+
226
+ let auto_start = config.meeting_transcription_mode == MeetingTranscriptionMode::Auto;
227
+ let show_widget = config.show_meeting_widget_enabled
228
+ || config.meeting_transcription_mode == MeetingTranscriptionMode::Ask
229
+ || auto_start;
230
+
231
+ if show_widget {
232
+ let app_clone = app.clone();
233
+ let id_clone = meeting_id.clone();
234
+ let title_clone = title.to_string();
235
+ let platform_clone = platform.to_string();
236
+ let scheduled_start = chrono::Utc::now().to_rfc3339();
237
+ tauri::async_runtime::spawn(async move {
238
+ let _ = crate::notifications::notify_meeting_starting(
239
+ app_clone,
240
+ id_clone,
241
+ title_clone,
242
+ 0,
243
+ None,
244
+ Some(scheduled_start),
245
+ None,
246
+ Some(platform_clone),
247
+ Some(auto_start),
248
+ Some("adhoc".to_string()),
249
+ )
250
+ .await;
251
+ });
252
+ }
253
+
254
+ if auto_start {
255
+ let _ = app.emit(
256
+ "meetings:start-transcription",
257
+ serde_json::json!({
258
+ "meetingId": meeting_id,
259
+ "joinUrl": null,
260
+ "reason": "adhoc-auto",
261
+ }),
262
+ );
263
+ }
264
+
265
+ Ok(())
266
+ }
267
+
268
+ fn reset_dwell(app: &AppHandle) {
269
+ if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
270
+ if let Ok(mut g) = state.inner.lock() {
271
+ g.dwell_platform = None;
272
+ g.dwell_since = None;
273
+ }
274
+ }
275
+ }
276
+
277
+ /// When the frontmost VC platform changes (or clears), drop session_notified
278
+ /// for platforms that are no longer front. Keep `cooldown_until` so leaving
279
+ /// and re-focusing Zoom within the cooldown does not spam another popup.
280
+ fn clear_session_if_left(app: &AppHandle, current: Option<&str>) {
281
+ if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
282
+ if let Ok(mut g) = state.inner.lock() {
283
+ g.session_notified
284
+ .retain(|platform, _| current == Some(platform.as_str()));
285
+ }
286
+ }
287
+ }
288
+
289
+ async fn create_adhoc_meeting(
290
+ app: &AppHandle,
291
+ client: &reqwest::Client,
292
+ platform: &str,
293
+ ) -> Result<String, String> {
294
+ let session = app
295
+ .try_state::<MeetingsWatcherState>()
296
+ .map(|s| s.session_snapshot())
297
+ .unwrap_or_default();
298
+ let Some(server_url) = session.server_url else {
299
+ return Err("no server_url for create-meeting".to_string());
300
+ };
301
+
302
+ let url = format!("{}/_agent-native/actions/create-meeting", server_url);
303
+ let row_title = if platform == "zoom" {
304
+ "Zoom meeting"
305
+ } else {
306
+ "Teams meeting"
307
+ };
308
+ let body = serde_json::json!({
309
+ "title": row_title,
310
+ "platform": platform,
311
+ "source": "adhoc",
312
+ "scheduledStart": chrono::Utc::now().to_rfc3339(),
313
+ });
314
+
315
+ let mut req = client
316
+ .post(&url)
317
+ .header("Content-Type", "application/json")
318
+ .header("X-Request-Source", "clips-desktop")
319
+ .json(&body);
320
+ if let Some(c) = session.session_cookie.as_deref() {
321
+ req = req.header("Cookie", c);
322
+ }
323
+ if let Some(token) = session.auth_token.as_deref() {
324
+ req = req.bearer_auth(token);
325
+ }
326
+
327
+ let resp = req
328
+ .send()
329
+ .await
330
+ .map_err(|e| format!("create-meeting fetch: {e}"))?;
331
+ let status = resp.status();
332
+ if status == reqwest::StatusCode::UNAUTHORIZED {
333
+ let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
334
+ return Err("create-meeting http 401".to_string());
335
+ }
336
+ if !status.is_success() {
337
+ let text = resp.text().await.unwrap_or_default();
338
+ return Err(format!(
339
+ "create-meeting http {} — {}",
340
+ status,
341
+ text.chars().take(180).collect::<String>()
342
+ ));
343
+ }
344
+ let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
345
+ extract_meeting_id(&body).ok_or_else(|| {
346
+ format!(
347
+ "create-meeting response missing meeting id: {}",
348
+ body.to_string().chars().take(200).collect::<String>()
349
+ )
350
+ })
351
+ }
352
+
353
+ fn extract_meeting_id(body: &serde_json::Value) -> Option<String> {
354
+ // Framework wraps action returns as `{ result: { meeting, created } }`.
355
+ let meeting = body
356
+ .get("result")
357
+ .and_then(|r| r.get("meeting"))
358
+ .or_else(|| body.get("meeting"))
359
+ .or_else(|| body.get("result"));
360
+ meeting
361
+ .and_then(|m| m.get("id"))
362
+ .and_then(|id| id.as_str())
363
+ .map(|s| s.to_string())
364
+ }
@@ -56,7 +56,7 @@ const ONBOARDING_HEIGHT_LOGICAL: f64 = 640.0;
56
56
  /// launch — this matches Loom's out-of-the-box behavior.
57
57
  const BUBBLE_SIZE_SMALL: u32 = 360;
58
58
  const BUBBLE_SIZE_MEDIUM: u32 = 504;
59
- const POPOVER_SHADOW_GUTTER_LOGICAL: f64 = 12.0;
59
+ const POPOVER_SHADOW_GUTTER_LOGICAL: f64 = 24.0;
60
60
  const POPOVER_DEFAULT_WIDTH_LOGICAL: f64 = 360.0;
61
61
  const POPOVER_DEFAULT_HEIGHT_LOGICAL: f64 = 520.0;
62
62
  const OVERLAY_SHADOW_GUTTER_LOGICAL: f64 = 18.0;
@@ -5,6 +5,7 @@
5
5
  //! is served by the Vite-built React UI (see `../dist`).
6
6
 
7
7
  mod accessibility;
8
+ mod adhoc_meetings_watcher;
8
9
  mod clips;
9
10
  mod config;
10
11
  mod debug;
@@ -211,6 +212,7 @@ pub fn run() {
211
212
  .manage(native_screen::NativeFullscreenRecordingState::default())
212
213
  .manage(screen_memory::ScreenMemoryState::default())
213
214
  .manage(meetings_watcher::MeetingsWatcherState::default())
215
+ .manage(adhoc_meetings_watcher::AdhocMeetingsWatcherState::default())
214
216
  .manage(notifications::MeetingNotificationState::default())
215
217
  .manage(silence_detector::DetectorState::default())
216
218
  .setup(|app| {
@@ -260,6 +262,9 @@ pub fn run() {
260
262
  // server URL via `meetings_watcher_set_server_url` once the
261
263
  // popover boots.
262
264
  meetings_watcher::spawn_watcher(app.handle().clone());
265
+ // Granola-style adhoc Zoom/Teams detection — shares session
266
+ // credentials with the calendar watcher above.
267
+ adhoc_meetings_watcher::spawn_watcher(app.handle().clone());
263
268
 
264
269
  // Pre-download the Whisper model in the background so the first
265
270
  // meeting doesn't pay the ~142 MB download cost mid-call. Skipped
@@ -2,9 +2,9 @@
2
2
  //!
3
3
  //! Runs as a tokio task spawned from `lib.rs::run` setup. Every 30s it calls
4
4
  //! the backend's `list-meetings` action for the next handful of live Google
5
- //! Calendar meetings. For any meeting starting in the next 5 minutes we
6
- //! haven't already alerted on, we fire a native notification + the in-app
7
- //! banner overlay.
5
+ //! Calendar meetings. For any meeting in the Granola-style reminder window
6
+ //! (1 minute before start through 5 minutes after) that we haven't already
7
+ //! alerted on, we fire the in-app banner overlay.
8
8
  //!
9
9
  //! ## Wire-up (from the popover renderer)
10
10
  //!
@@ -39,8 +39,12 @@ use crate::tray_meetings::MeetingItem as TrayMeetingItem;
39
39
 
40
40
  const MEETING_POLL_LIMIT: u8 = 10;
41
41
 
42
- /// Notify when a meeting starts within this many seconds (the reminder lead).
43
- const NOTIFY_LEAD_SECS: i64 = 300;
42
+ /// Show the reminder starting this many seconds before meeting start.
43
+ const NOTIFY_LEAD_SECS: i64 = 60;
44
+
45
+ /// Keep reminding eligible (until dismissed / acted on) this many seconds
46
+ /// after the scheduled start. Overlay auto-hide mirrors this hold window.
47
+ const NOTIFY_HOLD_AFTER_START_SECS: i64 = 5 * 60;
44
48
 
45
49
  /// Forget de-dupe / snooze entries once a meeting's start is this far past, so
46
50
  /// the maps don't grow unbounded across a long-running session.
@@ -80,6 +84,53 @@ struct MeetingsWatcherInner {
80
84
  /// meetingId -> unix-seconds deadline. While now < deadline the meeting is
81
85
  /// skipped; once it passes we re-fire the reminder exactly once.
82
86
  snoozed_until: HashMap<String, i64>,
87
+ /// platform -> unix-seconds when a calendar reminder last fired. Soft
88
+ /// guard so adhoc Zoom/Teams detection doesn't double-prompt right after
89
+ /// a calendar banner for the same app.
90
+ last_calendar_notify_at: HashMap<String, i64>,
91
+ }
92
+
93
+ /// Snapshot of auth fields the adhoc watcher needs to POST create-meeting.
94
+ #[derive(Clone, Default)]
95
+ pub struct MeetingsSessionSnapshot {
96
+ pub server_url: Option<String>,
97
+ pub session_cookie: Option<String>,
98
+ pub auth_token: Option<String>,
99
+ }
100
+
101
+ impl MeetingsWatcherState {
102
+ pub fn session_snapshot(&self) -> MeetingsSessionSnapshot {
103
+ let Ok(g) = self.inner.lock() else {
104
+ return MeetingsSessionSnapshot::default();
105
+ };
106
+ MeetingsSessionSnapshot {
107
+ server_url: g.server_url.clone(),
108
+ session_cookie: g.session_cookie.clone(),
109
+ auth_token: g.auth_token.clone(),
110
+ }
111
+ }
112
+
113
+ pub fn note_calendar_notify(&self, platform: Option<&str>) {
114
+ let Some(platform) = platform.map(str::trim).filter(|p| !p.is_empty()) else {
115
+ return;
116
+ };
117
+ if let Ok(mut g) = self.inner.lock() {
118
+ g.last_calendar_notify_at
119
+ .insert(platform.to_lowercase(), chrono::Utc::now().timestamp());
120
+ }
121
+ }
122
+
123
+ /// True if a calendar reminder for `platform` fired within `within_secs`.
124
+ pub fn recent_calendar_notify(&self, platform: &str, within_secs: i64) -> bool {
125
+ let Ok(g) = self.inner.lock() else {
126
+ return false;
127
+ };
128
+ let key = platform.to_lowercase();
129
+ let Some(at) = g.last_calendar_notify_at.get(&key).copied() else {
130
+ return false;
131
+ };
132
+ chrono::Utc::now().timestamp() - at <= within_secs
133
+ }
83
134
  }
84
135
 
85
136
  #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -88,8 +139,12 @@ struct MeetingItem {
88
139
  title: Option<String>,
89
140
  #[serde(default, alias = "scheduledStart")]
90
141
  scheduled_start: Option<String>,
142
+ #[serde(default, alias = "scheduledEnd")]
143
+ scheduled_end: Option<String>,
91
144
  #[serde(default, alias = "joinUrl")]
92
145
  join_url: Option<String>,
146
+ #[serde(default)]
147
+ platform: Option<String>,
93
148
  }
94
149
 
95
150
  #[derive(Debug, Deserialize)]
@@ -232,9 +287,19 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
232
287
 
233
288
  let url = format!("{}/_agent-native/actions/list-meetings", server_url);
234
289
  let limit = MEETING_POLL_LIMIT.to_string();
235
- let mut req = client
236
- .get(&url)
237
- .query(&[("view", "upcoming"), ("limit", limit.as_str())]);
290
+ // Include meetings that started within the hold window so a late-open
291
+ // desktop still surfaces the reminder until 5 minutes after start.
292
+ let within_min = ((NOTIFY_LEAD_SECS + NOTIFY_HOLD_AFTER_START_SECS) / 60
293
+ + 1)
294
+ .to_string();
295
+ let mut req = client.get(&url).query(&[
296
+ ("view", "upcoming"),
297
+ ("limit", limit.as_str()),
298
+ ("upcomingWithinMin", within_min.as_str()),
299
+ // list-meetings also uses this for the lower bound when we widen the
300
+ // upcoming window to include recently-started events (see action).
301
+ ("includeStartedWithinMin", "5"),
302
+ ]);
238
303
  req = req.header("X-Request-Source", "clips-desktop");
239
304
  if let Some(c) = cookie.as_deref() {
240
305
  req = req.header("Cookie", c);
@@ -301,14 +366,19 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
301
366
  g.snoozed_until
302
367
  .retain(|_, until| *until > now_ts - STALE_AFTER_SECS);
303
368
 
369
+ // Eligible from 1 min before start through 5 min after start.
370
+ // secs_until > 0 => still upcoming; negative => already started.
371
+ let in_window = secs_until <= NOTIFY_LEAD_SECS
372
+ && secs_until >= -NOTIFY_HOLD_AFTER_START_SECS;
373
+
304
374
  let eligible = match g.snoozed_until.get(&m.id).copied() {
305
375
  Some(until) if now_ts < until => false, // still snoozed
306
376
  Some(_) => {
307
- // Snooze elapsed — re-fire now regardless of the lead window.
377
+ // Snooze elapsed — re-fire if still inside the hold window.
308
378
  g.snoozed_until.remove(&m.id);
309
- true
379
+ in_window
310
380
  }
311
- None => (0..=NOTIFY_LEAD_SECS).contains(&secs_until),
381
+ None => in_window,
312
382
  };
313
383
 
314
384
  if !eligible {
@@ -333,10 +403,16 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
333
403
  if config.show_meeting_widget_enabled
334
404
  || config.meeting_transcription_mode == MeetingTranscriptionMode::Auto
335
405
  {
406
+ if let Some(state) = app.try_state::<MeetingsWatcherState>() {
407
+ state.note_calendar_notify(m.platform.as_deref());
408
+ }
336
409
  let app_clone = app.clone();
337
410
  let id_clone = m.id.clone();
338
411
  let title_clone = title.clone();
339
412
  let join_clone = join_url.clone();
413
+ let start_clone = m.scheduled_start.clone();
414
+ let end_clone = m.scheduled_end.clone();
415
+ let platform_clone = m.platform.clone();
340
416
  let auto_start = config.meeting_transcription_mode == MeetingTranscriptionMode::Auto;
341
417
  tauri::async_runtime::spawn(async move {
342
418
  let _ = crate::notifications::notify_meeting_starting(
@@ -345,7 +421,11 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
345
421
  title_clone,
346
422
  secs_until,
347
423
  join_clone,
424
+ start_clone,
425
+ end_clone,
426
+ platform_clone,
348
427
  Some(auto_start),
428
+ None,
349
429
  )
350
430
  .await;
351
431
  });