mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
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:
parent
9ed54e2b94
commit
1c2b2ac1fc
17 changed files with 712 additions and 20 deletions
|
|
@ -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 }))
|
||||
}, [])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue