From 3626120976c6d6334d5ffdcb7d65244d5769af3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=B7=8D?= Date: Fri, 10 Apr 2026 10:30:16 +0800 Subject: [PATCH 1/2] feat(app): support enso://focus URL scheme for session switching Adds URL scheme endpoint to receive session focus requests: - URL format: enso://focus?session=&cwd= - Parses focus URLs in main process via parseFocusUrl() - Sends APP_FOCUS_SESSION IPC to renderer - Renderer uses useFocusSession hook to: - Focus session by sessionId via agentSessionsStore - Switch to chat tab - Fallback to worktree switch if only cwd provided - Silently ignore if session not found Co-Authored-By: Claude Opus 4.6 --- ...6-04-10-url-scheme-focus-session-design.md | 87 +++++++++++++++++++ src/main/index.ts | 68 +++++++++++++++ src/preload/index.ts | 8 ++ src/renderer/App.tsx | 5 ++ src/renderer/App/hooks/index.ts | 1 + src/renderer/App/hooks/useFocusSession.ts | 48 ++++++++++ src/shared/types/ipc.ts | 1 + 7 files changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-url-scheme-focus-session-design.md create mode 100644 src/renderer/App/hooks/useFocusSession.ts 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..cc272a23 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,33 @@ function parseEnsoUrl(url: string): string | null { return null; } +// Parse focus URL (enso://focus?session=&cwd=) +interface FocusSessionParams { + sessionId?: string; + cwd?: 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') ?? undefined; + const cwd = parsed.searchParams.get('cwd') ?? undefined; + if (sessionId || cwd) { + return { sessionId, cwd }; + } + } + } + } catch { + // Invalid URL + } + return null; +} + // Send open path event to renderer function sendOpenPath(path: string): void { const windows = BrowserWindow.getAllWindows(); @@ -174,6 +202,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 +237,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 +257,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 +767,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..07fce8bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -506,6 +506,14 @@ const electronAPI = { ipcRenderer.on(IPC_CHANNELS.APP_OPEN_PATH, handler); return () => ipcRenderer.off(IPC_CHANNELS.APP_OPEN_PATH, handler); }, + onFocusSession: ( + callback: (params: { sessionId?: string; cwd?: string }) => void + ): (() => void) => { + const handler = (_: unknown, params: { sessionId?: string; cwd?: 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..6315634c --- /dev/null +++ b/src/renderer/App/hooks/useFocusSession.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; +import { useAgentSessionsStore } from '@/stores/agentSessions'; +import type { TabId } from '../constants'; + +interface FocusSessionParams { + sessionId?: string; + cwd?: 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, cwd } = params; + + // If sessionId provided, try to focus that session + if (sessionId) { + const sessions = useAgentSessionsStore.getState().sessions; + const session = sessions.find((s) => s.id === sessionId); + + if (session) { + // Use session's cwd if no cwd provided + const targetCwd = cwd || session.cwd; + // Activate the session + useAgentSessionsStore.getState().setActiveId(targetCwd, sessionId); + // Switch to chat tab + onSwitchTab('chat'); + return; + } + // Session not found - silently ignore if cwd also not provided + if (!cwd) { + return; + } + } + + // If cwd provided (and no session or session not found), switch worktree + if (cwd) { + onSwitchWorktree(cwd); + } + }); + + 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', From 8be0b7860b99c29b51d67e9118fa51d66f3285c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=B7=8D?= Date: Fri, 10 Apr 2026 16:01:32 +0800 Subject: [PATCH 2/2] fix: simplify focus session URL handling Remove cwd parameter from focus URL parsing, now only sessionId is required. When switching sessions, use the session's own cwd instead of accepting an override. Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 12 ++++---- src/preload/index.ts | 7 ++--- src/renderer/App/hooks/useFocusSession.ts | 34 ++++++----------------- 3 files changed, 16 insertions(+), 37 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index cc272a23..4e3049d2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -158,10 +158,9 @@ function parseEnsoUrl(url: string): string | null { return null; } -// Parse focus URL (enso://focus?session=&cwd=) +// Parse focus URL (enso://focus?session=) interface FocusSessionParams { - sessionId?: string; - cwd?: string; + sessionId: string; } function parseFocusUrl(url: string): FocusSessionParams | null { @@ -172,10 +171,9 @@ function parseFocusUrl(url: string): FocusSessionParams | null { const pathname = parsed.pathname; // Match //focus or host === 'focus' if (pathname === '//focus' || host === 'focus') { - const sessionId = parsed.searchParams.get('session') ?? undefined; - const cwd = parsed.searchParams.get('cwd') ?? undefined; - if (sessionId || cwd) { - return { sessionId, cwd }; + const sessionId = parsed.searchParams.get('session'); + if (sessionId) { + return { sessionId }; } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 07fce8bd..27ced539 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -506,11 +506,8 @@ const electronAPI = { ipcRenderer.on(IPC_CHANNELS.APP_OPEN_PATH, handler); return () => ipcRenderer.off(IPC_CHANNELS.APP_OPEN_PATH, handler); }, - onFocusSession: ( - callback: (params: { sessionId?: string; cwd?: string }) => void - ): (() => void) => { - const handler = (_: unknown, params: { sessionId?: string; cwd?: string }) => - callback(params); + 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); }, diff --git a/src/renderer/App/hooks/useFocusSession.ts b/src/renderer/App/hooks/useFocusSession.ts index 6315634c..bc6a517a 100644 --- a/src/renderer/App/hooks/useFocusSession.ts +++ b/src/renderer/App/hooks/useFocusSession.ts @@ -3,8 +3,7 @@ import { useAgentSessionsStore } from '@/stores/agentSessions'; import type { TabId } from '../constants'; interface FocusSessionParams { - sessionId?: string; - cwd?: string; + sessionId: string; } interface UseFocusSessionOptions { @@ -15,31 +14,16 @@ interface UseFocusSessionOptions { export function useFocusSession({ onSwitchWorktree, onSwitchTab }: UseFocusSessionOptions) { useEffect(() => { const cleanup = window.electronAPI.app.onFocusSession((params: FocusSessionParams) => { - const { sessionId, cwd } = params; + const { sessionId } = params; - // If sessionId provided, try to focus that session - if (sessionId) { - const sessions = useAgentSessionsStore.getState().sessions; - const session = sessions.find((s) => s.id === sessionId); + const sessions = useAgentSessionsStore.getState().sessions; + const session = sessions.find((s) => s.id === sessionId); - if (session) { - // Use session's cwd if no cwd provided - const targetCwd = cwd || session.cwd; - // Activate the session - useAgentSessionsStore.getState().setActiveId(targetCwd, sessionId); - // Switch to chat tab - onSwitchTab('chat'); - return; - } - // Session not found - silently ignore if cwd also not provided - if (!cwd) { - return; - } - } - - // If cwd provided (and no session or session not found), switch worktree - if (cwd) { - onSwitchWorktree(cwd); + 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'); } });