Skip to content
Draft
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
31 changes: 31 additions & 0 deletions app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@
import { createShellWindow } from './window';
import { createTray, refreshTrayMenu } from './tray';
// Track B — Pill + hotkeys
import { createPillWindow, togglePill, showPill, hidePill, sendToPill, setPillHeight, PILL_HEIGHT_COLLAPSED, PILL_HEIGHT_EXPANDED } from './pill';

Check warning on line 64 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'./pill' imported multiple times

Check warning on line 64 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'showPill' is defined but never used. Allowed unused vars must match /^_/u
import { createLogsWindow, attachToHub as attachLogsToHub, toggleLogs, hideLogs, getLogsWindow, showLogs, setLogsMode, updateLogsAnchor, focusLogsFollowUp } from './logsPill';
import * as takeoverOverlay from './takeoverOverlay';
import { sendSessionNotification } from './notifications';
import { registerHotkeys, unregisterHotkeys, getGlobalCmdbarAccelerator, setGlobalCmdbarAccelerator } from './hotkeys';
import { makeRequest, PROTOCOL_VERSION } from '../shared/types';

Check warning on line 69 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'PROTOCOL_VERSION' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 69 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'makeRequest' is defined but never used. Allowed unused vars must match /^_/u
import type { AgentEvent } from '../shared/types';

Check warning on line 70 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'AgentEvent' is defined but never used. Allowed unused vars must match /^_/u
// Identity
import { AccountStore } from './identity/AccountStore';
import { createOnboardingWindow } from './identity/onboardingWindow';
Expand All @@ -92,7 +92,7 @@
import { bootstrapHarness, harnessDir } from './hl/harness';
import { runEngine, DEFAULT_ENGINE_ID } from './hl/engines';
import { getEngine, setEngine, type EngineId } from './hl/engine';
import { forwardAgentEvent } from './pill';

Check warning on line 95 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'./pill' imported multiple times

Check warning on line 95 in app/src/main/index.ts

View workflow job for this annotation

GitHub Actions / Lint (TS)

'forwardAgentEvent' is defined but never used. Allowed unused vars must match /^_/u
// Session management
import { SessionManager } from './sessions/SessionManager';
import { BrowserPool } from './sessions/BrowserPool';
Expand Down Expand Up @@ -188,6 +188,11 @@
browserPool.setOnNavigate((sessionId, url) => {
sessionManager.updateNavigationFromUrl(sessionId, url);
});
browserPool.setOnNavigationState((sessionId, state) => {
if (shellWindow && !shellWindow.isDestroyed()) {
shellWindow.webContents.send('sessions:navigation-state', sessionId, state);
}
});
const accountStore = new AccountStore();
const whatsAppAdapter = new WhatsAppAdapter();
const channelRouter = new ChannelRouter(sessionManager, whatsAppAdapter);
Expand Down Expand Up @@ -1395,6 +1400,32 @@
return browserPool.getTabs(validatedId);
});

ipcMain.handle('sessions:get-navigation-state', (_event, id: string) => {
const validatedId = assertString(id, 'id', 100);
return browserPool.getNavigationState(validatedId);
});

ipcMain.handle('sessions:navigate', async (_event, id: string, input: string) => {
const validatedId = assertString(id, 'id', 100);
const validatedInput = assertString(input, 'input', 4096);
return browserPool.navigate(validatedId, validatedInput);
});

ipcMain.handle('sessions:back', (_event, id: string) => {
const validatedId = assertString(id, 'id', 100);
return browserPool.goBack(validatedId);
});

ipcMain.handle('sessions:forward', (_event, id: string) => {
const validatedId = assertString(id, 'id', 100);
return browserPool.goForward(validatedId);
});

ipcMain.handle('sessions:reload', (_event, id: string) => {
const validatedId = assertString(id, 'id', 100);
return browserPool.reload(validatedId);
});

ipcMain.handle('sessions:pool-stats', () => {
return browserPool.getStats();
});
Expand Down
104 changes: 104 additions & 0 deletions app/src/main/sessions/BrowserPool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { WebContentsView, type BrowserWindow, type WebContents } from 'electron';
import { browserLogger } from '../logger';
import type { TabInfo } from './types';
import { normalizeBrowserNavigationInput } from '../../shared/browser-navigation';

const DEFAULT_BROWSER_WIDTH = 1280;
const DEFAULT_BROWSER_HEIGHT = 800;
Expand Down Expand Up @@ -28,12 +29,25 @@ interface PoolEntry {
emulatedWidth: number;
}

export interface BrowserNavigationState {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
}

export type BrowserNavigationResult =
| { ok: true; url?: string }
| { ok: false; error: string };

export class BrowserPool {
private entries: Map<string, PoolEntry> = new Map();
private maxConcurrent: number;
private queue: string[] = [];
private onGone?: (sessionId: string) => void;
private onNavigate?: (sessionId: string, url: string) => void;
private onNavigationState?: (sessionId: string, state: BrowserNavigationState) => void;

constructor(maxConcurrent = DEFAULT_MAX_CONCURRENT) {
this.maxConcurrent = maxConcurrent;
Expand All @@ -54,6 +68,10 @@ export class BrowserPool {
this.onNavigate = listener;
}

setOnNavigationState(listener: (sessionId: string, state: BrowserNavigationState) => void): void {
this.onNavigationState = listener;
}

private notifyGone(sessionId: string): void {
try { this.onGone?.(sessionId); } catch (err) {
browserLogger.warn('BrowserPool.notifyGone.listenerError', { sessionId, error: (err as Error).message });
Expand All @@ -66,6 +84,15 @@ export class BrowserPool {
}
}

private notifyNavigationState(sessionId: string): void {
try {
const state = this.getNavigationState(sessionId);
if (state) this.onNavigationState?.(sessionId, state);
} catch (err) {
browserLogger.warn('BrowserPool.notifyNavigationState.listenerError', { sessionId, error: (err as Error).message });
}
}

get activeCount(): number {
return this.entries.size;
}
Expand Down Expand Up @@ -316,6 +343,7 @@ export class BrowserPool {
pid: wc.getOSProcessId(),
wcId: wc.id,
});
this.notifyNavigationState(sessionId);
});
wc.on('did-redirect-navigation', (_event, url, isInPlace, isMainFrame, frameProcessId, frameRoutingId) => {
if (!isMainFrame) return;
Expand Down Expand Up @@ -357,6 +385,7 @@ export class BrowserPool {
wcId: wc.id,
});
this.notifyNavigate(sessionId, url);
this.notifyNavigationState(sessionId);
});
wc.on('did-finish-load', () => {
if (!currentNavigation) return;
Expand All @@ -375,6 +404,7 @@ export class BrowserPool {
wcId: wc.id,
});
currentNavigation = null;
this.notifyNavigationState(sessionId);
});
wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) return;
Expand All @@ -395,6 +425,13 @@ export class BrowserPool {
wcId: wc.id,
});
if (errorCode !== -3) currentNavigation = null;
this.notifyNavigationState(sessionId);
});
wc.on('did-stop-loading', () => {
this.notifyNavigationState(sessionId);
});
wc.on('page-title-updated', () => {
this.notifyNavigationState(sessionId);
});
// SPA/hash navigation — pushState, replaceState, hash changes. Many
// sites (x.com, linkedin, gmail) never fire did-navigate after the
Expand All @@ -414,6 +451,7 @@ export class BrowserPool {
wcId: wc.id,
});
this.notifyNavigate(sessionId, url);
this.notifyNavigationState(sessionId);
}
});

Expand All @@ -438,6 +476,72 @@ export class BrowserPool {
return entry?.view ?? null;
}

getNavigationState(sessionId: string): BrowserNavigationState | null {
const wc = this.getWebContents(sessionId);
if (!wc) return null;

const readable = wc as WebContents & {
canGoBack?: () => boolean;
canGoForward?: () => boolean;
isLoadingMainFrame?: () => boolean;
isLoading?: () => boolean;
};
return {
url: wc.getURL() || 'about:blank',
title: wc.getTitle() || 'New Tab',
canGoBack: Boolean(readable.canGoBack?.()),
canGoForward: Boolean(readable.canGoForward?.()),
isLoading: Boolean(readable.isLoadingMainFrame?.() ?? readable.isLoading?.() ?? false),
};
}

async navigate(sessionId: string, input: string): Promise<BrowserNavigationResult> {
const wc = this.getWebContents(sessionId);
if (!wc) return { ok: false, error: 'Browser is not available.' };

const normalized = normalizeBrowserNavigationInput(input);
if (!normalized.ok) return normalized;

try {
await wc.loadURL(normalized.url);
this.notifyNavigationState(sessionId);
return { ok: true, url: normalized.url };
} catch (err) {
const error = (err as Error).message || 'Navigation failed.';
browserLogger.warn('BrowserPool.navigate.failed', { sessionId, url: normalized.url, error });
this.notifyNavigationState(sessionId);
return { ok: false, error };
}
}

goBack(sessionId: string): BrowserNavigationResult {
const wc = this.getWebContents(sessionId);
if (!wc) return { ok: false, error: 'Browser is not available.' };
const nav = wc as WebContents & { canGoBack?: () => boolean; goBack?: () => void };
if (!nav.canGoBack?.()) return { ok: false, error: 'No page to go back to.' };
nav.goBack?.();
this.notifyNavigationState(sessionId);
return { ok: true };
}

goForward(sessionId: string): BrowserNavigationResult {
const wc = this.getWebContents(sessionId);
if (!wc) return { ok: false, error: 'Browser is not available.' };
const nav = wc as WebContents & { canGoForward?: () => boolean; goForward?: () => void };
if (!nav.canGoForward?.()) return { ok: false, error: 'No page to go forward to.' };
nav.goForward?.();
this.notifyNavigationState(sessionId);
return { ok: true };
}

reload(sessionId: string): BrowserNavigationResult {
const wc = this.getWebContents(sessionId);
if (!wc) return { ok: false, error: 'Browser is not available.' };
wc.reload();
this.notifyNavigationState(sessionId);
return { ok: true };
}

/** Center + shrink the view rect inside the hub box if the emulated
* viewport is narrower than the rect (after zoom-to-fit on height).
* Otherwise the rect is used as-is. */
Expand Down
50 changes: 50 additions & 0 deletions app/src/preload/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
const raw = await ipcRenderer.invoke('sessions:get-tabs', id);
return validateTabs(raw);
},
getNavigationState: (id: string): Promise<{
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
} | null> => ipcRenderer.invoke('sessions:get-navigation-state', id),
navigate: (id: string, input: string): Promise<{ ok: boolean; url?: string; error?: string }> =>
ipcRenderer.invoke('sessions:navigate', id, input),
back: (id: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('sessions:back', id),
forward: (id: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('sessions:forward', id),
reload: (id: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('sessions:reload', id),
poolStats: async (): Promise<BrowserPoolStats> => {
const raw = await ipcRenderer.invoke('sessions:pool-stats');
return validatePoolStats(raw);
Expand Down Expand Up @@ -356,6 +371,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('sessions:browser-gone', handler);
return () => ipcRenderer.removeListener('sessions:browser-gone', handler);
},
sessionNavigationState: (cb: (id: string, state: {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
}) => void): (() => void) => {
const handler = (_event: unknown, id: string, state: unknown) => {
if (typeof id !== 'string' || !state || typeof state !== 'object') return;
const raw = state as {
url?: unknown;
title?: unknown;
canGoBack?: unknown;
canGoForward?: unknown;
isLoading?: unknown;
};
if (
typeof raw.url === 'string' &&
typeof raw.title === 'string' &&
typeof raw.canGoBack === 'boolean' &&
typeof raw.canGoForward === 'boolean' &&
typeof raw.isLoading === 'boolean'
) {
cb(id, {
url: raw.url,
title: raw.title,
canGoBack: raw.canGoBack,
canGoForward: raw.canGoForward,
isLoading: raw.isLoading,
});
}
};
ipcRenderer.on('sessions:navigation-state', handler);
return () => ipcRenderer.removeListener('sessions:navigation-state', handler);
},
sessionOutput: (cb: (id: string, event: HlEvent) => void): (() => void) => {
const handler = (_event: unknown, id: string, raw: unknown) => {
try {
Expand Down
14 changes: 14 additions & 0 deletions app/src/renderer/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,24 @@ interface ElectronSessionAPI {
viewsSetVisible: (visible: boolean) => Promise<void>;
viewsDetachAll: () => Promise<void>;
getTabs: (id: string) => Promise<unknown[]>;
getNavigationState: (id: string) => Promise<BrowserNavigationState | null>;
navigate: (id: string, input: string) => Promise<{ ok: boolean; url?: string; error?: string }>;
back: (id: string) => Promise<{ ok: boolean; error?: string }>;
forward: (id: string) => Promise<{ ok: boolean; error?: string }>;
reload: (id: string) => Promise<{ ok: boolean; error?: string }>;
poolStats: () => Promise<unknown>;
memory: () => Promise<{ totalMb: number; sessions: Array<{ id: string; mb: number; status: string }>; processes: Array<{ label: string; type: string; mb: number; sessionId?: string }>; processCount: number }>;
getTermReplay: (id: string) => Promise<string>;
}

interface BrowserNavigationState {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
}

interface ElectronChannelsAPI {
whatsapp: {
connect: () => Promise<{ status: string }>;
Expand Down Expand Up @@ -142,6 +155,7 @@ interface ElectronChromeImportAPI {
interface ElectronOnAPI {
sessionUpdated: (cb: (session: import('./hub/types').AgentSession) => void) => () => void;
sessionBrowserGone: (cb: (id: string) => void) => () => void;
sessionNavigationState: (cb: (id: string, state: BrowserNavigationState) => void) => () => void;
sessionOutput: (cb: (id: string, event: import('./hub/types').HlEvent) => void) => () => void;
sessionOutputTerm: (cb: (id: string, bytes: string) => void) => () => void;
openSettings?: (cb: (payload?: { focusBrowserCodeProvider?: string }) => void) => () => void;
Expand Down
Loading
Loading