diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index f7bbeff4..96717409 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -24,30 +24,19 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null } export function dispatchDeepLink(url: string): void { - console.log(`[deeplink] dispatch ${url}`); - if (!url.startsWith(URL_PREFIX)) { - console.log(`[deeplink] rejected: bad prefix`); - return; - } + if (!url.startsWith(URL_PREFIX)) return; pendingUrl = url; const win = mainWindowRef; - if (!win || win.isDestroyed()) { - console.log(`[deeplink] no window, buffered`); - return; - } + if (!win || win.isDestroyed()) return; if (win.isMinimized()) win.restore(); win.show(); win.focus(); - if (win.webContents.isLoading()) { - console.log(`[deeplink] window loading, buffered`); - return; - } + if (win.webContents.isLoading()) return; - console.log(`[deeplink] sending app:openUrl to renderer`); win.webContents.send("app:openUrl", { url }); pendingUrl = null; } 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 4b41e1ce..75a33c21 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -6,38 +6,38 @@ const HTTP_URL = /^https?:\/\//i; const ROWBOAT_URL = /^rowboat:\/\//i; export class ElectronNotificationService implements INotificationService { + // Holds strong references to active Notification instances so the GC can't + // collect them while they're still visible — without this, the click handler + // gets dropped and macOS clicks just focus the app silently. + private active = new Set(); + isSupported(): boolean { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link, actionLabel }: NotifyInput): void { + notify({ title = "Rowboat", message, link }: NotifyInput): void { const notification = new Notification({ title, body: message, - // Action button is only meaningful when there's something to open. - // macOS shows the first action inline (Banner) or all (Alert). - actions: link ? [{ type: "button", text: actionLabel?.trim() || "Open" }] : [], }); - const handleAction = (source: string) => { - console.log(`[notification] ${source} fired, link=${link ?? ''}`); + this.active.add(notification); + const release = () => { this.active.delete(notification); }; + + notification.on("click", () => { if (link && ROWBOAT_URL.test(link)) { dispatchDeepLink(link); - return; - } - if (link && HTTP_URL.test(link)) { + } else if (link && HTTP_URL.test(link)) { shell.openExternal(link).catch((err) => { console.error("[notification] failed to open link:", err); }); - return; + } else { + this.focusMainWindow(); } - this.focusMainWindow(); - }; - - // Both events route through the same handler — body click on macOS is - // less reliable than action-button click, but we want either to work. - notification.on("click", () => handleAction("click")); - notification.on("action", () => handleAction("action")); + release(); + }); + notification.on("close", release); + notification.on("failed", release); notification.show(); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index eed5a034..e42b3342 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3097,14 +3097,11 @@ function App() { useEffect(() => { const handle = (url: string) => { const view = parseDeepLink(url) - console.log('[deeplink renderer] received', url, '→ view', view) if (view) void navigateToViewRef.current(view) } void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => { - console.log('[deeplink renderer] mount drain:', url) if (url) handle(url) }) - console.log('[deeplink renderer] listener registered') return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) diff --git a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts index 6dbbd1e2..9bc619be 100644 --- a/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts @@ -14,15 +14,10 @@ Triggers a native macOS notification. The call returns immediately; it does not ### Parameters - **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top. - **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines. -- **\`link\`** (optional) — URL to open when the user clicks the notification or its action button. Two kinds accepted: +- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted: - **\`https://...\` / \`http://...\`** — opens in the default browser - **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below) - If omitted, clicking the notification focuses the Rowboat app. -- **\`actionLabel\`** (optional, defaults to \`"Open"\`) — label for the inline action button. Only shown when \`link\` is set. Keep it to 1-2 words: \`"Open"\`, \`"View"\`, \`"Read"\`, \`"Reply"\`. Pick a verb that names what clicking will do. - -### Why the action button matters - -When \`link\` is set, an action button is shown inline on the notification (the same way Slack shows "Reply" or Mail shows "Mark as Read"). This button is **the recommended click target** — it's a clear CTA and it's more reliable than expecting the user to click the notification body. Body click also works as a fallback. ### Examples @@ -43,7 +38,7 @@ External link: } \`\`\` -Deep link into a Rowboat note (default "Open" button): +Deep link into a Rowboat note: \`\`\`json { "message": "Daily brief is ready", @@ -51,16 +46,6 @@ Deep link into a Rowboat note (default "Open" button): } \`\`\` -Custom action label: -\`\`\`json -{ - "title": "Stripe charge declined", - "message": "Card ending 4242 — retry from the dashboard", - "link": "https://dashboard.stripe.com/payments/pi_abc", - "actionLabel": "Review" -} -\`\`\` - ## Deep links: \`rowboat://\` Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters. 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 81270472..7bf6bde1 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,6 @@ 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("Label for the action button shown when `link` is set. Defaults to 'Open'. Keep it short — 1-2 words like 'Open', 'View', 'Read', 'Reply'. Ignored when no link is provided."), }), isAvailable: async () => { try { @@ -1533,13 +1532,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 }: { title?: string; message: 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 }); 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..5e596853 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -2,7 +2,6 @@ export interface NotifyInput { title?: string; message: string; link?: string; - actionLabel?: string; } export interface INotificationService {