diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 0fe5a8be..55782a9b 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -188,6 +188,11 @@ browserPool.setOnGone((sessionId) => { 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); @@ -1395,6 +1400,32 @@ app.whenReady().then(async () => { 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(); }); diff --git a/app/src/main/sessions/BrowserPool.ts b/app/src/main/sessions/BrowserPool.ts index 16129a68..da985037 100644 --- a/app/src/main/sessions/BrowserPool.ts +++ b/app/src/main/sessions/BrowserPool.ts @@ -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; @@ -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 = 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; @@ -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 }); @@ -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; } @@ -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; @@ -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; @@ -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; @@ -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 @@ -414,6 +451,7 @@ export class BrowserPool { wcId: wc.id, }); this.notifyNavigate(sessionId, url); + this.notifyNavigationState(sessionId); } }); @@ -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 { + 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. */ diff --git a/app/src/preload/shell.ts b/app/src/preload/shell.ts index b8dd1069..d04d45bc 100644 --- a/app/src/preload/shell.ts +++ b/app/src/preload/shell.ts @@ -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 => { const raw = await ipcRenderer.invoke('sessions:pool-stats'); return validatePoolStats(raw); @@ -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 { diff --git a/app/src/renderer/globals.d.ts b/app/src/renderer/globals.d.ts index a2cdadb8..8adc1d41 100644 --- a/app/src/renderer/globals.d.ts +++ b/app/src/renderer/globals.d.ts @@ -71,11 +71,24 @@ interface ElectronSessionAPI { viewsSetVisible: (visible: boolean) => Promise; viewsDetachAll: () => Promise; getTabs: (id: string) => Promise; + getNavigationState: (id: string) => Promise; + 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; 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; } +interface BrowserNavigationState { + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +} + interface ElectronChannelsAPI { whatsapp: { connect: () => Promise<{ status: string }>; @@ -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; diff --git a/app/src/renderer/hub/AgentPane.tsx b/app/src/renderer/hub/AgentPane.tsx index 8c07ca2f..8f935931 100644 --- a/app/src/renderer/hub/AgentPane.tsx +++ b/app/src/renderer/hub/AgentPane.tsx @@ -572,6 +572,31 @@ function ResumeIcon(): React.ReactElement { ); } +function BackIcon(): React.ReactElement { + return ( + + ); +} + +function ForwardIcon(): React.ReactElement { + return ( + + ); +} + +function ReloadIcon(): React.ReactElement { + return ( + + ); +} + interface FollowUpAttachment { idx: number; name: string; mime: string; bytes: Uint8Array } async function fileToAttachment(file: File, idx: number): Promise { @@ -741,6 +766,116 @@ function CloseIcon(): React.ReactElement { ); } +function BrowserAddressBar({ + sessionId, + disabled, + state, +}: { + sessionId: string; + disabled: boolean; + state: BrowserNavigationState | null; +}): React.ReactElement { + const [value, setValue] = useState(state?.url ?? ''); + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!editing) setValue(state?.url ?? ''); + }, [editing, state?.url]); + + const runNavigationCommand = useCallback(async (command: 'back' | 'forward' | 'reload') => { + const api = window.electronAPI?.sessions; + if (!api) return; + setError(null); + setBusy(true); + try { + const result = await api[command](sessionId); + if (!result.ok) setError(result.error ?? 'Navigation failed.'); + } catch (err) { + setError((err as Error).message || 'Navigation failed.'); + } finally { + setBusy(false); + } + }, [sessionId]); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + const api = window.electronAPI?.sessions; + if (!api) return; + setError(null); + setBusy(true); + try { + const result = await api.navigate(sessionId, value); + if (!result.ok) { + setError(result.error ?? 'Navigation failed.'); + } else if (result.url) { + setValue(result.url); + } + } catch (err) { + setError((err as Error).message || 'Navigation failed.'); + } finally { + setBusy(false); + } + }, [sessionId, value]); + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + + {busy || state?.isLoading ? : null} + {error && {error}} + + ); +} + interface AgentPaneProps { session: AgentSession; focused?: boolean; @@ -763,6 +898,7 @@ export function AgentPane({ session, focused, onRerun, onResume, onFollowUp, onD const [browserDead, setBrowserDead] = useState(false); const [browserMissing, setBrowserMissing] = useState(false); const [frameRect, setFrameRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); + const [navigationState, setNavigationState] = useState(null); // Logs overlay is a separate window (see logsPill.ts). The pane tracks // visibility only to reflect it in the Logs button's active state. const [logsOpen, setLogsOpen] = useState(false); @@ -803,6 +939,28 @@ export function AgentPane({ session, focused, onRerun, onResume, onFollowUp, onD } }, [session.id, session.status]); + useEffect(() => { + setNavigationState(null); + const api = window.electronAPI; + if (!api?.sessions?.getNavigationState) return; + let cancelled = false; + void api.sessions.getNavigationState(session.id).then((state) => { + if (!cancelled) setNavigationState(state); + }).catch(() => { + if (!cancelled) setNavigationState(null); + }); + return () => { cancelled = true; }; + }, [session.id]); + + useEffect(() => { + const api = window.electronAPI; + if (!api?.on?.sessionNavigationState) return; + const off = api.on.sessionNavigationState((id, state) => { + if (id === session.id) setNavigationState(state); + }); + return off; + }, [session.id]); + useEffect(() => { const api = window.electronAPI; if (!api?.on?.sessionBrowserGone) return; @@ -1168,6 +1326,14 @@ export function AgentPane({ session, focused, onRerun, onResume, onFollowUp, onD {session.status === 'running' &&
}
+ {session.status !== 'draft' && ( + + )} + {frameRect && (showErrorUi || browserDead || browserMissing || session.status === 'draft' || session.status === 'stopped' || session.status === 'idle' || session.status === 'stuck') && (() => { const isStarting = !showErrorUi && !browserDead && !browserMissing && session.status === 'draft'; const browserLine = browserDead diff --git a/app/src/renderer/hub/hub.css b/app/src/renderer/hub/hub.css index 04026344..5a9533b5 100644 --- a/app/src/renderer/hub/hub.css +++ b/app/src/renderer/hub/hub.css @@ -1192,6 +1192,111 @@ button:focus:not(:focus-visible) { animation: progress-slide 2.2s ease-in-out infinite; } +.pane__address { + display: grid; + grid-template-columns: 28px 28px 28px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + background-color: var(--color-bg-base); + flex-shrink: 0; +} + +.pane__address-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--color-fg-tertiary); + transition: + color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out); +} + +.pane__address-btn:hover:not(:disabled) { + color: var(--color-fg-primary); + background-color: var(--color-surface-interactive-hover); +} + +.pane__address-btn:disabled { + color: var(--color-fg-disabled); + cursor: not-allowed; +} + +.pane__address-field { + min-width: 0; + height: 32px; + display: grid; + grid-template-columns: minmax(80px, max-content) minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + padding: 0 var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background-color: var(--color-bg-sunken); +} + +.pane__address-field:focus-within { + border-color: var(--color-border-default); + background-color: var(--color-bg-elevated); +} + +.pane__address-title { + min-width: 0; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-2xs); + color: var(--color-fg-tertiary); +} + +.pane__address-input { + min-width: 0; + width: 100%; + border: 0; + outline: none; + background: transparent; + color: var(--color-fg-primary); + font-family: var(--font-ui); + font-size: var(--font-size-xs); +} + +.pane__address-input::placeholder { + color: var(--color-fg-disabled); +} + +.pane__address-input:disabled { + color: var(--color-fg-disabled); + cursor: not-allowed; +} + +.pane__address-loading { + width: 12px; + height: 12px; + border: 1.5px solid var(--color-border-subtle); + border-top-color: var(--color-fg-tertiary); + border-radius: var(--radius-full); + animation: spin 0.9s linear infinite; +} + +.pane__address-error { + grid-column: 4 / 6; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-2xs); + color: var(--color-status-error); +} + +.pane__address--error .pane__address-field { + border-color: var(--color-status-error); +} + @keyframes progress-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(500%); } diff --git a/app/src/shared/browser-navigation.ts b/app/src/shared/browser-navigation.ts new file mode 100644 index 00000000..f155a89f --- /dev/null +++ b/app/src/shared/browser-navigation.ts @@ -0,0 +1,55 @@ +export type NormalizedNavigation = + | { ok: true; url: string; kind: 'url' | 'search' } + | { ok: false; error: string }; + +const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:', 'file:']); +const SEARCH_URL = 'https://www.google.com/search?q='; + +function hasScheme(input: string): boolean { + return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(input); +} + +function isLikelyHost(input: string): boolean { + if (/\s/.test(input)) return false; + if (/^localhost(?::\d+)?(?:[/?#].*)?$/i.test(input)) return true; + if (/^\[[0-9a-f:]+\](?::\d+)?(?:[/?#].*)?$/i.test(input)) return true; + if (/^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:[/?#].*)?$/.test(input)) return true; + return /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?(?:[/?#].*)?$/i.test(input); +} + +function validateUrl(raw: string): NormalizedNavigation { + try { + const parsed = new URL(raw); + if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) { + return { ok: false, error: `Unsupported URL scheme: ${parsed.protocol.replace(':', '')}` }; + } + if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && !parsed.hostname) { + return { ok: false, error: 'Enter a valid web address.' }; + } + return { ok: true, url: parsed.toString(), kind: 'url' }; + } catch { + return { ok: false, error: 'Enter a valid web address.' }; + } +} + +export function normalizeBrowserNavigationInput(input: string): NormalizedNavigation { + const trimmed = input.trim(); + if (!trimmed) return { ok: false, error: 'Enter a URL or search query.' }; + + if (isLikelyHost(trimmed)) { + const scheme = /^localhost(?::|[/?#]|$)/i.test(trimmed) || /^\d{1,3}(?:\.\d{1,3}){3}/.test(trimmed) + ? 'http://' + : 'https://'; + return validateUrl(`${scheme}${trimmed}`); + } + + if (hasScheme(trimmed)) { + return validateUrl(trimmed); + } + + return { + ok: true, + url: `${SEARCH_URL}${encodeURIComponent(trimmed)}`, + kind: 'search', + }; +} diff --git a/app/tests/fixtures/electron-mock.ts b/app/tests/fixtures/electron-mock.ts index d0484b9c..092ec4c8 100644 --- a/app/tests/fixtures/electron-mock.ts +++ b/app/tests/fixtures/electron-mock.ts @@ -244,14 +244,40 @@ let webContentsIdCounter = 1; function createMockWebContents() { const id = webContentsIdCounter++; + let currentUrl = 'about:blank'; + let title = 'New Tab'; + let canGoBack = false; + let canGoForward = false; + let loadError: Error | null = null; return { id, - getURL: (): string => 'about:blank', - getTitle: (): string => 'New Tab', + getURL: (): string => currentUrl, + getTitle: (): string => title, getOSProcessId: (): number => 10000 + id, + canGoBack: (): boolean => canGoBack, + canGoForward: (): boolean => canGoForward, + goBack: (): void => { canGoForward = true; }, + goForward: (): void => { canGoBack = true; }, + reload: (): void => undefined, + isLoadingMainFrame: (): boolean => false, + isDestroyed: (): boolean => false, setFrameRate: (_fps: number): void => undefined, setBackgroundThrottling: (_throttle: boolean): void => undefined, - loadURL: (_url: string): Promise => Promise.resolve(), + loadURL: (url: string): Promise => { + if (loadError) return Promise.reject(loadError); + currentUrl = url; + title = url === 'about:blank' ? 'New Tab' : url; + canGoBack = true; + canGoForward = false; + return Promise.resolve(); + }, + __setNavigationState: (next: { url?: string; title?: string; canGoBack?: boolean; canGoForward?: boolean; loadError?: Error | null }): void => { + if (next.url !== undefined) currentUrl = next.url; + if (next.title !== undefined) title = next.title; + if (next.canGoBack !== undefined) canGoBack = next.canGoBack; + if (next.canGoForward !== undefined) canGoForward = next.canGoForward; + if ('loadError' in next) loadError = next.loadError ?? null; + }, close: (): void => undefined, on: (): void => undefined, off: (): void => undefined, diff --git a/app/tests/unit/hub/AgentPane.spec.tsx b/app/tests/unit/hub/AgentPane.spec.tsx new file mode 100644 index 00000000..8de4efc1 --- /dev/null +++ b/app/tests/unit/hub/AgentPane.spec.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentPane } from '../../../src/renderer/hub/AgentPane'; +import type { AgentSession } from '../../../src/renderer/hub/types'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = class { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +}; + +vi.mock('../../../src/renderer/hub/useSessionsQuery', () => ({ + useHydrateSession: vi.fn(), +})); + +function makeSession(): AgentSession { + return { + id: 'session-1', + createdAt: Date.now(), + status: 'idle', + prompt: 'Browse manually', + output: [], + hasBrowser: true, + }; +} + +function installApi(overrides?: Partial): { + navigate: ReturnType; + back: ReturnType; + forward: ReturnType; + reload: ReturnType; + emitNavigation: (state: BrowserNavigationState) => void; +} { + let navigationHandler: ((id: string, state: BrowserNavigationState) => void) | null = null; + const navigate = vi.fn(async () => ({ ok: true, url: 'https://example.com/' })); + const back = vi.fn(async () => ({ ok: true })); + const forward = vi.fn(async () => ({ ok: true })); + const reload = vi.fn(async () => ({ ok: true })); + + (window as unknown as { electronAPI: Partial }).electronAPI = { + sessions: { + get: vi.fn(async () => null), + viewDetach: vi.fn(async () => true), + getNavigationState: vi.fn(async () => ({ + url: 'https://start.example/', + title: 'Start', + canGoBack: false, + canGoForward: true, + isLoading: false, + })), + navigate, + back, + forward, + reload, + ...overrides, + } as Partial as ElectronSessionAPI, + on: { + sessionNavigationState: (cb: (id: string, state: BrowserNavigationState) => void) => { + navigationHandler = cb; + return () => { navigationHandler = null; }; + }, + } as Partial as ElectronOnAPI, + }; + + return { + navigate, + back, + forward, + reload, + emitNavigation: (state) => navigationHandler?.('session-1', state), + }; +} + +function renderPane(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(); + }); + return { container, root }; +} + +function setInput(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (!setter) throw new Error('Missing input value setter'); + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +describe('AgentPane browser address bar', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + it('renders navigation state and submits manual navigation input', async () => { + const api = installApi(); + const { container, root } = renderPane(); + + await act(async () => { + await Promise.resolve(); + }); + + const input = container.querySelector('.pane__address-input'); + if (!input) throw new Error('Missing address input'); + expect(input.value).toBe('https://start.example/'); + + await act(async () => { + setInput(input, 'example.com'); + input.form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + }); + + expect(api.navigate).toHaveBeenCalledWith('session-1', 'example.com'); + + act(() => root.unmount()); + }); + + it('updates address, title, and history controls from browser state events', async () => { + const api = installApi(); + const { container, root } = renderPane(); + + await act(async () => { + await Promise.resolve(); + api.emitNavigation({ + url: 'https://next.example/page', + title: 'Next Page', + canGoBack: true, + canGoForward: false, + isLoading: false, + }); + }); + + const input = container.querySelector('.pane__address-input'); + const back = container.querySelector('button[aria-label="Back"]'); + const forward = container.querySelector('button[aria-label="Forward"]'); + const title = container.querySelector('.pane__address-title'); + + expect(input?.value).toBe('https://next.example/page'); + expect(title?.textContent).toBe('Next Page'); + expect(back?.disabled).toBe(false); + expect(forward?.disabled).toBe(true); + + act(() => root.unmount()); + }); + + it('shows invalid navigation feedback from the IPC result', async () => { + installApi({ + navigate: vi.fn(async () => ({ ok: false, error: 'Unsupported URL scheme: javascript' })), + }); + const { container, root } = renderPane(); + + await act(async () => { + await Promise.resolve(); + }); + + const input = container.querySelector('.pane__address-input'); + if (!input) throw new Error('Missing address input'); + + await act(async () => { + setInput(input, 'javascript:alert(1)'); + input.form?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Unsupported URL scheme: javascript'); + + act(() => root.unmount()); + }); +}); diff --git a/app/tests/unit/sessions/BrowserPool.test.ts b/app/tests/unit/sessions/BrowserPool.test.ts index aec27fa3..26997250 100644 --- a/app/tests/unit/sessions/BrowserPool.test.ts +++ b/app/tests/unit/sessions/BrowserPool.test.ts @@ -198,6 +198,70 @@ describe('BrowserPool — getTabs', () => { }); }); +describe('BrowserPool — manual navigation', () => { + let pool: BrowserPool; + + beforeEach(() => { pool = new BrowserPool(5); }); + afterEach(() => { pool.destroyAll(); }); + + it('normalizes and dispatches typed URLs to the active browser', async () => { + pool.create('s1'); + + const result = await pool.navigate('s1', 'example.com/docs'); + const state = pool.getNavigationState('s1'); + + expect(result).toMatchObject({ ok: true, url: 'https://example.com/docs' }); + expect(state).toMatchObject({ + url: 'https://example.com/docs', + title: 'https://example.com/docs', + canGoBack: true, + canGoForward: false, + }); + }); + + it('normalizes plain text into search navigation', async () => { + pool.create('s1'); + + const result = await pool.navigate('s1', 'browser use desktop'); + + expect(result).toMatchObject({ + ok: true, + url: 'https://www.google.com/search?q=browser%20use%20desktop', + }); + }); + + it('returns clear errors for invalid input and navigation failures', async () => { + pool.create('s1'); + const wc = pool.getWebContents('s1') as unknown as { + __setNavigationState: (state: { loadError?: Error | null }) => void; + }; + + expect(await pool.navigate('s1', 'javascript:alert(1)')).toMatchObject({ + ok: false, + error: 'Unsupported URL scheme: javascript', + }); + + wc.__setNavigationState({ loadError: new Error('DNS failed') }); + expect(await pool.navigate('s1', 'https://example.com')).toMatchObject({ + ok: false, + error: 'DNS failed', + }); + }); + + it('dispatches history and reload controls', async () => { + pool.create('s1'); + const wc = pool.getWebContents('s1') as unknown as { + __setNavigationState: (state: { canGoBack?: boolean; canGoForward?: boolean }) => void; + }; + + wc.__setNavigationState({ canGoBack: true, canGoForward: true }); + + expect(pool.goBack('s1')).toMatchObject({ ok: true }); + expect(pool.goForward('s1')).toMatchObject({ ok: true }); + expect(pool.reload('s1')).toMatchObject({ ok: true }); + }); +}); + describe('BrowserPool — fitted resize', () => { let pool: BrowserPool; let win: any; diff --git a/app/tests/unit/shared/browser-navigation.test.ts b/app/tests/unit/shared/browser-navigation.test.ts new file mode 100644 index 00000000..16451dcf --- /dev/null +++ b/app/tests/unit/shared/browser-navigation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeBrowserNavigationInput } from '../../../src/shared/browser-navigation'; + +describe('normalizeBrowserNavigationInput', () => { + it('preserves explicit http and https URLs', () => { + expect(normalizeBrowserNavigationInput('https://example.com/docs').ok).toBe(true); + expect(normalizeBrowserNavigationInput('http://localhost:3000/path')).toMatchObject({ + ok: true, + url: 'http://localhost:3000/path', + kind: 'url', + }); + }); + + it('adds a web scheme to host-like input', () => { + expect(normalizeBrowserNavigationInput('example.com')).toMatchObject({ + ok: true, + url: 'https://example.com/', + kind: 'url', + }); + expect(normalizeBrowserNavigationInput('localhost:5173')).toMatchObject({ + ok: true, + url: 'http://localhost:5173/', + kind: 'url', + }); + }); + + it('turns non-url text into a search URL', () => { + expect(normalizeBrowserNavigationInput('browser use desktop')).toMatchObject({ + ok: true, + url: 'https://www.google.com/search?q=browser%20use%20desktop', + kind: 'search', + }); + }); + + it('rejects empty and unsupported URL inputs', () => { + expect(normalizeBrowserNavigationInput('')).toMatchObject({ ok: false }); + expect(normalizeBrowserNavigationInput('javascript:alert(1)')).toMatchObject({ + ok: false, + error: 'Unsupported URL scheme: javascript', + }); + expect(normalizeBrowserNavigationInput('http://')).toMatchObject({ ok: false }); + }); +});