From ffbdb2f53d2f155c775c649edc1994a03bf22338 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 3 Mar 2026 16:56:54 -0800 Subject: [PATCH 1/5] feat: add maestro:// deep link protocol and clickable OS notifications Introduce maestro:// URL scheme support for navigating to agents, tabs, and groups from external apps and OS notification clicks. - Add deep-links module with URL parsing, protocol registration, single-instance locking, and cross-platform event handling - Wire notification click handlers to navigate to the originating agent/tab via deep link dispatch - Thread sessionId/tabId context through notification preload bridge - Add onDeepLink listener in renderer with routing to existing navigation handlers - Register maestro:// protocol in electron-builder config - Add 18 tests covering URL parsing and notification click wiring --- package.json | 6 + src/__tests__/main/deep-links.test.ts | 141 ++++++++++++ .../main/ipc/handlers/notifications.test.ts | 48 +++- src/main/deep-links.ts | 216 ++++++++++++++++++ src/main/index.ts | 12 +- src/main/ipc/handlers/index.ts | 2 +- src/main/ipc/handlers/notifications.ts | 31 ++- src/main/preload/index.ts | 1 + src/main/preload/notifications.ts | 6 +- src/main/preload/system.ts | 11 + src/renderer/App.tsx | 29 +++ src/renderer/global.d.ts | 3 +- src/renderer/stores/notificationStore.ts | 2 +- src/shared/types.ts | 19 ++ 14 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/main/deep-links.test.ts create mode 100644 src/main/deep-links.ts diff --git a/package.json b/package.json index e4fc247c6..5c01dafd1 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,12 @@ "npmRebuild": false, "appId": "com.maestro.app", "productName": "Maestro", + "protocols": [ + { + "name": "Maestro", + "schemes": ["maestro"] + } + ], "publish": { "provider": "github", "owner": "RunMaestro", diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts new file mode 100644 index 000000000..25b373897 --- /dev/null +++ b/src/__tests__/main/deep-links.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for deep link URL parsing + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron before importing the module under test +vi.mock('electron', () => ({ + app: { + isPackaged: false, + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn().mockReturnValue(true), + on: vi.fn(), + quit: vi.fn(), + }, + BrowserWindow: { + getAllWindows: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../main/utils/safe-send', () => ({ + isWebContentsAvailable: vi.fn().mockReturnValue(true), +})); + +import { parseDeepLink } from '../../main/deep-links'; + +describe('parseDeepLink', () => { + describe('focus action', () => { + it('should parse maestro://focus', () => { + expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' }); + }); + + it('should parse empty path as focus', () => { + expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' }); + }); + + it('should parse protocol-only as focus', () => { + expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' }); + }); + }); + + describe('session action', () => { + it('should parse session URL', () => { + expect(parseDeepLink('maestro://session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should parse session URL with tab', () => { + expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({ + action: 'session', + sessionId: 'abc123', + tabId: 'tab456', + }); + }); + + it('should decode URI-encoded session IDs', () => { + expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({ + action: 'session', + sessionId: 'session with space', + }); + }); + + it('should decode URI-encoded tab IDs', () => { + expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab/slash', + }); + }); + + it('should return null for session without ID', () => { + expect(parseDeepLink('maestro://session')).toBeNull(); + expect(parseDeepLink('maestro://session/')).toBeNull(); + }); + + it('should ignore extra path segments after tab ID', () => { + const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff'); + expect(result).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab1', + }); + }); + }); + + describe('group action', () => { + it('should parse group URL', () => { + expect(parseDeepLink('maestro://group/grp789')).toEqual({ + action: 'group', + groupId: 'grp789', + }); + }); + + it('should decode URI-encoded group IDs', () => { + expect(parseDeepLink('maestro://group/group%20name')).toEqual({ + action: 'group', + groupId: 'group name', + }); + }); + + it('should return null for group without ID', () => { + expect(parseDeepLink('maestro://group')).toBeNull(); + expect(parseDeepLink('maestro://group/')).toBeNull(); + }); + }); + + describe('Windows compatibility', () => { + it('should handle Windows maestro: prefix (no double slash)', () => { + expect(parseDeepLink('maestro:session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should handle Windows focus without double slash', () => { + expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' }); + }); + }); + + describe('error handling', () => { + it('should return null for unrecognized resource', () => { + expect(parseDeepLink('maestro://unknown/abc')).toBeNull(); + }); + + it('should return null for completely malformed URLs', () => { + // parseDeepLink is tolerant of most inputs, but unrecognized resources return null + expect(parseDeepLink('maestro://settings')).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index add55b37c..30b6b1e7c 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -17,6 +17,7 @@ import { ipcMain } from 'electron'; const mocks = vi.hoisted(() => ({ mockNotificationShow: vi.fn(), mockNotificationIsSupported: vi.fn().mockReturnValue(true), + mockNotificationOn: vi.fn(), })); // Mock electron with a proper class for Notification @@ -29,6 +30,9 @@ vi.mock('electron', () => { show() { mocks.mockNotificationShow(); } + on(event: string, handler: () => void) { + mocks.mockNotificationOn(event, handler); + } static isSupported() { return mocks.mockNotificationIsSupported(); } @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({ }, })); +// Mock deep-links module (used by notification click handler) +vi.mock('../../../../main/deep-links', () => ({ + parseDeepLink: vi.fn((url: string) => { + if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' }; + return { action: 'focus' }; + }), + dispatchDeepLink: vi.fn(), +})); + // Mock child_process - must include default export vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -99,6 +112,8 @@ import { describe('Notification IPC Handlers', () => { let handlers: Map; + const mockGetMainWindow = vi.fn().mockReturnValue(null); + beforeEach(() => { vi.clearAllMocks(); resetNotificationState(); @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => { // Reset mocks mocks.mockNotificationIsSupported.mockReturnValue(true); mocks.mockNotificationShow.mockClear(); + mocks.mockNotificationOn.mockClear(); // Capture registered handlers vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { handlers.set(channel, handler); }); - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: mockGetMainWindow }); }); afterEach(() => { @@ -186,6 +202,36 @@ describe('Notification IPC Handlers', () => { }); }); + describe('notification:show click-to-navigate', () => { + it('should register click handler when sessionId is provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should register click handler when sessionId and tabId are provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123', 'tab-456'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should not register click handler when sessionId is not provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body'); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + + it('should not register click handler when sessionId is undefined', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', undefined, undefined); + + expect(mocks.mockNotificationOn).not.toHaveBeenCalled(); + }); + }); + describe('notification:stopSpeak', () => { it('should return error when no active notification process', async () => { const handler = handlers.get('notification:stopSpeak')!; diff --git a/src/main/deep-links.ts b/src/main/deep-links.ts new file mode 100644 index 000000000..433606d43 --- /dev/null +++ b/src/main/deep-links.ts @@ -0,0 +1,216 @@ +/** + * Deep Link Handler for maestro:// URL scheme + * + * Provides OS-level protocol registration and URL parsing for deep links. + * Enables clickable OS notifications and external app integrations. + * + * URL scheme: + * maestro://focus — bring window to foreground + * maestro://session/{sessionId} — navigate to agent + * maestro://session/{sessionId}/tab/{tabId} — navigate to agent + tab + * maestro://group/{groupId} — expand group, focus first session + * + * Platform behavior: + * macOS: app.on('open-url') delivers the URL + * Windows/Linux: app.on('second-instance') delivers argv with URL; + * cold start delivers via process.argv + */ + +import { app, BrowserWindow } from 'electron'; +import { logger } from './utils/logger'; +import { isWebContentsAvailable } from './utils/safe-send'; +import type { ParsedDeepLink } from '../shared/types'; + +// ============================================================================ +// Constants +// ============================================================================ + +const PROTOCOL = 'maestro'; +const IPC_CHANNEL = 'app:deepLink'; + +// ============================================================================ +// State +// ============================================================================ + +/** URL received before the window was ready — flushed after createWindow() */ +let pendingDeepLinkUrl: string | null = null; + +// ============================================================================ +// URL Parsing +// ============================================================================ + +/** + * Parse a maestro:// URL into a structured deep link object. + * Returns null for malformed or unrecognized URLs. + */ +export function parseDeepLink(url: string): ParsedDeepLink | null { + try { + // Normalize: strip protocol prefix (handles both maestro:// and maestro: on Windows) + const normalized = url.replace(/^maestro:\/\//, '').replace(/^maestro:/, ''); + const parts = normalized.split('/').filter(Boolean); + + if (parts.length === 0) return { action: 'focus' }; + + const [resource, id, sub, subId] = parts; + + if (resource === 'focus') return { action: 'focus' }; + + if (resource === 'session' && id) { + if (sub === 'tab' && subId) { + return { action: 'session', sessionId: decodeURIComponent(id), tabId: decodeURIComponent(subId) }; + } + return { action: 'session', sessionId: decodeURIComponent(id) }; + } + + if (resource === 'group' && id) { + return { action: 'group', groupId: decodeURIComponent(id) }; + } + + logger.warn(`Unrecognized deep link resource: ${resource}`, 'DeepLink'); + return null; + } catch (error) { + logger.error('Failed to parse deep link URL', 'DeepLink', { url, error: String(error) }); + return null; + } +} + +// ============================================================================ +// Deep Link Dispatch +// ============================================================================ + +/** + * Process a deep link URL: parse it, bring window to foreground, and send to renderer. + */ +function processDeepLink(url: string, getMainWindow: () => BrowserWindow | null): void { + logger.info('Processing deep link', 'DeepLink', { url }); + + const parsed = parseDeepLink(url); + if (!parsed) return; + + const win = getMainWindow(); + if (!win) { + // Window not ready yet — buffer for later + pendingDeepLinkUrl = url; + logger.debug('Window not ready, buffering deep link', 'DeepLink'); + return; + } + + // Bring window to foreground + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + // For 'focus' action, bringing window to front is all we need + if (parsed.action === 'focus') return; + + // Send parsed payload to renderer for navigation + if (isWebContentsAvailable(win)) { + win.webContents.send(IPC_CHANNEL, parsed); + } +} + +// ============================================================================ +// Lifecycle Setup +// ============================================================================ + +/** + * Set up deep link protocol handling. + * + * MUST be called synchronously before app.whenReady() because + * requestSingleInstanceLock() only works before the app is ready. + * + * @returns false if another instance is already running (caller should app.quit()) + */ +export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null): boolean { + // Register as handler for maestro:// URLs + // In dev mode, skip registration to avoid clobbering the production app's registration + const isDev = !app.isPackaged; + if (!isDev) { + app.setAsDefaultProtocolClient(PROTOCOL); + logger.info('Registered as default protocol client for maestro://', 'DeepLink'); + } else { + // In dev, register only if explicitly opted in + if (process.env.REGISTER_DEEP_LINKS_IN_DEV === '1') { + app.setAsDefaultProtocolClient(PROTOCOL); + logger.info('Registered protocol client in dev mode (REGISTER_DEEP_LINKS_IN_DEV=1)', 'DeepLink'); + } else { + logger.debug('Skipping protocol registration in dev mode', 'DeepLink'); + } + } + + // Single-instance lock (Windows/Linux deep link support) + // On macOS, open-url handles this; on Windows/Linux, the OS launches a new instance + // with the URL in argv, and second-instance event fires in the primary instance + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + // Another instance is running — it will receive our argv via second-instance + logger.info('Another instance is running, quitting', 'DeepLink'); + return false; + } + + // Handle second-instance event (Windows/Linux: new instance launched with deep link URL) + app.on('second-instance', (_event, argv) => { + const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + if (deepLinkUrl) { + processDeepLink(deepLinkUrl, getMainWindow); + } else { + // No deep link, but user tried to open a second instance — bring existing window to front + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + } + }); + + // Handle open-url event (macOS: OS delivers URL to running app) + app.on('open-url', (event, url) => { + event.preventDefault(); + processDeepLink(url, getMainWindow); + }); + + // Check process.argv for cold-start deep link (Windows/Linux: app launched with URL as arg) + const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + if (deepLinkArg) { + pendingDeepLinkUrl = deepLinkArg; + logger.info('Found deep link in process argv (cold start)', 'DeepLink', { url: deepLinkArg }); + } + + return true; +} + +/** + * Flush any pending deep link URL that arrived before the window was ready. + * Call this after createWindow() inside app.whenReady(). + */ +export function flushPendingDeepLink(getMainWindow: () => BrowserWindow | null): void { + if (!pendingDeepLinkUrl) return; + + const url = pendingDeepLinkUrl; + pendingDeepLinkUrl = null; + logger.info('Flushing pending deep link', 'DeepLink', { url }); + processDeepLink(url, getMainWindow); +} + +/** + * Directly dispatch a parsed deep link to the renderer. + * Used by notification click handlers to avoid an OS protocol round-trip. + */ +export function dispatchDeepLink( + parsed: ParsedDeepLink, + getMainWindow: () => BrowserWindow | null +): void { + const win = getMainWindow(); + if (!win) return; + + // Bring window to foreground + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + if (parsed.action === 'focus') return; + + if (isWebContentsAvailable(win)) { + win.webContents.send(IPC_CHANNEL, parsed); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0afff4436..23ee01bc0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -91,6 +91,7 @@ import { } from './constants'; // initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring) import { checkWslEnvironment } from './utils/wslDetector'; +import { setupDeepLinkHandling, flushPendingDeepLink } from './deep-links'; // Extracted modules (Phase 1 refactoring) import { parseParticipantSessionId } from './group-chat/session-parser'; import { extractTextFromStreamJson } from './group-chat/output-parser'; @@ -291,6 +292,12 @@ function createWindow() { // Set up global error handlers for uncaught exceptions (Phase 4 refactoring) setupGlobalErrorHandlers(); +// Set up deep link protocol handling (must be before app.whenReady for requestSingleInstanceLock) +const gotSingleInstanceLock = setupDeepLinkHandling(() => mainWindow); +if (!gotSingleInstanceLock) { + app.quit(); +} + app.whenReady().then(async () => { // Load logger settings first const logLevel = store.get('logLevel', 'info'); @@ -394,6 +401,9 @@ app.whenReady().then(async () => { logger.info('Creating main window', 'Startup'); createWindow(); + // Flush any deep link URL that arrived before the window was ready (cold start) + flushPendingDeepLink(() => mainWindow); + // Note: History file watching is handled by HistoryManager.startWatching() above // which uses the new per-session file format in the history/ directory @@ -654,7 +664,7 @@ function setupIpcHandlers() { registerAgentErrorHandlers(); // Register notification handlers (extracted to handlers/notifications.ts) - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: () => mainWindow }); // Register attachments handlers (extracted to handlers/attachments.ts) registerAttachmentsHandlers({ app }); diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index ba41c326b..690cd4b30 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -259,7 +259,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { settingsStore: deps.settingsStore, }); // Register notification handlers (OS notifications and TTS) - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: deps.getMainWindow }); // Register Symphony handlers for token donation / open source contributions registerSymphonyHandlers({ app: deps.app, diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 95367b775..03f775c55 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -14,6 +14,7 @@ import { ipcMain, Notification, BrowserWindow } from 'electron'; import { spawn, type ChildProcess } from 'child_process'; import { logger } from '../../utils/logger'; import { isWebContentsAvailable } from '../../utils/safe-send'; +import { parseDeepLink, dispatchDeepLink } from '../../deep-links'; // ========================================================================== // Constants @@ -329,14 +330,21 @@ async function processNextNotification(): Promise { // Handler Registration // ========================================================================== +/** + * Dependencies for notification handlers + */ +export interface NotificationsHandlerDependencies { + getMainWindow: () => BrowserWindow | null; +} + /** * Register all notification-related IPC handlers */ -export function registerNotificationsHandlers(): void { - // Show OS notification +export function registerNotificationsHandlers(deps?: NotificationsHandlerDependencies): void { + // Show OS notification (with optional click-to-navigate support) ipcMain.handle( 'notification:show', - async (_event, title: string, body: string): Promise => { + async (_event, title: string, body: string, sessionId?: string, tabId?: string): Promise => { try { if (Notification.isSupported()) { const notification = new Notification({ @@ -344,8 +352,23 @@ export function registerNotificationsHandlers(): void { body, silent: true, // Don't play system sound - we have our own audio feedback option }); + + // Wire click handler for navigation if session context is provided + if (sessionId && deps?.getMainWindow) { + const deepLinkUrl = tabId + ? `maestro://session/${sessionId}/tab/${tabId}` + : `maestro://session/${sessionId}`; + + notification.on('click', () => { + const parsed = parseDeepLink(deepLinkUrl); + if (parsed) { + dispatchDeepLink(parsed, deps.getMainWindow); + } + }); + } + notification.show(); - logger.debug('Showed OS notification', 'Notification', { title, body }); + logger.debug('Showed OS notification', 'Notification', { title, body, sessionId, tabId }); return { success: true }; } else { logger.warn('OS notifications not supported on this platform', 'Notification'); diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..6749e8a66 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -303,6 +303,7 @@ export type { ShellInfo, UpdateStatus, } from './system'; +export type { ParsedDeepLink } from '../../shared/types'; export type { // From sshRemote SshRemoteApi, diff --git a/src/main/preload/notifications.ts b/src/main/preload/notifications.ts index 1f1f3d415..162de0187 100644 --- a/src/main/preload/notifications.ts +++ b/src/main/preload/notifications.ts @@ -35,9 +35,11 @@ export function createNotificationApi() { * Show an OS notification * @param title - Notification title * @param body - Notification body text + * @param sessionId - Optional session ID for click-to-navigate + * @param tabId - Optional tab ID for click-to-navigate */ - show: (title: string, body: string): Promise => - ipcRenderer.invoke('notification:show', title, body), + show: (title: string, body: string, sessionId?: string, tabId?: string): Promise => + ipcRenderer.invoke('notification:show', title, body, sessionId, tabId), /** * Execute a custom notification command (e.g., TTS, logging) diff --git a/src/main/preload/system.ts b/src/main/preload/system.ts index 14303a269..c88cb5c01 100644 --- a/src/main/preload/system.ts +++ b/src/main/preload/system.ts @@ -5,6 +5,7 @@ */ import { ipcRenderer } from 'electron'; +import type { ParsedDeepLink } from '../../shared/types'; /** * Shell information @@ -202,6 +203,16 @@ export function createAppApi() { ipcRenderer.on('app:systemResume', handler); return () => ipcRenderer.removeListener('app:systemResume', handler); }, + /** + * Listen for deep link navigation events (maestro:// URLs) + * Fired when the app is activated via a deep link from OS notification clicks, + * external apps, or CLI commands. + */ + onDeepLink: (callback: (deepLink: ParsedDeepLink) => void): (() => void) => { + const handler = (_: unknown, deepLink: ParsedDeepLink) => callback(deepLink); + ipcRenderer.on('app:deepLink', handler); + return () => ipcRenderer.removeListener('app:deepLink', handler); + }, }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index aa7525946..57faf40e2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1504,6 +1504,35 @@ function MaestroConsoleInner() { [setActiveSessionId] ); + // Deep link navigation handler — processes maestro:// URLs from OS notifications, + // external apps, and CLI commands + useEffect(() => { + const unsubscribe = window.maestro.app.onDeepLink((deepLink) => { + if (deepLink.action === 'focus') { + // Window already brought to foreground by main process + return; + } + if (deepLink.action === 'session' && deepLink.sessionId) { + handleToastSessionClick(deepLink.sessionId, deepLink.tabId); + return; + } + if (deepLink.action === 'group' && deepLink.groupId) { + // Find first session in group and navigate to it + const groupSession = sessions.find((s) => s.groupId === deepLink.groupId); + if (groupSession) { + handleToastSessionClick(groupSession.id); + } + // Expand the group if it's collapsed + setGroups((prev) => + prev.map((g) => + g.id === deepLink.groupId ? { ...g, collapsed: false } : g + ) + ); + } + }); + return unsubscribe; + }, [handleToastSessionClick, sessions, setGroups]); + // --- SESSION SORTING --- // Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization) const { sortedSessions, visibleSessions } = useSortedSessions({ diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b0b64c7c4..6424efe95 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1035,6 +1035,7 @@ interface MaestroAPI { confirmQuit: () => void; cancelQuit: () => void; onSystemResume: (callback: () => void) => () => void; + onDeepLink: (callback: (deepLink: { action: 'focus' | 'session' | 'group'; sessionId?: string; tabId?: string; groupId?: string }) => void) => () => void; }; platform: string; logger: { @@ -1315,7 +1316,7 @@ interface MaestroAPI { reload: () => Promise; }; notification: { - show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>; + show: (title: string, body: string, sessionId?: string, tabId?: string) => Promise<{ success: boolean; error?: string }>; speak: ( text: string, command?: string diff --git a/src/renderer/stores/notificationStore.ts b/src/renderer/stores/notificationStore.ts index 5b7bd7d64..d380fe3c6 100644 --- a/src/renderer/stores/notificationStore.ts +++ b/src/renderer/stores/notificationStore.ts @@ -265,7 +265,7 @@ export function notifyToast(toast: Omit): string { const prefix = bodyParts.length > 0 ? `${bodyParts.join(' > ')}: ` : ''; const notifBody = prefix + firstSentence; - window.maestro.notification.show(notifTitle, notifBody).catch((err) => { + window.maestro.notification.show(notifTitle, notifBody, toast.sessionId, toast.tabId).catch((err) => { console.error('[notificationStore] Failed to show OS notification:', err); }); } diff --git a/src/shared/types.ts b/src/shared/types.ts index 24d4da21e..30ab748af 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -365,6 +365,25 @@ export interface AgentSshRemoteConfig { workingDirOverride?: string; } +// ============================================================================ +// Deep Link Types +// ============================================================================ + +/** + * Parsed deep link from a maestro:// URL. + * Used by both main process (URL parsing) and renderer (navigation dispatch). + */ +export interface ParsedDeepLink { + /** The type of navigation action */ + action: 'focus' | 'session' | 'group'; + /** Maestro session ID (for action: 'session') */ + sessionId?: string; + /** Tab ID within the session (for action: 'session') */ + tabId?: string; + /** Group ID (for action: 'group') */ + groupId?: string; +} + // ============================================================================ // Global Agent Statistics Types // ============================================================================ From e2af94615241f545791ca165e0131af73de2a420 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 09:45:15 -0800 Subject: [PATCH 2/5] fix: address PR review feedback for deep links - URI-encode sessionId/tabId when constructing deep link URLs in notification click handler to prevent malformed URLs with special chars - Add process.exit(0) after app.quit() so secondary instances exit immediately without running further module-level setup - Use useRef for sessions in deep link effect to avoid tearing down and re-registering the IPC listener on every sessions change - Guard against navigating to non-existent session IDs in deep link handler to prevent invalid UI state - Add cross-reference comment in global.d.ts linking to canonical ParsedDeepLink type (can't import in ambient declaration file) - Add test for URI-encoding round-trip in notification click handler --- .../main/ipc/handlers/notifications.test.ts | 14 ++++++++++++++ src/main/index.ts | 1 + src/main/ipc/handlers/notifications.ts | 12 +++++++++--- src/renderer/App.tsx | 16 +++++++++++----- src/renderer/global.d.ts | 17 +++++++++++++++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index 30b6b1e7c..a1ed411db 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -217,6 +217,20 @@ describe('Notification IPC Handlers', () => { expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); }); + it('should URI-encode sessionId and tabId in deep link URL', async () => { + const { parseDeepLink } = await import('../../../../main/deep-links'); + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special'); + + // Trigger the click handler + const clickHandler = mocks.mockNotificationOn.mock.calls[0][1]; + clickHandler(); + + expect(parseDeepLink).toHaveBeenCalledWith( + `maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}` + ); + }); + it('should not register click handler when sessionId is not provided', async () => { const handler = handlers.get('notification:show')!; await handler({}, 'Title', 'Body'); diff --git a/src/main/index.ts b/src/main/index.ts index 23ee01bc0..d6d2bed13 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -296,6 +296,7 @@ setupGlobalErrorHandlers(); const gotSingleInstanceLock = setupDeepLinkHandling(() => mainWindow); if (!gotSingleInstanceLock) { app.quit(); + process.exit(0); } app.whenReady().then(async () => { diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 03f775c55..4b1ba37b7 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -344,7 +344,13 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Show OS notification (with optional click-to-navigate support) ipcMain.handle( 'notification:show', - async (_event, title: string, body: string, sessionId?: string, tabId?: string): Promise => { + async ( + _event, + title: string, + body: string, + sessionId?: string, + tabId?: string + ): Promise => { try { if (Notification.isSupported()) { const notification = new Notification({ @@ -356,8 +362,8 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Wire click handler for navigation if session context is provided if (sessionId && deps?.getMainWindow) { const deepLinkUrl = tabId - ? `maestro://session/${sessionId}/tab/${tabId}` - : `maestro://session/${sessionId}`; + ? `maestro://session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}` + : `maestro://session/${encodeURIComponent(sessionId)}`; notification.on('click', () => { const parsed = parseDeepLink(deepLinkUrl); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 57faf40e2..70c9b2a8a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1504,6 +1504,12 @@ function MaestroConsoleInner() { [setActiveSessionId] ); + // Keep a ref to sessions so the deep link listener doesn't churn on every sessions change + const sessionsRef = useRef(sessions); + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + // Deep link navigation handler — processes maestro:// URLs from OS notifications, // external apps, and CLI commands useEffect(() => { @@ -1513,25 +1519,25 @@ function MaestroConsoleInner() { return; } if (deepLink.action === 'session' && deepLink.sessionId) { + const targetExists = sessionsRef.current.some((s) => s.id === deepLink.sessionId); + if (!targetExists) return; handleToastSessionClick(deepLink.sessionId, deepLink.tabId); return; } if (deepLink.action === 'group' && deepLink.groupId) { // Find first session in group and navigate to it - const groupSession = sessions.find((s) => s.groupId === deepLink.groupId); + const groupSession = sessionsRef.current.find((s) => s.groupId === deepLink.groupId); if (groupSession) { handleToastSessionClick(groupSession.id); } // Expand the group if it's collapsed setGroups((prev) => - prev.map((g) => - g.id === deepLink.groupId ? { ...g, collapsed: false } : g - ) + prev.map((g) => (g.id === deepLink.groupId ? { ...g, collapsed: false } : g)) ); } }); return unsubscribe; - }, [handleToastSessionClick, sessions, setGroups]); + }, [handleToastSessionClick, setGroups]); // --- SESSION SORTING --- // Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization) diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 6424efe95..6a7995de8 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1035,7 +1035,15 @@ interface MaestroAPI { confirmQuit: () => void; cancelQuit: () => void; onSystemResume: (callback: () => void) => () => void; - onDeepLink: (callback: (deepLink: { action: 'focus' | 'session' | 'group'; sessionId?: string; tabId?: string; groupId?: string }) => void) => () => void; + /** @see ParsedDeepLink in src/shared/types.ts — keep in sync */ + onDeepLink: ( + callback: (deepLink: { + action: 'focus' | 'session' | 'group'; + sessionId?: string; + tabId?: string; + groupId?: string; + }) => void + ) => () => void; }; platform: string; logger: { @@ -1316,7 +1324,12 @@ interface MaestroAPI { reload: () => Promise; }; notification: { - show: (title: string, body: string, sessionId?: string, tabId?: string) => Promise<{ success: boolean; error?: string }>; + show: ( + title: string, + body: string, + sessionId?: string, + tabId?: string + ) => Promise<{ success: boolean; error?: string }>; speak: ( text: string, command?: string From f4be1494c324433ef526c8293dfc4f9f798b3fee Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 10:04:56 -0800 Subject: [PATCH 3/5] feat: add deep link template variables, shared URL builders, and docs - Add shared deep-link-urls.ts with buildSessionDeepLink(), buildGroupDeepLink(), and buildFocusDeepLink() utilities - Add {{AGENT_DEEP_LINK}}, {{TAB_DEEP_LINK}}, {{GROUP_DEEP_LINK}} template variables available in system prompts, custom AI commands, and Auto Run documents - Wire activeTabId and groupId into TemplateContext at all call sites (agentStore, useInputProcessing, useRemoteHandlers, useDocumentProcessor, useMergeTransferHandlers, batch-processor) - Refactor notifications.ts to use shared buildSessionDeepLink() - Add sessionId/tabId to notifyToast callers where context is available (merge, transfer, summarize, PR creation) - Add docs/deep-links.md documentation page with URL format, usage examples, template variables, and platform behavior - Add 8 tests for URL builders, 6 tests for template variable substitution including URI encoding --- docs/deep-links.md | 96 +++++++++++++++++++ docs/docs.json | 2 +- src/__tests__/shared/deep-link-urls.test.ts | 54 +++++++++++ .../shared/templateVariables.test.ts | 63 ++++++++++++ src/cli/services/batch-processor.ts | 1 + src/main/ipc/handlers/notifications.ts | 5 +- src/renderer/App.tsx | 1 + .../hooks/agent/useMergeTransferHandlers.ts | 5 + .../hooks/agent/useSummarizeAndContinue.ts | 2 + .../hooks/batch/useDocumentProcessor.ts | 2 + .../hooks/input/useInputProcessing.ts | 4 + .../hooks/remote/useRemoteHandlers.ts | 2 + src/renderer/stores/agentStore.ts | 6 ++ src/shared/deep-link-urls.ts | 33 +++++++ src/shared/templateVariables.ts | 19 ++++ 15 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 docs/deep-links.md create mode 100644 src/__tests__/shared/deep-link-urls.test.ts create mode 100644 src/shared/deep-link-urls.ts diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 000000000..a0e618dd7 --- /dev/null +++ b/docs/deep-links.md @@ -0,0 +1,96 @@ +--- +title: Deep Links +description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications. +icon: link +--- + +# Deep Links + +Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks. + +## URL Format + +``` +maestro://[action]/[parameters] +``` + +### Available Actions + +| URL | Action | +| ------------------------------------------- | ------------------------------------------ | +| `maestro://focus` | Bring Maestro window to foreground | +| `maestro://session/{sessionId}` | Navigate to an agent | +| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent | +| `maestro://group/{groupId}` | Expand a group and focus its first agent | + +IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded. + +## Usage + +### From Terminal + +```bash +# macOS +open "maestro://session/abc123" +open "maestro://session/abc123/tab/def456" +open "maestro://group/my-group-id" +open "maestro://focus" + +# Linux +xdg-open "maestro://session/abc123" + +# Windows +start maestro://session/abc123 +``` + +### OS Notification Clicks + +When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab. + +This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings. + +### Template Variables + +Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents: + +| Variable | Description | Example Value | +| --------------------- | ---------------------------------------------- | ------------------------------------- | +| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` | +| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` | +| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` | + +These variables can be used in: + +- **System prompts** — give AI agents awareness of their own deep link for cross-referencing +- **Custom AI commands** — include deep links in generated output +- **Auto Run documents** — reference agents in batch automation workflows +- **Custom notification commands** — include deep links in TTS or logging scripts + +### From Scripts and External Tools + +Any application can launch Maestro deep links by opening the URL. This enables integrations like: + +- CI/CD pipelines that open a specific agent after deployment +- Shell scripts that navigate to a group after batch operations +- Alfred/Raycast workflows for quick agent access +- Bookmarks for frequently-used agents + +## Platform Behavior + +| Platform | Mechanism | +| ----------------- | ----------------------------------------------------------------------------- | +| **macOS** | `app.on('open-url')` delivers the URL to the running instance | +| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance | +| **Cold start** | URL is buffered and processed after the window is ready | + +Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one. + + +In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development. + + +## Related + +- [Configuration](./configuration) — OS notification settings +- [General Usage](./general-usage) — Core UI and workflow patterns +- [MCP Server](./mcp-server) — Connect AI applications to Maestro diff --git a/docs/docs.json b/docs/docs.json index 069f0c48b..e33c3057f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -82,7 +82,7 @@ }, { "group": "Integrations", - "pages": ["mcp-server"], + "pages": ["mcp-server", "deep-links"], "icon": "plug" }, { diff --git a/src/__tests__/shared/deep-link-urls.test.ts b/src/__tests__/shared/deep-link-urls.test.ts new file mode 100644 index 000000000..f5163aca2 --- /dev/null +++ b/src/__tests__/shared/deep-link-urls.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for src/shared/deep-link-urls.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + buildSessionDeepLink, + buildGroupDeepLink, + buildFocusDeepLink, +} from '../../shared/deep-link-urls'; + +describe('buildSessionDeepLink', () => { + it('should build a session-only deep link', () => { + expect(buildSessionDeepLink('abc123')).toBe('maestro://session/abc123'); + }); + + it('should build a session + tab deep link', () => { + expect(buildSessionDeepLink('abc123', 'tab456')).toBe('maestro://session/abc123/tab/tab456'); + }); + + it('should URI-encode session IDs with special characters', () => { + expect(buildSessionDeepLink('id/with/slashes')).toBe( + `maestro://session/${encodeURIComponent('id/with/slashes')}` + ); + }); + + it('should URI-encode tab IDs with special characters', () => { + expect(buildSessionDeepLink('sess', 'tab?special')).toBe( + `maestro://session/sess/tab/${encodeURIComponent('tab?special')}` + ); + }); + + it('should not include tab segment when tabId is undefined', () => { + expect(buildSessionDeepLink('abc123', undefined)).toBe('maestro://session/abc123'); + }); +}); + +describe('buildGroupDeepLink', () => { + it('should build a group deep link', () => { + expect(buildGroupDeepLink('grp789')).toBe('maestro://group/grp789'); + }); + + it('should URI-encode group IDs with special characters', () => { + expect(buildGroupDeepLink('group/name')).toBe( + `maestro://group/${encodeURIComponent('group/name')}` + ); + }); +}); + +describe('buildFocusDeepLink', () => { + it('should build a focus deep link', () => { + expect(buildFocusDeepLink()).toBe('maestro://focus'); + }); +}); diff --git a/src/__tests__/shared/templateVariables.test.ts b/src/__tests__/shared/templateVariables.test.ts index 6d1d8c3eb..84dc4d01a 100644 --- a/src/__tests__/shared/templateVariables.test.ts +++ b/src/__tests__/shared/templateVariables.test.ts @@ -93,6 +93,13 @@ describe('TEMPLATE_VARIABLES constant', () => { expect(variables).toContain('{{IS_GIT_REPO}}'); }); + it('should include deep link variables', () => { + const variables = TEMPLATE_VARIABLES.map((v) => v.variable); + expect(variables).toContain('{{AGENT_DEEP_LINK}}'); + expect(variables).toContain('{{TAB_DEEP_LINK}}'); + expect(variables).toContain('{{GROUP_DEEP_LINK}}'); + }); + it('should mark Auto Run-only variables with autoRunOnly flag', () => { const autoRunOnlyVars = TEMPLATE_VARIABLES.filter((v) => v.autoRunOnly); const autoRunOnlyNames = autoRunOnlyVars.map((v) => v.variable); @@ -565,6 +572,62 @@ describe('substituteTemplateVariables', () => { }); }); + describe('Deep Link Variables', () => { + it('should replace {{AGENT_DEEP_LINK}} with session deep link URL', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + }); + const result = substituteTemplateVariables('Link: {{AGENT_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc'); + }); + + it('should replace {{TAB_DEEP_LINK}} with session+tab deep link when activeTabId provided', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + activeTabId: 'tab-def', + }); + const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc/tab/tab-def'); + }); + + it('should replace {{TAB_DEEP_LINK}} with session-only link when no activeTabId', () => { + const context = createTestContext({ + session: createTestSession({ id: 'sess-abc' }), + }); + const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://session/sess-abc'); + }); + + it('should replace {{GROUP_DEEP_LINK}} with group deep link when groupId provided', () => { + const context = createTestContext({ + groupId: 'grp-789', + }); + const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context); + expect(result).toBe('Link: maestro://group/grp-789'); + }); + + it('should replace {{GROUP_DEEP_LINK}} with empty string when no groupId', () => { + const context = createTestContext(); + const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context); + expect(result).toBe('Link: '); + }); + + it('should URI-encode special characters in deep link IDs', () => { + const context = createTestContext({ + session: createTestSession({ id: 'id/with/slashes' }), + activeTabId: 'tab?special', + groupId: 'group#hash', + }); + const agentResult = substituteTemplateVariables('{{AGENT_DEEP_LINK}}', context); + const tabResult = substituteTemplateVariables('{{TAB_DEEP_LINK}}', context); + const groupResult = substituteTemplateVariables('{{GROUP_DEEP_LINK}}', context); + + expect(agentResult).toContain(encodeURIComponent('id/with/slashes')); + expect(tabResult).toContain(encodeURIComponent('tab?special')); + expect(groupResult).toContain(encodeURIComponent('group#hash')); + }); + }); + describe('Case Insensitivity', () => { it('should handle lowercase variables', () => { const context = createTestContext({ diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a..20e2e19a1 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -397,6 +397,7 @@ export async function* runPlaybook( }, gitBranch, groupName, + groupId: session.groupId, autoRunFolder: folderPath, loopNumber: loopIteration + 1, // 1-indexed documentName: docEntry.filename, diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts index 4b1ba37b7..aed1e00a3 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -15,6 +15,7 @@ import { spawn, type ChildProcess } from 'child_process'; import { logger } from '../../utils/logger'; import { isWebContentsAvailable } from '../../utils/safe-send'; import { parseDeepLink, dispatchDeepLink } from '../../deep-links'; +import { buildSessionDeepLink } from '../../../shared/deep-link-urls'; // ========================================================================== // Constants @@ -361,9 +362,7 @@ export function registerNotificationsHandlers(deps?: NotificationsHandlerDepende // Wire click handler for navigation if session context is provided if (sessionId && deps?.getMainWindow) { - const deepLinkUrl = tabId - ? `maestro://session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}` - : `maestro://session/${encodeURIComponent(sessionId)}`; + const deepLinkUrl = buildSessionDeepLink(sessionId, tabId); notification.on('click', () => { const parsed = parseDeepLink(deepLinkUrl); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 70c9b2a8a..9380b8487 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1756,6 +1756,7 @@ function MaestroConsoleInner() { message: prDetails.title, actionUrl: prDetails.url, actionLabel: prDetails.url, + sessionId: session?.id, }); // Add history entry with PR details if (session) { diff --git a/src/renderer/hooks/agent/useMergeTransferHandlers.ts b/src/renderer/hooks/agent/useMergeTransferHandlers.ts index d3eb80d9a..714fe679f 100644 --- a/src/renderer/hooks/agent/useMergeTransferHandlers.ts +++ b/src/renderer/hooks/agent/useMergeTransferHandlers.ts @@ -186,6 +186,8 @@ export function useMergeTransferHandlers( message: `"${result.sourceSessionName || 'Current Session'}" → "${ result.targetSessionName || 'Selected Session' }"${tokenInfo}.${savedInfo}`, + sessionId: result.targetSessionId, + tabId: result.targetTabId, }); // Clear the merge state for the source tab @@ -220,6 +222,7 @@ export function useMergeTransferHandlers( type: 'success', title: 'Context Transferred', message: `Created "${sessionName}" with transferred context`, + sessionId, }); // Show desktop notification for visibility when app is not focused @@ -474,6 +477,8 @@ You are taking over this conversation. Based on the context above, provide a bri const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: targetSession, gitBranch, + groupId: targetSession.groupId, + activeTabId: newTabId, conductorProfile, }); effectivePrompt = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${effectivePrompt}`; diff --git a/src/renderer/hooks/agent/useSummarizeAndContinue.ts b/src/renderer/hooks/agent/useSummarizeAndContinue.ts index bc211bf0b..8980ec947 100644 --- a/src/renderer/hooks/agent/useSummarizeAndContinue.ts +++ b/src/renderer/hooks/agent/useSummarizeAndContinue.ts @@ -347,6 +347,8 @@ export function useSummarizeAndContinue(session: Session | null): UseSummarizeAn type: 'warning', title: 'Cannot Compact', message: `Context too small. Need at least ${contextSummarizationService.getMinContextUsagePercent()}% usage, ~2k tokens, or 8+ messages to compact.`, + sessionId, + tabId: targetTabId, }); return; } diff --git a/src/renderer/hooks/batch/useDocumentProcessor.ts b/src/renderer/hooks/batch/useDocumentProcessor.ts index 3fa69365a..f0e173694 100644 --- a/src/renderer/hooks/batch/useDocumentProcessor.ts +++ b/src/renderer/hooks/batch/useDocumentProcessor.ts @@ -297,6 +297,8 @@ export function useDocumentProcessor(): UseDocumentProcessorReturn { session, gitBranch, groupName, + groupId: session.groupId, + activeTabId: session.activeTabId, autoRunFolder: folderPath, loopNumber: loopIteration, // Already 1-indexed from caller documentName: filename, diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 095db9375..d6c2a87a2 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -243,6 +243,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces substituteTemplateVariables(matchingCustomCommand.prompt, { session: activeSession, gitBranch, + groupId: activeSession.groupId, + activeTabId: activeSession.activeTabId, conductorProfile, }); @@ -976,6 +978,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: freshSession, gitBranch, + groupId: freshSession.groupId, + activeTabId: freshSession.activeTabId, historyFilePath, conductorProfile, }); diff --git a/src/renderer/hooks/remote/useRemoteHandlers.ts b/src/renderer/hooks/remote/useRemoteHandlers.ts index bc1885178..ceff1067f 100644 --- a/src/renderer/hooks/remote/useRemoteHandlers.ts +++ b/src/renderer/hooks/remote/useRemoteHandlers.ts @@ -284,6 +284,8 @@ export function useRemoteHandlers(deps: UseRemoteHandlersDeps): UseRemoteHandler promptToSend = substituteTemplateVariables(matchingCommand.prompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: session.activeTabId, conductorProfile, }); commandMetadata = { diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts index 120376284..853e9bc88 100644 --- a/src/renderer/stores/agentStore.ts +++ b/src/renderer/stores/agentStore.ts @@ -317,6 +317,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); @@ -387,6 +389,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedPrompt = substituteTemplateVariables(promptWithArgs, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); @@ -397,6 +401,8 @@ export const useAgentStore = create()((set, get) => ({ const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session, gitBranch, + groupId: session.groupId, + activeTabId: targetTab.id, conductorProfile: deps.conductorProfile, }); promptForAgent = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${substitutedPrompt}`; diff --git a/src/shared/deep-link-urls.ts b/src/shared/deep-link-urls.ts new file mode 100644 index 000000000..d6383e440 --- /dev/null +++ b/src/shared/deep-link-urls.ts @@ -0,0 +1,33 @@ +/** + * Deep Link URL Builders + * + * Shared utilities for constructing maestro:// URLs with proper URI encoding. + * Used by both main process (notification click handlers) and shared modules + * (template variable substitution). + */ + +const PROTOCOL = 'maestro://'; + +/** + * Build a deep link URL for a session, optionally targeting a specific tab. + */ +export function buildSessionDeepLink(sessionId: string, tabId?: string): string { + if (tabId) { + return `${PROTOCOL}session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}`; + } + return `${PROTOCOL}session/${encodeURIComponent(sessionId)}`; +} + +/** + * Build a deep link URL for a group. + */ +export function buildGroupDeepLink(groupId: string): string { + return `${PROTOCOL}group/${encodeURIComponent(groupId)}`; +} + +/** + * Build a deep link URL that simply brings the window to foreground. + */ +export function buildFocusDeepLink(): string { + return `${PROTOCOL}focus`; +} diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index 39ecae010..afd395c22 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -1,3 +1,5 @@ +import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; + /** * Template Variable System for Auto Run and Custom AI Commands * @@ -40,6 +42,11 @@ * {{GIT_BRANCH}} - Current git branch name (requires git repo) * {{IS_GIT_REPO}} - "true" or "false" * + * Deep Link Variables: + * {{AGENT_DEEP_LINK}} - maestro:// deep link to this agent + * {{TAB_DEEP_LINK}} - maestro:// deep link to this agent + active tab + * {{GROUP_DEEP_LINK}} - maestro:// deep link to this agent's group (if grouped) + * * Context Variables: * {{CONTEXT_USAGE}} - Current context window usage percentage */ @@ -64,6 +71,8 @@ export interface TemplateContext { session: TemplateSessionInfo; gitBranch?: string; groupName?: string; + groupId?: string; + activeTabId?: string; autoRunFolder?: string; loopNumber?: number; // Auto Run document context @@ -78,6 +87,7 @@ export interface TemplateContext { // List of all available template variables for documentation (alphabetically sorted) // Variables marked as autoRunOnly are only shown in Auto Run contexts, not in AI Commands settings export const TEMPLATE_VARIABLES = [ + { variable: '{{AGENT_DEEP_LINK}}', description: 'Deep link to this agent (maestro://)' }, { variable: '{{AGENT_GROUP}}', description: 'Agent group name' }, { variable: '{{CONDUCTOR_PROFILE}}', description: "Conductor's About Me profile" }, { variable: '{{AGENT_HISTORY_PATH}}', description: 'History file path (task recall)' }, @@ -95,6 +105,7 @@ export const TEMPLATE_VARIABLES = [ { variable: '{{DOCUMENT_NAME}}', description: 'Current document name', autoRunOnly: true }, { variable: '{{DOCUMENT_PATH}}', description: 'Current document path', autoRunOnly: true }, { variable: '{{GIT_BRANCH}}', description: 'Git branch name' }, + { variable: '{{GROUP_DEEP_LINK}}', description: 'Deep link to agent group (maestro://)' }, { variable: '{{IS_GIT_REPO}}', description: 'Is git repo (true/false)' }, { variable: '{{LOOP_NUMBER}}', @@ -102,6 +113,7 @@ export const TEMPLATE_VARIABLES = [ autoRunOnly: true, }, { variable: '{{MONTH}}', description: 'Month (01-12)' }, + { variable: '{{TAB_DEEP_LINK}}', description: 'Deep link to agent + active tab (maestro://)' }, { variable: '{{TIME}}', description: 'Time (HH:MM:SS)' }, { variable: '{{TIMESTAMP}}', description: 'Unix timestamp (ms)' }, { variable: '{{TIME_SHORT}}', description: 'Time (HH:MM)' }, @@ -121,6 +133,8 @@ export function substituteTemplateVariables(template: string, context: TemplateC session, gitBranch, groupName, + groupId, + activeTabId, autoRunFolder, loopNumber, documentName, @@ -181,6 +195,11 @@ export function substituteTemplateVariables(template: string, context: TemplateC GIT_BRANCH: gitBranch || '', IS_GIT_REPO: String(session.isGitRepo ?? false), + // Deep link variables + AGENT_DEEP_LINK: buildSessionDeepLink(session.id), + TAB_DEEP_LINK: buildSessionDeepLink(session.id, activeTabId), + GROUP_DEEP_LINK: groupId ? buildGroupDeepLink(groupId) : '', + // Context variables CONTEXT_USAGE: String(session.contextUsage || 0), }; From 15b59a2563794c1ed18e28a5d6ae401c68808812 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 20:52:35 -0600 Subject: [PATCH 4/5] feat: add Copy Deep Link option to tab overlay menu Adds a "Copy Deep Link" button to the tab context menu that copies a maestro://session/{id}/tab/{tabId} URL to the clipboard. Reuses the existing buildSessionDeepLink shared utility. --- .../renderer/components/TabBar.test.tsx | 74 +++++++++++++++++++ src/renderer/components/MainPanel.tsx | 1 + src/renderer/components/TabBar.tsx | 40 +++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index aea4c81c7..6887e1ef6 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -97,6 +97,11 @@ vi.mock('lucide-react', () => ({ 📂 ), + Link: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + 🔗 + + ), FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( 📄 @@ -1278,6 +1283,75 @@ describe('TabBar', () => { expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); }); + it('copies deep link to clipboard when Copy Deep Link clicked', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'abc123-xyz789', + }), + ]; + + render( + + ); + + const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!; + fireEvent.mouseEnter(tab); + act(() => { + vi.advanceTimersByTime(450); + }); + + fireEvent.click(screen.getByText('Copy Deep Link')); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'maestro://session/session-42/tab/tab-1' + ); + expect(screen.getByText('Copied!')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1600); + }); + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + }); + + it('does not show Copy Deep Link when sessionId not provided', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'abc123', + }), + ]; + + render( + + ); + + const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!; + fireEvent.mouseEnter(tab); + act(() => { + vi.advanceTimersByTime(450); + }); + + expect(screen.queryByText('Copy Deep Link')).not.toBeInTheDocument(); + }); + it('calls onTabStar when star button clicked', async () => { const tabs = [ createTab({ diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index f68b600ff..e514cd6fe 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -1464,6 +1464,7 @@ export const MainPanel = React.memo( tabs={activeSession.aiTabs} activeTabId={activeSession.activeTabId} theme={theme} + sessionId={activeSession.id} onTabSelect={onTabSelect} onTabClose={onTabClose} onNewTab={onNewTab} diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 6a798672b..37c027294 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -20,6 +20,7 @@ import { Loader2, ExternalLink, FolderOpen, + Link, } from 'lucide-react'; import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types'; import { hasDraft } from '../utils/tabHelpers'; @@ -27,11 +28,14 @@ import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { getExtensionColor } from '../utils/extensionColors'; import { getRevealLabel } from '../utils/platformUtils'; import { safeClipboardWrite } from '../utils/clipboard'; +import { buildSessionDeepLink } from '../../shared/deep-link-urls'; interface TabBarProps { tabs: AITab[]; activeTabId: string; theme: Theme; + /** The Maestro session/agent ID that owns these tabs */ + sessionId?: string; onTabSelect: (tabId: string) => void; onTabClose: (tabId: string) => void; onNewTab: () => void; @@ -87,6 +91,8 @@ interface TabProps { tabId: string; isActive: boolean; theme: Theme; + /** The Maestro session/agent ID that owns these tabs */ + sessionId?: string; canClose: boolean; /** Stable callback - receives tabId as first argument */ onSelect: (tabId: string) => void; @@ -197,6 +203,7 @@ const Tab = memo(function Tab({ tabId, isActive, theme, + sessionId, canClose, onSelect, onClose, @@ -231,7 +238,7 @@ const Tab = memo(function Tab({ }: TabProps) { const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); - const [showCopied, setShowCopied] = useState(false); + const [showCopied, setShowCopied] = useState<'sessionId' | 'deepLink' | false>(false); const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number; @@ -311,13 +318,25 @@ const Tab = memo(function Tab({ e.stopPropagation(); if (tab.agentSessionId) { safeClipboardWrite(tab.agentSessionId); - setShowCopied(true); + setShowCopied('sessionId'); setTimeout(() => setShowCopied(false), 1500); } }, [tab.agentSessionId] ); + const handleCopyDeepLink = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (sessionId) { + safeClipboardWrite(buildSessionDeepLink(sessionId, tabId)); + setShowCopied('deepLink'); + setTimeout(() => setShowCopied(false), 1500); + } + }, + [sessionId, tabId] + ); + const handleStarClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -692,7 +711,19 @@ const Tab = memo(function Tab({ title={`Full ID: ${tab.agentSessionId}`} > - {showCopied ? 'Copied!' : 'Copy Session ID'} + {showCopied === 'sessionId' ? 'Copied!' : 'Copy Session ID'} + + )} + + {sessionId && ( + )} @@ -1508,6 +1539,7 @@ function TabBarInner({ tabs, activeTabId, theme, + sessionId, onTabSelect, onTabClose, onNewTab, @@ -1946,6 +1978,7 @@ function TabBarInner({ tabId={tab.id} isActive={isActive} theme={theme} + sessionId={sessionId} canClose={canClose} onSelect={onTabSelect} onClose={onTabClose} @@ -2065,6 +2098,7 @@ function TabBarInner({ tabId={tab.id} isActive={isActive} theme={theme} + sessionId={sessionId} canClose={canClose} onSelect={onTabSelect} onClose={onTabClose} From 3b0bba200045165df0efb8a3681a915cdec0e988 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:05:43 -0600 Subject: [PATCH 5/5] fix: pass app entry point in dev-mode protocol registration In dev mode, setAsDefaultProtocolClient without extra args registers the stock Electron binary, causing the Electron splash screen to open instead of Maestro. Pass process.execPath and the resolved app entry point so macOS launches the correct application. --- src/main/deep-links.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/deep-links.ts b/src/main/deep-links.ts index 433606d43..10705f4d2 100644 --- a/src/main/deep-links.ts +++ b/src/main/deep-links.ts @@ -16,6 +16,7 @@ * cold start delivers via process.argv */ +import path from 'path'; import { app, BrowserWindow } from 'electron'; import { logger } from './utils/logger'; import { isWebContentsAvailable } from './utils/safe-send'; @@ -57,7 +58,11 @@ export function parseDeepLink(url: string): ParsedDeepLink | null { if (resource === 'session' && id) { if (sub === 'tab' && subId) { - return { action: 'session', sessionId: decodeURIComponent(id), tabId: decodeURIComponent(subId) }; + return { + action: 'session', + sessionId: decodeURIComponent(id), + tabId: decodeURIComponent(subId), + }; } return { action: 'session', sessionId: decodeURIComponent(id) }; } @@ -131,8 +136,15 @@ export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null) } else { // In dev, register only if explicitly opted in if (process.env.REGISTER_DEEP_LINKS_IN_DEV === '1') { - app.setAsDefaultProtocolClient(PROTOCOL); - logger.info('Registered protocol client in dev mode (REGISTER_DEEP_LINKS_IN_DEV=1)', 'DeepLink'); + // In dev mode, the bare Electron binary is used. We must pass the app + // entry point as an argument so macOS launches Maestro, not the default + // Electron splash screen. + const appPath = path.resolve(process.argv[1]); + app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [appPath]); + logger.info( + `Registered protocol client in dev mode (REGISTER_DEEP_LINKS_IN_DEV=1, entry=${appPath})`, + 'DeepLink' + ); } else { logger.debug('Skipping protocol registration in dev mode', 'DeepLink'); } @@ -150,7 +162,9 @@ export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null) // Handle second-instance event (Windows/Linux: new instance launched with deep link URL) app.on('second-instance', (_event, argv) => { - const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + const deepLinkUrl = argv.find( + (arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`) + ); if (deepLinkUrl) { processDeepLink(deepLinkUrl, getMainWindow); } else { @@ -170,7 +184,9 @@ export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null) }); // Check process.argv for cold-start deep link (Windows/Linux: app launched with URL as arg) - const deepLinkArg = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)); + const deepLinkArg = process.argv.find( + (arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`) + ); if (deepLinkArg) { pendingDeepLinkUrl = deepLinkArg; logger.info('Found deep link in process argv (cold start)', 'DeepLink', { url: deepLinkArg });