mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
added button for links
This commit is contained in:
parent
9fa2d1d5e3
commit
8cb55a903d
9 changed files with 150 additions and 20 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export interface NotifyInput {
|
|||
title?: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue