From 0107dc5dbf14df3aef12a58d5d298427e90b38cf Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:44:40 +0530 Subject: [PATCH] option to join meeting and take notes --- apps/x/apps/main/src/deeplink.ts | 21 ++++---- .../electron-notification-service.ts | 53 +++++++++++++------ apps/x/apps/renderer/src/App.tsx | 14 +++-- .../core/src/application/lib/builtin-tools.ts | 12 +++-- .../src/application/notification/service.ts | 1 + .../src/knowledge/notify_calendar_meetings.ts | 16 ++++-- apps/x/packages/shared/src/ipc.ts | 3 ++ 7 files changed, 84 insertions(+), 36 deletions(-) diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index 6dd828cb..605990d1 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -54,12 +54,12 @@ export function dispatchDeepLink(url: string): void { pendingUrl = null; } -interface TakeMeetingNotesAction { - type: "take-meeting-notes"; +interface MeetingNotesAction { + type: "take-meeting-notes" | "join-and-take-meeting-notes"; eventId: string; } -type ParsedAction = TakeMeetingNotesAction; +type ParsedAction = MeetingNotesAction; function parseAction(url: string): ParsedAction | null { if (!url.startsWith(URL_PREFIX)) return null; @@ -69,7 +69,7 @@ function parseAction(url: string): ParsedAction | null { if (host !== ACTION_HOST) return null; const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); const type = params.get("type"); - if (type === "take-meeting-notes") { + if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") { const eventId = params.get("eventId"); return eventId ? { type, eventId } : null; } @@ -80,12 +80,11 @@ async function dispatchAction(url: string): Promise { const parsed = parseAction(url); if (!parsed) return; - if (parsed.type === "take-meeting-notes") { - await handleTakeMeetingNotes(parsed.eventId); - } + const openMeeting = parsed.type === "join-and-take-meeting-notes"; + await handleTakeMeetingNotes(parsed.eventId, openMeeting); } -async function handleTakeMeetingNotes(eventId: string): Promise { +async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise { const win = mainWindowRef; if (!win || win.isDestroyed()) return; focusWindow(win); @@ -100,14 +99,16 @@ async function handleTakeMeetingNotes(eventId: string): Promise { return; } + const payload = { event, openMeeting }; + if (win.webContents.isLoading()) { win.webContents.once("did-finish-load", () => { - win.webContents.send("app:takeMeetingNotes", { event }); + win.webContents.send("app:takeMeetingNotes", payload); }); return; } - win.webContents.send("app:takeMeetingNotes", { event }); + win.webContents.send("app:takeMeetingNotes", payload); } function focusWindow(win: BrowserWindow): void { diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index e9c8145b..dd37e37d 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -15,25 +15,39 @@ export class ElectronNotificationService implements INotificationService { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void { + // Build the actions array AND a parallel index → link map. + // macOS shows actions[0] inline (Banner) or all of them (Alert); + // additional ones live behind the chevron menu. + const actionDefs: Electron.NotificationConstructorOptions["actions"] = []; + const actionLinks: string[] = []; + + const primaryLabel = actionLabel?.trim(); + if (link && primaryLabel) { + actionDefs!.push({ type: "button", text: primaryLabel }); + actionLinks.push(link); + } + if (secondaryActions) { + for (const sa of secondaryActions) { + actionDefs!.push({ type: "button", text: sa.label }); + actionLinks.push(sa.link); + } + } + const notification = new Notification({ title, body: message, - // Action button is only meaningful when there's a link to drive it. - // macOS shows the first action inline (Banner) or behind chevron (Alert). - actions: link && actionLabel?.trim() - ? [{ type: "button", text: actionLabel.trim() }] - : [], + actions: actionDefs, }); this.active.add(notification); const release = () => { this.active.delete(notification); }; - const handleClick = () => { - if (link && ROWBOAT_URL.test(link)) { - dispatchUrl(link); - } else if (link && HTTP_URL.test(link)) { - shell.openExternal(link).catch((err) => { + const openLink = (target: string | undefined) => { + if (target && ROWBOAT_URL.test(target)) { + dispatchUrl(target); + } else if (target && HTTP_URL.test(target)) { + shell.openExternal(target).catch((err) => { console.error("[notification] failed to open link:", err); }); } else { @@ -42,11 +56,18 @@ export class ElectronNotificationService implements INotificationService { release(); }; - // Both events route through the same handler. Body click on macOS is - // unreliable when actions are defined, but we register both so either - // one (whichever fires) drives the same behavior. - notification.on("click", handleClick); - notification.on("action", handleClick); + // Body click: always opens the primary `link` (or focuses the app if none). + notification.on("click", () => openLink(link)); + + // Action button click: dispatch by index into the actions array. + notification.on("action", (_event, index) => { + if (index >= 0 && index < actionLinks.length) { + openLink(actionLinks[index]); + } else { + openLink(undefined); + } + }); + notification.on("close", release); notification.on("failed", release); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 8587111a..3e8634a0 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3107,8 +3107,9 @@ function App() { // Triggered by main when the user clicks a calendar-meeting notification. // Reuses the same flow as the in-app "Join meeting & take notes" button. + // When `openMeeting` is true, also opens the meeting URL in the system browser. useEffect(() => { - return window.ipc.on('app:takeMeetingNotes', ({ event }) => { + return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => { const e = event as { summary?: string start?: { dateTime?: string; date?: string; timeZone?: string } @@ -3119,8 +3120,15 @@ function App() { conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } } if (!e || typeof e !== 'object') return - const conferenceLink = e.hangoutLink - || e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri + // Order matches extractConferenceLink in calendar-block.tsx: + // entryPoints first (covers Zoom/integrated providers), then hangoutLink (Google Meet shortcut). + const conferenceLink = e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri + || e.hangoutLink + if (openMeeting && conferenceLink) { + window.open(conferenceLink, '_blank') + } else if (openMeeting) { + console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e) + } window.__pendingCalendarEvent = { summary: e.summary, start: e.start, diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 67209b6c..6a1b1eb6 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1524,7 +1524,13 @@ export const BuiltinTools: z.infer = { link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { message: "link must be an http(s):// or rowboat:// URL", }).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."), - actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body. Note: macOS may render the button inline (Banner) or behind a chevron (Alert) depending on the user's notification style for Rowboat."), + actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."), + secondaryActions: z.array(z.object({ + label: z.string().min(1).max(30), + link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), { + message: "secondary action link must be an http(s):// or rowboat:// URL", + }), + })).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."), }), isAvailable: async () => { try { @@ -1533,13 +1539,13 @@ export const BuiltinTools: z.infer = { return false; } }, - execute: async ({ title, message, link, actionLabel }: { title?: string; message: string; link?: string; actionLabel?: string }) => { + execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => { try { const service = container.resolve('notificationService'); if (!service.isSupported()) { return { success: false, error: 'Notifications are not supported on this system' }; } - service.notify({ title, message, link, actionLabel }); + service.notify({ title, message, link, actionLabel, secondaryActions }); return { success: true }; } catch (error) { return { diff --git a/apps/x/packages/core/src/application/notification/service.ts b/apps/x/packages/core/src/application/notification/service.ts index a90c8e13..195315b1 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -3,6 +3,7 @@ export interface NotifyInput { message: string; link?: string; actionLabel?: string; + secondaryActions?: Array<{ label: string; link: string }>; } export interface INotificationService { diff --git a/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts index 60e84ded..3b32a271 100644 --- a/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts +++ b/apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts @@ -121,14 +121,22 @@ async function tick(state: NotificationState): Promise<{ state: NotificationStat if (msUntilStart < -NOTIFY_GRACE_MS) continue; const summary = event.summary?.trim() || "Untitled meeting"; - const link = `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(eventId)}`; + const eid = encodeURIComponent(eventId); try { service.notify({ title: "Upcoming meeting", - message: `${summary} starts in 1 minute. Click to take notes.`, - link, - actionLabel: "Take Notes", + message: `${summary} starts in 1 minute. Click to join and take notes.`, + // Primary (body click + first button): join the meeting AND take notes. + link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`, + actionLabel: "Join meeting and take notes", + // Behind the chevron: just take notes (no join). + secondaryActions: [ + { + label: "Take notes", + link: `rowboat://action?type=take-meeting-notes&eventId=${eid}`, + }, + ], }); console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`); } catch (err) { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 86f53316..2ddc54ff 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -302,6 +302,9 @@ const ipcSchemas = { req: z.object({ // Pass the raw calendar event JSON through; renderer adapts to its existing flow. event: z.unknown(), + // 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(), }), res: z.null(), },