diff --git a/docs/superpowers/specs/2026-04-10-url-scheme-focus-session-design.md b/docs/superpowers/specs/2026-04-10-url-scheme-focus-session-design.md new file mode 100644 index 00000000..95c8c671 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-url-scheme-focus-session-design.md @@ -0,0 +1,87 @@ +# URL Scheme Focus Session — Design + +## Overview + +Extend the existing `enso://` protocol to support focusing specific sessions via URL. When a user opens `enso://focus?session=&cwd=`, the app switches to the corresponding tab/pane. + +## URL Format + +``` +enso://focus?session=&cwd= +``` + +### Parameters + +| Parameter | Description | +|-----------|-------------| +| `session` | Session ID to focus (matches `session.id` in `agentSessionsStore`) | +| `cwd` | Worktree path for the session (used as key for `activeIds`) | + +### URL Matching + +- `parsed.host === 'focus'` or `parsed.pathname === '//focus'` + +## Architecture + +### Main Process (src/main/index.ts) + +**Existing Protocol Registration:** +```typescript +app.setAsDefaultProtocolClient('enso'); +app.on('open-url', (event, url) => { ... }); +handleCommandLineArgs(argv); // handles enso:// URLs +``` + +**Modified `parseEnsoUrl()`:** +- Parse `session` and `cwd` from URL searchParams +- If `host === 'focus'` or `pathname === '//focus'`, extract session/cwd +- Send via IPC channel `APP_FOCUS_SESSION` + +### IPC Channel + +**File:** `src/shared/types/ipc.ts` + +```typescript +APP_FOCUS_SESSION: 'app:focusSession' +``` + +**Payload:** +```typescript +interface FocusSessionPayload { + sessionId?: string; + cwd?: string; +} +``` + +### Renderer (src/renderer) + +**New Hook:** `src/renderer/App/hooks/useFocusSession.ts` +- Listen for `APP_FOCUS_SESSION` IPC event +- If `sessionId` exists: find session in `agentSessionsStore`, call `setActiveId(cwd, sessionId)` +- If `sessionId` not found but `cwd` exists: fallback to switching worktree via `handleSwitchWorktreePath` +- If neither found: silently ignore +- Switch to `chat` tab after focusing session + +## Behavior + +| Parameters | Behavior | +|------------|----------| +| `session` only | Find and focus session by ID, switch to chat tab | +| `session` + `cwd` | Focus session within specified worktree | +| `cwd` only | Switch to specified worktree | +| Neither found | Silently ignore | + +## Files to Change + +1. **src/shared/types/ipc.ts** — Add `APP_FOCUS_SESSION` channel +2. **src/main/index.ts** — Modify `parseEnsoUrl()` to extract session/cwd, send IPC +3. **src/preload/index.ts** — Expose `onFocusSession` API in `electronAPI.app` +4. **src/renderer/App/hooks/useFocusSession.ts** — New hook for focus logic +5. **src/renderer/App.tsx** — Call `useFocusSession` hook + +## Implementation Notes + +- Session lookup uses `useAgentSessionsStore` which stores all sessions indexed by `id` +- Active session per worktree is tracked in `activeIds: Record` keyed by normalized cwd +- `setActiveId(cwd, sessionId)` updates the active session for a specific worktree +- Tab switching uses existing `setActiveTab('chat')` mechanism diff --git a/src/main/index.ts b/src/main/index.ts index 6f9b9914..4e3049d2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -69,6 +69,7 @@ import { openLocalWindow } from './windows/WindowManager'; let mainWindow: BrowserWindow | null = null; let pendingOpenPath: string | null = null; +let pendingFocusSession: FocusSessionParams | null = null; let cleanupWindowHandlers: (() => void) | null = null; let isQuittingCleanupRunning = false; @@ -157,6 +158,31 @@ function parseEnsoUrl(url: string): string | null { return null; } +// Parse focus URL (enso://focus?session=) +interface FocusSessionParams { + sessionId: string; +} + +function parseFocusUrl(url: string): FocusSessionParams | null { + try { + const parsed = new URL(url); + if (parsed.protocol === 'enso:') { + const host = parsed.host; + const pathname = parsed.pathname; + // Match //focus or host === 'focus' + if (pathname === '//focus' || host === 'focus') { + const sessionId = parsed.searchParams.get('session'); + if (sessionId) { + return { sessionId }; + } + } + } + } catch { + // Invalid URL + } + return null; +} + // Send open path event to renderer function sendOpenPath(path: string): void { const windows = BrowserWindow.getAllWindows(); @@ -174,6 +200,24 @@ function sendOpenPath(path: string): void { } } +// Send focus session event to renderer +function sendFocusSession(params: FocusSessionParams): void { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const win = windows[0]; + win.focus(); + if (win.webContents.isLoading()) { + // Store for later - overwrite any pending path since focus is more specific + pendingOpenPath = null; + pendingFocusSession = params; + } else { + win.webContents.send(IPC_CHANNELS.APP_FOCUS_SESSION, params); + } + } else { + pendingFocusSession = params; + } +} + // Sanitize path: remove trailing slashes/backslashes and stray quotes (Windows CMD issue) function sanitizePath(path: string): string { return path.replace(/[\\/]+$/, '').replace(/^["']|["']$/g, ''); @@ -191,6 +235,13 @@ function handleCommandLineArgs(argv: string[]): void { return; } if (arg.startsWith('enso://')) { + // Check for focus URL first + const focusParams = parseFocusUrl(arg); + if (focusParams) { + sendFocusSession(focusParams); + return; + } + // Fall back to path-based URL const rawPath = parseEnsoUrl(arg); const path = rawPath ? sanitizePath(rawPath) : null; if (path) { @@ -204,6 +255,17 @@ function handleCommandLineArgs(argv: string[]): void { // macOS: Handle open-url event app.on('open-url', (event, url) => { event.preventDefault(); + // Check for focus URL first + const focusParams = parseFocusUrl(url); + if (focusParams) { + if (app.isReady()) { + sendFocusSession(focusParams); + } else { + pendingFocusSession = focusParams; + } + return; + } + // Fall back to path-based URL const path = parseEnsoUrl(url); if (path) { if (app.isReady()) { @@ -703,6 +765,10 @@ app.whenReady().then(async () => { mainWindow?.webContents.send(IPC_CHANNELS.APP_OPEN_PATH, pendingOpenPath); pendingOpenPath = null; } + if (pendingFocusSession) { + mainWindow?.webContents.send(IPC_CHANNELS.APP_FOCUS_SESSION, pendingFocusSession); + pendingFocusSession = null; + } }); // Initialize auto-updater diff --git a/src/preload/index.ts b/src/preload/index.ts index 448b5a15..27ced539 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -506,6 +506,11 @@ const electronAPI = { ipcRenderer.on(IPC_CHANNELS.APP_OPEN_PATH, handler); return () => ipcRenderer.off(IPC_CHANNELS.APP_OPEN_PATH, handler); }, + onFocusSession: (callback: (params: { sessionId: string }) => void): (() => void) => { + const handler = (_: unknown, params: { sessionId: string }) => callback(params); + ipcRenderer.on(IPC_CHANNELS.APP_FOCUS_SESSION, handler); + return () => ipcRenderer.off(IPC_CHANNELS.APP_FOCUS_SESSION, handler); + }, setLanguage: (language: Locale): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP_SET_LANGUAGE, language), setProxy: (settings: ProxySettings): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index eba769c9..9c89149d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,6 +24,7 @@ import { useClaudeProviderListener, useCodeReviewContinue, useFileDragDrop, + useFocusSession, useGroupSync, useMenuActions, useMergeState, @@ -569,6 +570,10 @@ export default function App() { useGroupSync(hideGroups, activeGroupId, setActiveGroupId, saveActiveGroupId); useOpenPathListener(true, repositories, saveRepositories, setSelectedRepoState); + useFocusSession({ + onSwitchWorktree: (path) => switchWorktreePathRef.current?.(path), + onSwitchTab: handleTabChange, + }); useClaudeIntegration(activeWorktree?.path ?? null, true); useCodeReviewContinue(activeWorktree, handleTabChange); useWorktreeSync(worktrees, activeWorktree, worktreesFetching, setActiveWorktree); diff --git a/src/renderer/App/hooks/index.ts b/src/renderer/App/hooks/index.ts index df4f0d7b..a9a94e90 100644 --- a/src/renderer/App/hooks/index.ts +++ b/src/renderer/App/hooks/index.ts @@ -4,6 +4,7 @@ export { useClaudeIntegration } from './useClaudeIntegration'; export { useClaudeProviderListener } from './useClaudeProviderListener'; export { useCodeReviewContinue } from './useCodeReviewContinue'; export { useFileDragDrop } from './useFileDragDrop'; +export { useFocusSession } from './useFocusSession'; export { useGroupSync } from './useGroupSync'; export { useMenuActions } from './useMenuActions'; export { useMergeState } from './useMergeState'; diff --git a/src/renderer/App/hooks/useFocusSession.ts b/src/renderer/App/hooks/useFocusSession.ts new file mode 100644 index 00000000..bc6a517a --- /dev/null +++ b/src/renderer/App/hooks/useFocusSession.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; +import { useAgentSessionsStore } from '@/stores/agentSessions'; +import type { TabId } from '../constants'; + +interface FocusSessionParams { + sessionId: string; +} + +interface UseFocusSessionOptions { + onSwitchWorktree: (path: string) => void; + onSwitchTab: (tab: TabId) => void; +} + +export function useFocusSession({ onSwitchWorktree, onSwitchTab }: UseFocusSessionOptions) { + useEffect(() => { + const cleanup = window.electronAPI.app.onFocusSession((params: FocusSessionParams) => { + const { sessionId } = params; + + const sessions = useAgentSessionsStore.getState().sessions; + const session = sessions.find((s) => s.id === sessionId); + + if (session) { + // Switch to the session's worktree first, then set active session (same as RunningProjectsPopover) + onSwitchWorktree(session.cwd); + useAgentSessionsStore.getState().setActiveId(session.cwd, sessionId); + onSwitchTab('chat'); + } + }); + + return cleanup; + }, [onSwitchWorktree, onSwitchTab]); +} diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index cfd58e89..21e7c0e2 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -142,6 +142,7 @@ export const IPC_CHANNELS = { APP_CLOSE_SAVE_REQUEST: 'app:closeSaveRequest', APP_CLOSE_SAVE_RESPONSE: 'app:closeSaveResponse', APP_OPEN_PATH: 'app:openPath', + APP_FOCUS_SESSION: 'app:focusSession', APP_SET_LANGUAGE: 'app:setLanguage', APP_SET_PROXY: 'app:setProxy', APP_TEST_PROXY: 'app:testProxy',