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/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..a1ed411db 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,50 @@ 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 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'); + + 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/__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/__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/deep-links.ts b/src/main/deep-links.ts new file mode 100644 index 000000000..10705f4d2 --- /dev/null +++ b/src/main/deep-links.ts @@ -0,0 +1,232 @@ +/** + * 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 path from 'path'; +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') { + // 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'); + } + } + + // 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..d6d2bed13 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,13 @@ 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(); + process.exit(0); +} + app.whenReady().then(async () => { // Load logger settings first const logLevel = store.get('logLevel', 'info'); @@ -394,6 +402,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 +665,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..aed1e00a3 100644 --- a/src/main/ipc/handlers/notifications.ts +++ b/src/main/ipc/handlers/notifications.ts @@ -14,6 +14,8 @@ 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'; +import { buildSessionDeepLink } from '../../../shared/deep-link-urls'; // ========================================================================== // Constants @@ -329,14 +331,27 @@ 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 +359,21 @@ 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 = buildSessionDeepLink(sessionId, tabId); + + 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..9380b8487 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1504,6 +1504,41 @@ 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(() => { + 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) { + 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 = 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)) + ); + } + }); + return unsubscribe; + }, [handleToastSessionClick, setGroups]); + // --- SESSION SORTING --- // Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization) const { sortedSessions, visibleSessions } = useSortedSessions({ @@ -1721,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/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} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b0b64c7c4..6a7995de8 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1035,6 +1035,15 @@ interface MaestroAPI { confirmQuit: () => void; cancelQuit: () => void; onSystemResume: (callback: () => 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: { @@ -1315,7 +1324,12 @@ 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/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/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/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), }; 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 // ============================================================================