diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index 96717409..f7bbeff4 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -24,19 +24,30 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null } export function dispatchDeepLink(url: string): void { - if (!url.startsWith(URL_PREFIX)) return; + console.log(`[deeplink] dispatch ${url}`); + if (!url.startsWith(URL_PREFIX)) { + console.log(`[deeplink] rejected: bad prefix`); + return; + } pendingUrl = url; const win = mainWindowRef; - if (!win || win.isDestroyed()) return; + if (!win || win.isDestroyed()) { + console.log(`[deeplink] no window, buffered`); + return; + } if (win.isMinimized()) win.restore(); win.show(); win.focus(); - if (win.webContents.isLoading()) return; + if (win.webContents.isLoading()) { + console.log(`[deeplink] window loading, buffered`); + 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 04f28449..4b41e1ce 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -1,20 +1,30 @@ import { BrowserWindow, Notification, shell } from "electron"; import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js"; +import { dispatchDeepLink } from "../deeplink.js"; const HTTP_URL = /^https?:\/\//i; +const ROWBOAT_URL = /^rowboat:\/\//i; export class ElectronNotificationService implements INotificationService { isSupported(): boolean { return Notification.isSupported(); } - notify({ title = "Rowboat", message, link }: NotifyInput): void { + notify({ title = "Rowboat", message, link, actionLabel }: 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" }] : [], }); - notification.on("click", () => { + const handleAction = (source: string) => { + console.log(`[notification] ${source} fired, link=${link ?? ''}`); + if (link && ROWBOAT_URL.test(link)) { + dispatchDeepLink(link); + return; + } if (link && HTTP_URL.test(link)) { shell.openExternal(link).catch((err) => { console.error("[notification] failed to open link:", err); @@ -22,7 +32,12 @@ export class ElectronNotificationService implements INotificationService { return; } 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")); notification.show(); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 413ae4ae..eed5a034 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3087,19 +3087,26 @@ function App() { void navigateToView({ type: 'file', path }) }, [navigateToView]) - const handleDeepLinkUrl = useCallback((url: string) => { - const view = parseDeepLink(url) - if (view) void navigateToView(view) - }, [navigateToView]) + // Deep-link handler kept in a ref so the useEffect below can register the + // IPC listener (and run the one-time pending-link drain) just once on mount, + // rather than re-running on every navigation when navigateToView's identity + // changes. + const navigateToViewRef = useRef(navigateToView) + useEffect(() => { navigateToViewRef.current = navigateToView }, [navigateToView]) 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 }) => { - if (url) handleDeepLinkUrl(url) + console.log('[deeplink renderer] mount drain:', url) + if (url) handle(url) }) - return window.ipc.on('app:openUrl', ({ url }) => { - handleDeepLinkUrl(url) - }) - }, [handleDeepLinkUrl]) + console.log('[deeplink renderer] listener registered') + return window.ipc.on('app:openUrl', ({ url }) => handle(url)) + }, []) const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => { setBaseConfigByPath((prev) => ({ ...prev, [path]: config })) diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index af2d7a20..a455d845 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -85,6 +85,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. **Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane. +**Notifications:** When you need to send a desktop notification — completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view — load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it. + ## Learning About the User (save-to-memory) diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index cad23177..6d3cdc5b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -14,6 +14,7 @@ import appNavigationSkill from "./app-navigation/skill.js"; import browserControlSkill from "./browser-control/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js"; import tracksSkill from "./tracks/skill.js"; +import notifyUserSkill from "./notify-user/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; @@ -112,6 +113,12 @@ const definitions: SkillDefinition[] = [ summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.", content: browserControlSkill, }, + { + id: "notify-user", + title: "Notify User", + summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.", + content: notifyUserSkill, + }, ]; const skillEntries = definitions.map((definition) => ({ 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 new file mode 100644 index 00000000..6dbbd1e2 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/notify-user/skill.ts @@ -0,0 +1,85 @@ +export const skill = String.raw` +# Notify User + +Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result. + +## When to use +- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive. +- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you). + +## The tool: \`notify-user\` + +Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click. + +### 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: + - **\`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 + +Plain alert (no link — clicking focuses the app): +\`\`\`json +{ + "title": "Backup complete", + "message": "All 142 files synced to iCloud." +} +\`\`\` + +External link: +\`\`\`json +{ + "title": "New email from Monica", + "message": "Re: Q4 planning — needs your input by Friday", + "link": "https://mail.google.com/mail/u/0/#inbox/abc123" +} +\`\`\` + +Deep link into a Rowboat note (default "Open" button): +\`\`\`json +{ + "message": "Daily brief is ready", + "link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md" +} +\`\`\` + +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. + +| Target | Format | Example | +|---|---|---| +| Open a file | \`rowboat://open?type=file&path=\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` | +| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=\`) | \`rowboat://open?type=chat&runId=abc123\` | +| Knowledge graph | \`rowboat://open?type=graph\` | — | +| Background task view | \`rowboat://open?type=task&name=\` | \`rowboat://open?type=task&name=daily-brief\` | +| Suggested topics | \`rowboat://open?type=suggested-topics\` | — | + +The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`). + +## Anti-patterns +- **Don't notify per step** of a multi-step task. Notify on completion, not on progress. +- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification. +- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link. +- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done". +`; + +export default skill; 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 f390ddae..81270472 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1521,9 +1521,10 @@ export const BuiltinTools: z.infer = { inputSchema: z.object({ title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."), message: z.string().min(1).describe("Body text of the notification."), - link: z.string().url().refine((v) => /^https?:\/\//i.test(v), { - message: "link must be an http:// or https:// URL", - }).optional().describe("Optional http(s) URL opened when the user clicks the notification."), + 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 { @@ -1532,13 +1533,13 @@ export const BuiltinTools: z.infer = { return false; } }, - execute: async ({ title, message, link }: { title?: string; message: string; link?: string }) => { + execute: async ({ title, message, link, actionLabel }: { title?: string; message: string; link?: string; actionLabel?: 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 }); + service.notify({ title, message, link, actionLabel }); 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 5e596853..a90c8e13 100644 --- a/apps/x/packages/core/src/application/notification/service.ts +++ b/apps/x/packages/core/src/application/notification/service.ts @@ -2,6 +2,7 @@ export interface NotifyInput { title?: string; message: string; link?: string; + actionLabel?: string; } export interface INotificationService { diff --git a/apps/x/packages/core/src/knowledge/track/run-agent.ts b/apps/x/packages/core/src/knowledge/track/run-agent.ts index d93366f3..685305b2 100644 --- a/apps/x/packages/core/src/knowledge/track/run-agent.ts +++ b/apps/x/packages/core/src/knowledge/track/run-agent.ts @@ -263,6 +263,7 @@ You have the full workspace toolkit. Quick reference for common cases: - **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files. - **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized. - **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. +- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view). # The Knowledge Graph