Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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=<sessionId>&cwd=<path>`, the app switches to the corresponding tab/pane.

## URL Format

```
enso://focus?session=<sessionId>&cwd=<path>
```

### 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<string, string | null>` keyed by normalized cwd
- `setActiveId(cwd, sessionId)` updates the active session for a specific worktree
- Tab switching uses existing `setActiveTab('chat')` mechanism
66 changes: 66 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -157,6 +158,31 @@ function parseEnsoUrl(url: string): string | null {
return null;
}

// Parse focus URL (enso://focus?session=<id>)
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();
Expand All @@ -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, '');
Expand All @@ -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) {
Expand All @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> =>
ipcRenderer.invoke(IPC_CHANNELS.APP_SET_LANGUAGE, language),
setProxy: (settings: ProxySettings): Promise<void> =>
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
useClaudeProviderListener,
useCodeReviewContinue,
useFileDragDrop,
useFocusSession,
useGroupSync,
useMenuActions,
useMergeState,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/renderer/App/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/App/hooks/useFocusSession.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
1 change: 1 addition & 0 deletions src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading