feat(x): ambient meeting detection + take-notes popup (Granola parity phase 2)

- mic-monitor Swift helper: polls CoreAudio for mic-in-use, with owning
  PIDs on macOS 14.4+ (process-object API) so calls attribute to the app
  that actually holds the mic (Chrome vs Zoom vs Slack); no TCC permission
  needed. Compiled best-effort by bundle.mjs; exits when stdin closes
- core meetings/detector: 5s mic debounce, PID→process attribution with
  running-app fallback, Granola labels (Huddle/Call/Meeting detected),
  calendar merge (event start −15min through end), once per mic session
  (30s idle reset), suppressed while Rowboat itself captures (recording
  or assistant voice call), gated by a meeting_detection notification
  category (Settings > Notifications)
- popup: lean Granola-style bar (title + platform + Take notes pill,
  floating ×) in a transparent non-activating panel at screen-saver level
  with fullscreen-auxiliary behavior — floats over fullscreen meetings on
  the cursor's display; activation policy restored immediately so the
  Dock icon survives and Take Notes can foreground Rowboat
- Take Notes routes into the existing take-meeting-notes flow with a
  synthetic or matched calendar event (source: detected); never records
  without a click
- tray icon bumped to 18pt
This commit is contained in:
Gagan 2026-07-13 21:50:39 +05:30
parent 9b0776596d
commit 66865b4f6b
13 changed files with 942 additions and 12 deletions

View file

@ -734,6 +734,9 @@ const ipcSchemas = {
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
// in addition to triggering the take-notes flow.
openMeeting: z.boolean().optional(),
// Origin recorded in the note frontmatter: 'calendar-sync' (default) for
// notification/deep-link starts, 'detected' for ambient meeting detection.
source: z.string().optional(),
}),
res: z.null(),
},
@ -783,6 +786,48 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Renderer → main: assistant voice/video call holds the mic — suppresses
// ambient meeting detection (it would otherwise see our own capture).
'voice:setCallActive': {
req: z.object({
active: z.boolean(),
}),
res: z.object({
success: z.literal(true),
}),
},
// --- Ambient meeting detection popup (own always-on-top window) ---
// Main → popup: the detection to display.
'meetingDetect:payload': {
req: z.object({
title: z.string(),
message: z.string(),
// Calendar-linked detections render a solid accent bar; ad-hoc ones a
// dashed one (Granola's affordance).
hasCalendarEvent: z.boolean(),
}),
res: z.null(),
},
// Popup → main: fetch the payload (the push can race listener registration).
'meetingDetect:getPayload': {
req: z.null(),
res: z.object({
payload: z
.object({
title: z.string(),
message: z.string(),
hasCalendarEvent: z.boolean(),
})
.nullable(),
}),
},
// Popup → main: user clicked a button.
'meetingDetect:action': {
req: z.object({
action: z.enum(['take-notes', 'dismiss']),
}),
res: z.object({}),
},
'granola:getConfig': {
req: z.null(),
res: z.object({

View file

@ -7,12 +7,14 @@ import { z } from 'zod';
* - new_email: a new email arrived during incremental Gmail sync
* - agent_permission: an agent is requesting permission to run a tool
* - background_task: a background task agent pinged via the notify-user tool
* - meeting_detection: popup when Rowboat detects you're in a call/meeting
*/
export const NotificationCategorySchema = z.enum([
'chat_completion',
'new_email',
'agent_permission',
'background_task',
'meeting_detection',
]);
export const NotificationCategoriesSchema = z.object({
@ -20,6 +22,7 @@ export const NotificationCategoriesSchema = z.object({
new_email: z.boolean(),
agent_permission: z.boolean(),
background_task: z.boolean(),
meeting_detection: z.boolean(),
});
export const NotificationSettingsSchema = z.object({
@ -32,6 +35,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
new_email: true,
agent_permission: true,
background_task: true,
meeting_detection: true,
},
};