feat: native desktop notifications + rowboat:// deep links

Adds INotificationService with an Electron implementation, plus a deep-link
dispatcher (rowboat://) for routing notification clicks back into the app.

Notifications:
- New `notify-user` skill + builtin tool. Title, message, optional primary
    link, optional secondary actions. Supports https:// (opens in browser) and
    rowboat:// (opens in app) targets.
- ElectronNotificationService holds strong refs to active Notification
    instances so click handlers survive GC (otherwise macOS click silently
    no-ops).
- Calendar meeting notifier fires 1-min warnings with "take notes" /
    "join + take notes" actions backed by deep links.

Deep links (rowboat://):
- forge.config.cjs declares the protocol; main.ts wires single-instance
    lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/
    linux), and first-launch argv extraction.
- New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions
    (rowboat://action?type=...) vs renderer navigation (rowboat://open?...)
    via app:openUrl IPC. Includes pending-URL buffering for first-launch
    delivery before the renderer is ready.
- Renderer parseDeepLink supports file / chat / graph / task /
    suggested-topics targets.
- New app:consumePendingDeepLink IPC for renderer one-time drain on mount.

Refactor: extractConferenceLink moved out of calendar-block.tsx into
shared lib/calendar-event.ts (used by both the block and the take-notes
deep-link handler)
This commit is contained in:
arkml 2026-05-04 15:47:30 +05:30 committed by GitHub
parent 9ed54e2b94
commit 1c2b2ac1fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 712 additions and 20 deletions

View file

@ -54,6 +54,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
@ -515,6 +516,45 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
return true // both graph
}
/**
* Parse a rowboat:// deep link into a ViewState. Returns null if the URL is
* malformed or names an unknown target.
*
* Shape: rowboat://open?type=<file|chat|graph|task|suggested-topics>&...
* file: ?type=file&path=knowledge/foo.md
* chat: ?type=chat&runId=abc123 (runId optional)
* graph: ?type=graph
* task: ?type=task&name=daily-brief
* suggested-topics: ?type=suggested-topics
*/
function parseDeepLink(input: string): ViewState | null {
const SCHEME = 'rowboat://'
if (!input.startsWith(SCHEME)) return null
const rest = input.slice(SCHEME.length)
const queryIdx = rest.indexOf('?')
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, '')
if (host !== 'open') return null
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : '')
switch (params.get('type')) {
case 'file': {
const path = params.get('path')
return path ? { type: 'file', path } : null
}
case 'chat':
return { type: 'chat', runId: params.get('runId') || null }
case 'graph':
return { type: 'graph' }
case 'task': {
const name = params.get('name')
return name ? { type: 'task', name } : null
}
case 'suggested-topics':
return { type: 'suggested-topics' }
default:
return null
}
}
/** Sidebar toggle (fixed position, top-left) */
function FixedSidebarToggle({
leftInsetPx,
@ -3050,6 +3090,58 @@ function App() {
void navigateToView({ type: 'file', path })
}, [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)
if (view) void navigateToViewRef.current(view)
}
void window.ipc.invoke('app:consumePendingDeepLink', null).then(({ url }) => {
if (url) handle(url)
})
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
}, [])
// 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, openMeeting }) => {
const e = event as {
summary?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
end?: { dateTime?: string; date?: string; timeZone?: string }
location?: string
htmlLink?: string
hangoutLink?: string
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
}
if (!e || typeof e !== 'object') return
const conferenceLink = extractConferenceLink(e as Record<string, unknown>)
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,
end: e.end,
location: e.location,
htmlLink: e.htmlLink,
conferenceLink,
source: 'calendar-sync',
}
window.dispatchEvent(new Event('calendar-block:join-meeting'))
})
}, [])
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
}, [])