added button for links

This commit is contained in:
Arjun 2026-04-25 11:12:57 +05:30
parent 9fa2d1d5e3
commit 8cb55a903d
9 changed files with 150 additions and 20 deletions

View file

@ -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;
}

View file

@ -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 ?? '<none>'}`);
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();
}

View file

@ -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 }))

View file

@ -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)

View file

@ -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) => ({

View file

@ -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=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
| Knowledge graph | \`rowboat://open?type=graph\` | — |
| Background task view | \`rowboat://open?type=task&name=<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;

View file

@ -1521,9 +1521,10 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
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<typeof BuiltinToolsSchema> = {
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<INotificationService>('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 {

View file

@ -2,6 +2,7 @@ export interface NotifyInput {
title?: string;
message: string;
link?: string;
actionLabel?: string;
}
export interface INotificationService {

View file

@ -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