diff --git a/.claude/settings.local.json b/.claude/settings.local.json index caa7f13..698ec71 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,29 +1,11 @@ { "permissions": { "allow": [ - "WebSearch", - "Bash(pnpm init:*)", - "Bash(npm init:*)", - "Bash(npm install:*)", - "Bash(npx tailwindcss:*)", - "Bash(git init:*)", - "Bash(npm run dev:*)", + "Bash(git commit:*)", "Bash(npm run build:*)", - "Bash(find:*)", - "Bash(npm uninstall:*)", - "Read(//c/Users/jasie/OneDrive/Pictures/Screenshots/**)", - "Bash(npx electron-builder install-app-deps)", - "Bash(npx electron-rebuild -f -w better-sqlite3)", - "Bash(git log:*)", - "Bash(npm run lint)", - "Bash(npm run lint:*)", - "Bash(curl:*)", - "Bash(unzip:*)", - "Bash(chmod:*)", - "Bash(npm run format:*)", - "Bash(npx electron-rebuild:*)", - "WebFetch(domain:ollama.com)", - "Bash(npm run lint:fix:*)" + "Bash(git add:*)", + "WebSearch", + "WebFetch(domain:www.electronjs.org)" ], "deny": [], "ask": [] diff --git a/src/main/index.ts b/src/main/index.ts index 18585b7..89aaadc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { databaseService } from './services/database'; import { ollamaService } from './services/ollama'; import { downloadService } from './services/download'; +import { tabWindowManager } from './services/tabWindowManager'; import { registerIpcHandlers } from './ipc/handlers'; // Polyfill __dirname for ESM @@ -301,6 +302,12 @@ app.whenReady().then(async () => { createWindow(); + // Initialize tab window manager after main window is created + if (mainWindow) { + tabWindowManager.initialize(mainWindow); + console.log('[Main] TabWindowManager initialized'); + } + // On macOS, re-create window when dock icon is clicked and no windows are open app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -329,6 +336,14 @@ async function performCleanup(): Promise { cleanupPerformed = true; console.log('[Main] Performing cleanup...'); + try { + // Cleanup tab windows + tabWindowManager.cleanup(); + console.log('[Main] Tab windows cleaned up'); + } catch (error) { + console.error('[Main] Error cleaning up tab windows:', error); + } + try { // Stop Ollama service first await ollamaService.stop(); @@ -547,7 +562,9 @@ app.on('web-contents-created', (event, contents) => { ); console.log('[Context Menu] User selected save path:', savePath); if (savePath) { - console.log('[Context Menu] Registering custom save path and triggering download'); + console.log( + '[Context Menu] Registering custom save path and triggering download' + ); // Register the custom save path for this URL downloadService.setCustomSavePath(linkUrl, savePath); // Trigger download - will use the custom path diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ed64d0d..a2ce54a 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -10,6 +10,7 @@ import { import { ollamaService } from '../services/ollama'; import { captureService } from '../services/capture'; import { downloadService } from '../services/download'; +import { tabWindowManager } from '../services/tabWindowManager'; import { createDownloadManagerWindow } from '../index'; import type { GenerateOptions, @@ -377,17 +378,21 @@ export function registerIpcHandlers() { ipcMain.handle('capture:forVision', async (_event) => { try { - // Find the active webview (browser tab) instead of the chat window - const allWebContents = webContents.getAllWebContents(); - const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); - - if (!webviewContents) { + // Get active tab window + const activeTabId = tabWindowManager.getActiveTabId(); + if (!activeTabId) { // Gracefully return null if no active tab - user might have context toggle on in empty tab console.log('capture:forVision: No active browser tab, skipping context capture'); return null; } - return await captureService.captureForVision(webviewContents); + const tabWebContents = tabWindowManager.getTabWebContents(activeTabId); + if (!tabWebContents) { + console.log('capture:forVision: Could not access tab contents'); + return null; + } + + return await captureService.captureForVision(tabWebContents); } catch (error: any) { console.error('capture:forVision error:', error.message); // Return null instead of throwing - let the chat continue without context @@ -397,18 +402,21 @@ export function registerIpcHandlers() { ipcMain.handle('capture:forText', async (_event) => { try { - // Find the active webview (browser tab) instead of the chat window - // Webviews have type 'webview' and are guest windows - const allWebContents = webContents.getAllWebContents(); - const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); - - if (!webviewContents) { + // Get active tab window + const activeTabId = tabWindowManager.getActiveTabId(); + if (!activeTabId) { // Gracefully return null if no active tab - user might have context toggle on in empty tab console.log('capture:forText: No active browser tab, skipping context capture'); return null; } - return await captureService.captureForText(webviewContents); + const tabWebContents = tabWindowManager.getTabWebContents(activeTabId); + if (!tabWebContents) { + console.log('capture:forText: Could not access tab contents'); + return null; + } + + return await captureService.captureForText(tabWebContents); } catch (error: any) { console.error('capture:forText error:', error.message); // Return null instead of throwing - let the chat continue without context @@ -741,17 +749,20 @@ When Planning Mode is enabled, you have access to these tools: ipcMain.handle('tool:analyze_page_content', async (_event) => { try { - // Find the active webview (browser tab) instead of the main window - const allWebContents = webContents.getAllWebContents(); - const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); - - if (!webviewContents) { + // Get active tab window + const activeTabId = tabWindowManager.getActiveTabId(); + if (!activeTabId) { throw new Error( 'No browser tab is currently open. Please open a webpage first, then try again.' ); } - const capture = await captureService.capturePage(webviewContents, { + const tabWebContents = tabWindowManager.getTabWebContents(activeTabId); + if (!tabWebContents) { + throw new Error('Could not access tab contents'); + } + + const capture = await captureService.capturePage(tabWebContents, { includeScreenshot: false, extractReadable: true, }); @@ -770,17 +781,20 @@ When Planning Mode is enabled, you have access to these tools: ipcMain.handle('tool:capture_screenshot', async (_event) => { try { - // Find the active webview (browser tab) instead of the main window - const allWebContents = webContents.getAllWebContents(); - const webviewContents = allWebContents.find((wc) => wc.getType() === 'webview'); - - if (!webviewContents) { + // Get active tab window + const activeTabId = tabWindowManager.getActiveTabId(); + if (!activeTabId) { throw new Error( 'No browser tab is currently open. Please open a webpage first, then try again.' ); } - const screenshot = await captureService.captureScreenshot(webviewContents); + const tabWebContents = tabWindowManager.getTabWebContents(activeTabId); + if (!tabWebContents) { + throw new Error('Could not access tab contents'); + } + + const screenshot = await captureService.captureScreenshot(tabWebContents); return { screenshot }; } catch (error: any) { console.error('tool:capture_screenshot error:', error.message); @@ -1241,4 +1255,275 @@ When Planning Mode is enabled, you have access to these tools: throw error; } }); + + // Tab window handlers (BrowserWindow-based tabs) + ipcMain.handle('tabWindow:create', async (_event, tabId: string, url: string) => { + try { + validateString(tabId, 'Tab ID', 256); + if (url) { + validateUrl(url, 'URL'); + } + const tab = tabWindowManager.createTab(tabId, url); + return { + id: tab.id, + url: tab.url, + title: tab.title, + favicon: tab.favicon, + }; + } catch (error: any) { + console.error('tabWindow:create error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:close', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.closeTab(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:close error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:setActive', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.setActiveTab(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:setActive error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:navigate', async (_event, tabId: string, url: string) => { + try { + validateString(tabId, 'Tab ID', 256); + validateUrl(url, 'URL'); + tabWindowManager.navigateTab(tabId, url); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:navigate error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:goBack', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.goBack(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:goBack error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:goForward', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.goForward(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:goForward error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:reload', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.reload(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:reload error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:stop', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + tabWindowManager.stop(tabId); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:stop error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:getInfo', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const tab = tabWindowManager.getTabInfo(tabId); + if (!tab) { + throw new Error(`Tab not found: ${tabId}`); + } + return { + id: tab.id, + url: tab.url, + title: tab.title, + favicon: tab.favicon, + isActive: tab.isActive, + }; + } catch (error: any) { + console.error('tabWindow:getInfo error:', error.message); + throw error; + } + }); + + // DevTools handler + ipcMain.handle('tabWindow:openDevTools', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const webContents = tabWindowManager.getTabWebContents(tabId); + if (webContents) { + webContents.openDevTools(); + return { success: true }; + } + throw new Error(`Tab not found: ${tabId}`); + } catch (error: any) { + console.error('tabWindow:openDevTools error:', error.message); + throw error; + } + }); + + // Print handler + ipcMain.handle('tabWindow:print', async (_event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const webContents = tabWindowManager.getTabWebContents(tabId); + if (webContents) { + webContents.print(); + return { success: true }; + } + throw new Error(`Tab not found: ${tabId}`); + } catch (error: any) { + console.error('tabWindow:print error:', error.message); + throw error; + } + }); + + // Zoom handlers + ipcMain.handle('tabWindow:zoomIn', async (event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const webContents = tabWindowManager.getTabWebContents(tabId); + if (webContents) { + const currentZoom = webContents.getZoomLevel(); + const newZoom = currentZoom + 0.5; + webContents.setZoomLevel(newZoom); + + // Save zoom level per-origin (like Chrome) + const currentUrl = webContents.getURL(); + try { + const origin = new URL(currentUrl).origin; + databaseService.setZoomLevel(origin, newZoom); + } catch (_err) { + console.warn('[Zoom] Invalid URL, cannot save zoom preference:', currentUrl); + } + + // Send zoom level update to renderer + const mainWindow = BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send('tab-zoom-changed', { + tabId, + zoomLevel: newZoom, + zoomFactor: webContents.getZoomFactor(), + }); + } + + return { success: true, zoomLevel: newZoom }; + } + throw new Error(`Tab not found: ${tabId}`); + } catch (error: any) { + console.error('tabWindow:zoomIn error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:zoomOut', async (event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const webContents = tabWindowManager.getTabWebContents(tabId); + if (webContents) { + const currentZoom = webContents.getZoomLevel(); + const newZoom = currentZoom - 0.5; + webContents.setZoomLevel(newZoom); + + // Save zoom level per-origin (like Chrome) + const currentUrl = webContents.getURL(); + try { + const origin = new URL(currentUrl).origin; + databaseService.setZoomLevel(origin, newZoom); + } catch (_err) { + console.warn('[Zoom] Invalid URL, cannot save zoom preference:', currentUrl); + } + + // Send zoom level update to renderer + const mainWindow = BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send('tab-zoom-changed', { + tabId, + zoomLevel: newZoom, + zoomFactor: webContents.getZoomFactor(), + }); + } + + return { success: true, zoomLevel: newZoom }; + } + throw new Error(`Tab not found: ${tabId}`); + } catch (error: any) { + console.error('tabWindow:zoomOut error:', error.message); + throw error; + } + }); + + ipcMain.handle('tabWindow:resetZoom', async (event, tabId: string) => { + try { + validateString(tabId, 'Tab ID', 256); + const webContents = tabWindowManager.getTabWebContents(tabId); + if (webContents) { + webContents.setZoomLevel(0); + + // Save zoom level per-origin (like Chrome) - reset to 0 (100%) + const currentUrl = webContents.getURL(); + try { + const origin = new URL(currentUrl).origin; + databaseService.setZoomLevel(origin, 0); + } catch (_err) { + console.warn('[Zoom] Invalid URL, cannot save zoom preference:', currentUrl); + } + + // Send zoom level update to renderer + const mainWindow = BrowserWindow.getAllWindows()[0]; + if (mainWindow) { + mainWindow.webContents.send('tab-zoom-changed', { + tabId, + zoomLevel: 0, + zoomFactor: 1.0, + }); + } + + return { success: true, zoomLevel: 0 }; + } + throw new Error(`Tab not found: ${tabId}`); + } catch (error: any) { + console.error('tabWindow:resetZoom error:', error.message); + throw error; + } + }); + + // Hide/show active tab view (for modals and overlays) + ipcMain.handle('tabWindow:setActiveVisible', async (_event, visible: boolean) => { + try { + tabWindowManager.setActiveTabVisible(visible); + return { success: true }; + } catch (error: any) { + console.error('tabWindow:setActiveVisible error:', error.message); + throw error; + } + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index dbff3a2..f971cbb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -73,6 +73,21 @@ const ALLOWED_INVOKE_CHANNELS = [ 'agreement:check', 'agreement:accept', 'app:quit', + 'tabWindow:create', + 'tabWindow:close', + 'tabWindow:setActive', + 'tabWindow:navigate', + 'tabWindow:goBack', + 'tabWindow:goForward', + 'tabWindow:reload', + 'tabWindow:stop', + 'tabWindow:getInfo', + 'tabWindow:openDevTools', + 'tabWindow:print', + 'tabWindow:zoomIn', + 'tabWindow:zoomOut', + 'tabWindow:resetZoom', + 'tabWindow:setActiveVisible', ]; const ALLOWED_LISTEN_CHANNELS = [ @@ -89,6 +104,21 @@ const ALLOWED_LISTEN_CHANNELS = [ 'ai-summarize-selection', 'download:progress', 'download:complete', + 'tab-title-updated', + 'tab-favicon-updated', + 'tab-loading-start', + 'tab-loading-stop', + 'tab-did-navigate', + 'tab-did-navigate-in-page', + 'tab-request-new', + 'tab-load-error', + 'tab-activated', + 'tab-crashed', + 'tab-unresponsive', + 'tab-responsive', + 'tab-certificate-error', + 'tab-context-menu', + 'tab-zoom-changed', ]; // Expose protected methods that allow the renderer process to use diff --git a/src/main/services/database.ts b/src/main/services/database.ts index cb270fe..a7db957 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -185,6 +185,17 @@ class DatabaseService { CREATE INDEX IF NOT EXISTS idx_downloads_state ON downloads(state); `); + // Zoom preferences table (per-domain zoom levels like Chrome) + this.db.exec(` + CREATE TABLE IF NOT EXISTS zoom_preferences ( + origin TEXT PRIMARY KEY, + zoom_level REAL NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_zoom_updated ON zoom_preferences(updated_at DESC); + `); + // Initialize default system prompt if not exists const systemPrompt = this.getSetting('system-prompt'); if (!systemPrompt) { @@ -713,6 +724,43 @@ class DatabaseService { } } + // Zoom preferences operations (per-origin like Chrome) + getZoomLevel(origin: string): number | null { + if (!this.db) throw new Error('Database not initialized'); + + const result = this.db + .prepare('SELECT zoom_level FROM zoom_preferences WHERE origin = ?') + .get(origin) as { zoom_level: number } | undefined; + + return result ? result.zoom_level : null; + } + + setZoomLevel(origin: string, zoomLevel: number): void { + if (!this.db) throw new Error('Database not initialized'); + + this.db + .prepare( + ` + INSERT INTO zoom_preferences (origin, zoom_level, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(origin) DO UPDATE SET + zoom_level = excluded.zoom_level, + updated_at = excluded.updated_at + ` + ) + .run(origin, zoomLevel, Date.now()); + } + + deleteZoomLevel(origin: string): void { + if (!this.db) throw new Error('Database not initialized'); + this.db.prepare('DELETE FROM zoom_preferences WHERE origin = ?').run(origin); + } + + clearZoomPreferences(): void { + if (!this.db) throw new Error('Database not initialized'); + this.db.prepare('DELETE FROM zoom_preferences').run(); + } + close() { this.db?.close(); } diff --git a/src/main/services/download.ts b/src/main/services/download.ts index 3457c02..bdd5b36 100644 --- a/src/main/services/download.ts +++ b/src/main/services/download.ts @@ -136,7 +136,7 @@ class DownloadService { /** * Handle download item and save to database */ - handleDownload(item: DownloadItem, savePath: string, webContents: Electron.WebContents): number { + handleDownload(item: DownloadItem, savePath: string, _webContents?: unknown): number { const filename = path.basename(savePath); // Create download record in database diff --git a/src/main/services/ollama.ts b/src/main/services/ollama.ts index 8758b96..88f9381 100644 --- a/src/main/services/ollama.ts +++ b/src/main/services/ollama.ts @@ -1210,7 +1210,9 @@ export class OllamaService { */ async *chat( request: ChatRequest - ): AsyncGenerator { + ): AsyncGenerator< + string | { type: 'tool_calls'; tool_calls: any[] } | { type: 'thinking'; content: string } + > { await this.ensureRunning(); // Track if thinking mode is enabled diff --git a/src/main/services/tabWindowManager.ts b/src/main/services/tabWindowManager.ts new file mode 100644 index 0000000..c40cc03 --- /dev/null +++ b/src/main/services/tabWindowManager.ts @@ -0,0 +1,535 @@ +import { BrowserWindow, WebContents, WebContentsView } from 'electron'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { databaseService } from './database'; + +// Polyfill __dirname for ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export interface TabWindow { + id: string; + view: WebContentsView | null; // Null until URL is loaded + url: string; + title: string; + favicon: string; + isActive: boolean; +} + +/** + * TabWindowManager + * Manages WebContentsView instances for each tab - the Chrome pattern + * Each tab is a separate WebContentsView that gets shown/hidden based on active state + * WebContentsViews are properly embedded within the parent window (not floating) + */ +class TabWindowManager { + private tabWindows: Map = new Map(); + private mainWindow: BrowserWindow | null = null; + private activeTabId: string | null = null; + + /** + * Initialize the manager with the main window reference + */ + initialize(mainWindow: BrowserWindow) { + this.mainWindow = mainWindow; + console.log('[TabWindowManager] Initialized with main window'); + + // Listen for main window close to cleanup tab windows + mainWindow.on('close', () => { + console.log('[TabWindowManager] Main window closing, cleaning up tab windows'); + this.cleanup(); + }); + + // Listen for main window resize/move to update tab window positions + mainWindow.on('resize', () => this.updateAllTabWindowBounds()); + mainWindow.on('move', () => this.updateAllTabWindowBounds()); + mainWindow.on('maximize', () => this.updateAllTabWindowBounds()); + mainWindow.on('unmaximize', () => this.updateAllTabWindowBounds()); + } + + /** + * Create a new tab view + */ + createTab(tabId: string, url: string): TabWindow { + if (!this.mainWindow) { + throw new Error('TabWindowManager not initialized with main window'); + } + + console.log(`[TabWindowManager] Creating tab view for tab: ${tabId}, URL: ${url}`); + + // Create a new WebContentsView for this tab + const tabView = new WebContentsView({ + webPreferences: { + preload: path.join(__dirname, '../preload.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, // Full sandbox for security + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + const tab: TabWindow = { + id: tabId, + view: tabView, + url: url || '', + title: '', + favicon: '', + isActive: false, + }; + + this.tabWindows.set(tabId, tab); + + // Add view to main window at the bottom of the z-order (index 0) + // This ensures WebContentsViews render behind UI elements in the renderer + this.mainWindow.contentView.addChildView(tabView); + // Note: Electron doesn't provide a way to set z-index directly for WebContentsView + // They are always rendered in a separate layer above the main window's content + // We need to manage visibility and positioning to avoid covering UI + + // Setup event listeners BEFORE loading URL to catch all events + this.setupTabWindowListeners(tab); + + // Position the tab view + this.updateTabWindowBounds(tabId); + + // Start hidden - setActiveTab will make it visible + tabView.setVisible(false); + + // Load the URL after everything is set up + if (url) { + tabView.webContents.loadURL(url).catch((err) => { + console.error(`[TabWindowManager] Failed to load URL in tab ${tabId}:`, err); + }); + } + + console.log(`[TabWindowManager] Tab view created: ${tabId}`); + return tab; + } + + /** + * Extract origin from URL (protocol + hostname + port) for per-domain zoom + */ + private getOrigin(url: string): string | null { + try { + const urlObj = new URL(url); + return urlObj.origin; + } catch { + return null; + } + } + + /** + * Restore saved zoom level for a URL's origin + */ + private restoreZoomLevel(webContents: WebContents, url: string) { + const origin = this.getOrigin(url); + if (!origin) return; + + const savedZoom = databaseService.getZoomLevel(origin); + if (savedZoom !== null) { + console.log(`[TabWindowManager] Restoring zoom level ${savedZoom} for ${origin}`); + webContents.setZoomLevel(savedZoom); + } + } + + /** + * Setup event listeners for a tab view + */ + private setupTabWindowListeners(tab: TabWindow) { + const webContents = tab.view.webContents; + + // Page title updated + webContents.on('page-title-updated', (event, title) => { + tab.title = title; + this.notifyMainWindow('tab-title-updated', { + tabId: tab.id, + title, + }); + }); + + // Favicon updated + webContents.on('page-favicon-updated', (event, favicons) => { + if (favicons.length > 0) { + tab.favicon = favicons[0]; + this.notifyMainWindow('tab-favicon-updated', { + tabId: tab.id, + favicon: favicons[0], + }); + } + }); + + // Navigation events + webContents.on('did-start-loading', () => { + this.notifyMainWindow('tab-loading-start', { + tabId: tab.id, + }); + }); + + webContents.on('did-stop-loading', () => { + this.notifyMainWindow('tab-loading-stop', { + tabId: tab.id, + canGoBack: webContents.canGoBack(), + canGoForward: webContents.canGoForward(), + }); + }); + + webContents.on('did-navigate', (event, url) => { + tab.url = url; + + // Restore saved zoom level for this origin + this.restoreZoomLevel(webContents, url); + + // Show the view now that it has content + if (tab.isActive && url && url.trim() !== '') { + tab.view.setVisible(true); + } + + this.notifyMainWindow('tab-did-navigate', { + tabId: tab.id, + url, + canGoBack: webContents.canGoBack(), + canGoForward: webContents.canGoForward(), + }); + }); + + webContents.on('did-navigate-in-page', (event, url) => { + tab.url = url; + + // In-page navigation (hash changes) keeps same origin, no zoom change needed + this.notifyMainWindow('tab-did-navigate-in-page', { + tabId: tab.id, + url, + }); + }); + + // New window handling (popups, target="_blank") + webContents.setWindowOpenHandler(({ url }) => { + // Create a new tab for the new window + this.notifyMainWindow('tab-request-new', { url }); + return { action: 'deny' }; // Deny the default popup, we'll handle it + }); + + // Handle failed loads + webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { + if (errorCode === -3) return; // ERR_ABORTED is normal (user cancelled) + console.error( + `[TabWindowManager] Tab ${tab.id} failed to load:`, + errorDescription, + validatedURL + ); + this.notifyMainWindow('tab-load-error', { + tabId: tab.id, + errorCode, + errorDescription, + url: validatedURL, + }); + }); + + // Handle renderer crashes + webContents.on('render-process-gone', (event, details) => { + console.error(`[TabWindowManager] Tab ${tab.id} crashed:`, details.reason); + this.notifyMainWindow('tab-crashed', { + tabId: tab.id, + reason: details.reason, + exitCode: details.exitCode, + }); + + // If killed, try to reload + if (details.reason === 'killed' || details.reason === 'crashed') { + console.log(`[TabWindowManager] Attempting to reload crashed tab ${tab.id}`); + setTimeout(() => { + webContents.reload(); + }, 1000); + } + }); + + // Handle unresponsive renderer + webContents.on('unresponsive', () => { + console.warn(`[TabWindowManager] Tab ${tab.id} became unresponsive`); + this.notifyMainWindow('tab-unresponsive', { + tabId: tab.id, + }); + }); + + webContents.on('responsive', () => { + console.log(`[TabWindowManager] Tab ${tab.id} became responsive again`); + this.notifyMainWindow('tab-responsive', { + tabId: tab.id, + }); + }); + + // Handle certificate errors + webContents.on('certificate-error', (event, url, error, certificate, callback) => { + console.warn(`[TabWindowManager] Certificate error for ${url}: ${error}`); + // For now, deny all certificate errors (secure by default) + // TODO: Add UI to let user override + event.preventDefault(); + callback(false); + + this.notifyMainWindow('tab-certificate-error', { + tabId: tab.id, + url, + error, + }); + }); + + // Handle context menu (right-click) + webContents.on('context-menu', (event, params) => { + // Notify renderer to show context menu with params + this.notifyMainWindow('tab-context-menu', { + tabId: tab.id, + params: { + x: params.x, + y: params.y, + linkURL: params.linkURL, + srcURL: params.srcURL, + pageURL: params.pageURL, + frameURL: params.frameURL, + selectionText: params.selectionText, + isEditable: params.isEditable, + mediaType: params.mediaType, + }, + }); + }); + } + + /** + * Calculate the bounds for tab views based on main window + * WebContentsViews are positioned below the tab bar and navigation bar + * Note: WebContentsView renders in a native layer above the renderer's DOM, + * so we cannot use CSS z-index. We must position it to avoid covering UI elements. + * + * Strategy: Reserve space for sidebars AND modals to ensure all UI is accessible. + * Modals appear centered, so we need margins on all sides. + */ + private getTabWindowBounds(): { x: number; y: number; width: number; height: number } { + if (!this.mainWindow) { + return { x: 0, y: 0, width: 800, height: 600 }; + } + + // Get the full window size + const [width, height] = this.mainWindow.getSize(); + + // TabBar height: ~40px (py-2 with border) + // NavigationBar height: ~48px (typical navbar height) + // Total offset from top: ~88px + const UI_TOP_HEIGHT = 88; + + // Sidebar width: 320px (w-80 = 20rem * 16px) + // Reserve space on the right for sidebars (ChatSidebar, HistorySidebar, BookmarksSidebar) + const SIDEBAR_WIDTH = 320; + + // Bottom margin: 60px for download status bar and to avoid covering bottom modals + const BOTTOM_MARGIN = 60; + + // Position WebContentsView below the UI elements and to the left of sidebars + // Keep bottom margin for status bars and modals + return { + x: 0, + y: UI_TOP_HEIGHT, + width: width - SIDEBAR_WIDTH, + height: height - UI_TOP_HEIGHT - BOTTOM_MARGIN, + }; + } + + /** + * Update a specific tab view's bounds + */ + private updateTabWindowBounds(tabId: string) { + const tab = this.tabWindows.get(tabId); + if (!tab) return; + + const bounds = this.getTabWindowBounds(); + tab.view.setBounds(bounds); + } + + /** + * Update all tab view bounds (called on main window resize/move) + */ + private updateAllTabWindowBounds() { + const bounds = this.getTabWindowBounds(); + this.tabWindows.forEach((tab) => { + tab.view.setBounds(bounds); + }); + } + + /** + * Switch to a different tab (show/hide views) + */ + setActiveTab(tabId: string) { + console.log(`[TabWindowManager] Switching to tab: ${tabId}`); + + // Hide current active tab + if (this.activeTabId) { + const currentTab = this.tabWindows.get(this.activeTabId); + if (currentTab) { + currentTab.isActive = false; + currentTab.view.setVisible(false); + } + } + + // Show new active tab + const newTab = this.tabWindows.get(tabId); + if (newTab) { + newTab.isActive = true; + this.updateTabWindowBounds(tabId); // Ensure correct position + + // Only show the view if it has a URL loaded + // Empty tabs (welcome screen) should not show the WebContentsView to avoid blocking UI + if (newTab.url && newTab.url.trim() !== '') { + newTab.view.setVisible(true); + } else { + newTab.view.setVisible(false); + } + + this.activeTabId = tabId; + + // Notify main window about active tab change + this.notifyMainWindow('tab-activated', { + tabId, + url: newTab.url, + title: newTab.title, + canGoBack: newTab.view.webContents.canGoBack(), + canGoForward: newTab.view.webContents.canGoForward(), + }); + } + } + + /** + * Close a tab view + */ + closeTab(tabId: string) { + console.log(`[TabWindowManager] Closing tab: ${tabId}`); + const tab = this.tabWindows.get(tabId); + if (!tab || !this.mainWindow) return; + + // If this was the active tab, we need to activate another one + const wasActive = tab.isActive; + + // Remove view from main window + this.mainWindow.contentView.removeChildView(tab.view); + this.tabWindows.delete(tabId); + + // If this was the active tab, activate another one + if (wasActive && this.tabWindows.size > 0) { + const nextTab = Array.from(this.tabWindows.values())[0]; + this.setActiveTab(nextTab.id); + } else if (this.tabWindows.size === 0) { + this.activeTabId = null; + } + } + + /** + * Navigate a tab to a URL + */ + navigateTab(tabId: string, url: string) { + const tab = this.tabWindows.get(tabId); + if (!tab) return; + + console.log(`[TabWindowManager] Navigating tab ${tabId} to: ${url}`); + tab.view.webContents.loadURL(url).catch((err) => { + console.error(`[TabWindowManager] Failed to navigate tab ${tabId}:`, err); + }); + } + + /** + * Browser controls for active tab + */ + goBack(tabId: string) { + const tab = this.tabWindows.get(tabId); + if (tab && tab.view.webContents.canGoBack()) { + tab.view.webContents.goBack(); + } + } + + goForward(tabId: string) { + const tab = this.tabWindows.get(tabId); + if (tab && tab.view.webContents.canGoForward()) { + tab.view.webContents.goForward(); + } + } + + reload(tabId: string) { + const tab = this.tabWindows.get(tabId); + if (tab) { + tab.view.webContents.reload(); + } + } + + stop(tabId: string) { + const tab = this.tabWindows.get(tabId); + if (tab) { + tab.view.webContents.stop(); + } + } + + /** + * Get a tab's webContents for operations like screenshots, etc. + */ + getTabWebContents(tabId: string): WebContents | null { + const tab = this.tabWindows.get(tabId); + return tab ? tab.view.webContents : null; + } + + /** + * Get info about a tab + */ + getTabInfo(tabId: string): TabWindow | null { + return this.tabWindows.get(tabId) || null; + } + + /** + * Get all tabs + */ + getAllTabs(): TabWindow[] { + return Array.from(this.tabWindows.values()); + } + + /** + * Get active tab ID + */ + getActiveTabId(): string | null { + return this.activeTabId; + } + + /** + * Temporarily hide/show the active tab view (for modals, overlays, etc.) + */ + setActiveTabVisible(visible: boolean) { + if (this.activeTabId) { + const activeTab = this.tabWindows.get(this.activeTabId); + if (activeTab && activeTab.view) { + activeTab.view.setVisible(visible); + console.log( + `[TabWindowManager] Active tab visibility set to: ${visible} for tab ${this.activeTabId}` + ); + } + } + } + + /** + * Send notification to main window + */ + private notifyMainWindow(channel: string, data: any) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(channel, data); + } + } + + /** + * Cleanup all tab views + */ + cleanup() { + console.log('[TabWindowManager] Cleaning up all tab views'); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.tabWindows.forEach((tab) => { + this.mainWindow!.contentView.removeChildView(tab.view); + }); + } + this.tabWindows.clear(); + this.activeTabId = null; + } +} + +export const tabWindowManager = new TabWindowManager(); diff --git a/src/renderer/components/Browser/BrowserLayout.tsx b/src/renderer/components/Browser/BrowserLayout.tsx index 5ea9343..89ec280 100644 --- a/src/renderer/components/Browser/BrowserLayout.tsx +++ b/src/renderer/components/Browser/BrowserLayout.tsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState } from 'react'; import { NavigationBar } from './NavigationBar'; -import { MultiWebViewContainer, WebViewHandle } from './MultiWebViewContainer'; +import { BrowserWindowContainer, BrowserWindowHandle } from './BrowserWindowContainer'; import { TabBar } from './TabBar'; import { ChatSidebar } from '../Chat/ChatSidebar'; import { HistorySidebar } from './HistorySidebar'; @@ -13,7 +13,7 @@ import { useTabsStore } from '../../store/tabs'; import { useModelStore } from '../../store/models'; export const BrowserLayout: React.FC = () => { - const webviewRef = useRef(null); + const browserWindowRef = useRef(null); const { toggleHistory, toggleBookmarks } = useBrowserStore(); const { tabs, activeTabId, addTab, closeTab, setActiveTab, loadTabs, suspendInactiveTabs } = useTabsStore(); @@ -131,51 +131,51 @@ export const BrowserLayout: React.FC = () => { // Ctrl/Cmd + R or F5 - Reload if (((e.ctrlKey || e.metaKey) && e.key === 'r') || e.key === 'F5') { e.preventDefault(); - webviewRef.current?.reload(); + browserWindowRef.current?.reload(); } // Ctrl/Cmd + Plus - Zoom in else if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) { e.preventDefault(); - webviewRef.current?.zoomIn(); + browserWindowRef.current?.zoomIn(); } // Ctrl/Cmd + Minus - Zoom out else if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); - webviewRef.current?.zoomOut(); + browserWindowRef.current?.zoomOut(); } // Ctrl/Cmd + 0 - Reset zoom else if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); - webviewRef.current?.resetZoom(); + browserWindowRef.current?.resetZoom(); } // Alt + Left Arrow - Back else if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); - webviewRef.current?.goBack(); + browserWindowRef.current?.goBack(); } // Alt + Right Arrow - Forward else if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); - webviewRef.current?.goForward(); + browserWindowRef.current?.goForward(); } // Ctrl/Cmd + P - Print else if ((e.ctrlKey || e.metaKey) && e.key === 'p') { e.preventDefault(); - webviewRef.current?.print(); + browserWindowRef.current?.print(); } // Ctrl/Cmd + U - View Page Source else if ((e.ctrlKey || e.metaKey) && e.key === 'u') { e.preventDefault(); - webviewRef.current?.viewSource(); + browserWindowRef.current?.viewSource(); } // F12 - Developer Tools else if (e.key === 'F12') { e.preventDefault(); - webviewRef.current?.openDevTools(); + browserWindowRef.current?.openDevTools(); } // Escape - Stop loading else if (e.key === 'Escape') { - webviewRef.current?.stop(); + browserWindowRef.current?.stop(); } }; @@ -193,36 +193,48 @@ export const BrowserLayout: React.FC = () => { ]); return ( -
- {/* Tab Bar */} - - - {/* Navigation Bar */} - - - {/* Main Content Area */} -
- {/* Multi-Tab WebView Container */} - - - {/* Sidebars (only one visible at a time) */} - - - +
+ {/* Tab Bar - captures clicks */} +
+
- {/* Modal Overlays */} - + {/* Navigation Bar - captures clicks */} +
+ +
+ + {/* Main Content Area - BrowserWindows fill entire window, UI overlays on top */} +
+ {/* BrowserWindow Container - hidden, manages windows in main process */} + + + {/* Sidebars (only one visible at a time) - capture clicks */} +
+ + + +
+
- {/* Download Status Bar */} - + {/* Modal Overlays - capture clicks */} +
+ +
+ + {/* Download Status Bar - capture clicks */} +
+ +
- {/* Download Notification Toast */} + {/* Download Notification Toast - capture clicks */} {downloadNotification && ( - setDownloadNotification(null)} - /> +
+ setDownloadNotification(null)} + /> +
)}
); diff --git a/src/renderer/components/Browser/BrowserWindowContainer.tsx b/src/renderer/components/Browser/BrowserWindowContainer.tsx new file mode 100644 index 0000000..76cb6f1 --- /dev/null +++ b/src/renderer/components/Browser/BrowserWindowContainer.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useImperativeHandle, forwardRef, useState } from 'react'; +import { useTabsStore } from '../../store/tabs'; +import { useTabWindowEvents } from '../../hooks/useTabWindowEvents'; +import { PersonalitySelector } from '../Settings/PersonalitySelector'; +import type { Personality } from '../../../shared/types'; + +// Helper function to get emoji for icon names +function getIconEmoji(iconName: string): string { + const iconMap: Record = { + briefcase: '💼', + code: '💻', + target: '🎯', + calendar: '📅', + book: '📚', + users: '👥', + 'book-open': '📖', + zap: '⚡', + palette: '🎨', + gamepad: '🎮', + smile: '😄', + 'message-circle': '💬', + image: '🖼️', + coffee: '☕', + theater: '🎭', + heart: '❤️', + compass: '🧭', + 'book-heart': '📚', + 'shield-heart': '🛡️', + sparkles: '✨', + }; + + return iconMap[iconName] || '🤖'; +} + +export interface BrowserWindowHandle { + goBack: () => void; + goForward: () => void; + reload: () => void; + stop: () => void; + openDevTools: () => void; + print: () => void; + viewSource: () => void; + zoomIn: () => void; + zoomOut: () => void; + resetZoom: () => void; +} + +/** + * BrowserWindowContainer + * This component manages the browser content area for WebContentsView-based tabs. + * Unlike WebView, the actual browser views are managed in the main process. + * This component only handles the UI overlay when there are no tabs. + */ +export const BrowserWindowContainer = forwardRef((props, ref) => { + const { tabs, activeTabId, addTab } = useTabsStore(); + const [isPersonalitySelectorOpen, setIsPersonalitySelectorOpen] = useState(false); + const [currentPersonality, setCurrentPersonality] = useState(null); + + // Listen for tab window events from main process + useTabWindowEvents(); + + // Load current personality on mount + useEffect(() => { + const loadPersonality = async () => { + try { + const personality = await window.electron.invoke('personalities:getCurrent'); + setCurrentPersonality(personality); + } catch (error) { + console.error('Failed to load current personality:', error); + } + }; + + loadPersonality(); + }, []); + + // Reload personality when selector closes + useEffect(() => { + if (!isPersonalitySelectorOpen) { + const loadPersonality = async () => { + try { + const personality = await window.electron.invoke('personalities:getCurrent'); + setCurrentPersonality(personality); + } catch (error) { + console.error('Failed to load current personality:', error); + } + }; + + loadPersonality(); + } + }, [isPersonalitySelectorOpen]); + + // Expose methods to parent via ref + useImperativeHandle(ref, () => ({ + goBack: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:goBack', activeTabId); + } catch (error) { + console.error('Failed to go back:', error); + } + } + }, + goForward: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:goForward', activeTabId); + } catch (error) { + console.error('Failed to go forward:', error); + } + } + }, + reload: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:reload', activeTabId); + } catch (error) { + console.error('Failed to reload:', error); + } + } + }, + stop: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:stop', activeTabId); + } catch (error) { + console.error('Failed to stop:', error); + } + } + }, + openDevTools: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:openDevTools', activeTabId); + } catch (error) { + console.error('Failed to open DevTools:', error); + } + } + }, + print: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:print', activeTabId); + } catch (error) { + console.error('Failed to print:', error); + } + } + }, + viewSource: () => { + const activeTab = tabs.find((t) => t.id === activeTabId); + if (activeTab?.url) { + addTab(`view-source:${activeTab.url}`); + } + }, + zoomIn: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:zoomIn', activeTabId); + } catch (error) { + console.error('Failed to zoom in:', error); + } + } + }, + zoomOut: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:zoomOut', activeTabId); + } catch (error) { + console.error('Failed to zoom out:', error); + } + } + }, + resetZoom: async () => { + if (activeTabId) { + try { + await window.electron.invoke('tabWindow:resetZoom', activeTabId); + } catch (error) { + console.error('Failed to reset zoom:', error); + } + } + }, + })); + + const activeTab = tabs.find((t) => t.id === activeTabId); + const showWelcomeScreen = !activeTab?.url; + + return ( + <> + {/* + The actual browser views fill the entire main window. + This container is just for showing the welcome screen when there are no tabs. + The WebContentsView tabs are positioned and shown/hidden by TabWindowManager. + */} +
+ {/* Welcome Screen - shown when active tab has no URL */} + {showWelcomeScreen && ( +
+
+ + + +

Welcome to Open Browser

+

+ Enter a URL or search query in the address bar to get started. +

+

+ Click the AI button to chat with local models about any page. +

+ + {/* Current Personality Display */} + {currentPersonality && ( +
+
+ {getIconEmoji(currentPersonality.icon)} +
+
+ Current AI: {currentPersonality.personName} +
+
{currentPersonality.name}
+
+
+
+ )} + + {/* Personality Selection Button */} +
+ +

+ Customize how your AI assistant talks to you +

+
+
+
+ )} +
+ + {/* Personality Selector Modal */} + setIsPersonalitySelectorOpen(false)} + /> + + ); +}); + +BrowserWindowContainer.displayName = 'BrowserWindowContainer'; diff --git a/src/renderer/components/Browser/DownloadDropdown.tsx b/src/renderer/components/Browser/DownloadDropdown.tsx index 09c2222..ee1743d 100644 --- a/src/renderer/components/Browser/DownloadDropdown.tsx +++ b/src/renderer/components/Browser/DownloadDropdown.tsx @@ -15,7 +15,7 @@ export interface Download { interface DownloadDropdownProps { isOpen: boolean; onClose: () => void; - anchorRef: React.RefObject; + anchorRef: React.RefObject>; } export const DownloadDropdown: React.FC = ({ diff --git a/src/renderer/components/Browser/MultiWebViewContainer.tsx b/src/renderer/components/Browser/MultiWebViewContainer.tsx deleted file mode 100644 index efcbbf5..0000000 --- a/src/renderer/components/Browser/MultiWebViewContainer.tsx +++ /dev/null @@ -1,562 +0,0 @@ -import React, { useEffect, useRef, useImperativeHandle, forwardRef, useState } from 'react'; -import { useBrowserStore } from '../../store/browser'; -import { useTabsStore } from '../../store/tabs'; -import { browserDataService } from '../../services/browserData'; -import { PersonalitySelector } from '../Settings/PersonalitySelector'; -import type { Personality } from '../../../shared/types'; - -// Helper function to get emoji for icon names -function getIconEmoji(iconName: string): string { - const iconMap: Record = { - briefcase: '💼', - code: '💻', - target: '🎯', - calendar: '📅', - book: '📚', - users: '👥', - 'book-open': '📖', - zap: '⚡', - palette: '🎨', - gamepad: '🎮', - smile: '😄', - 'message-circle': '💬', - image: '🖼️', - coffee: '☕', - theater: '🎭', - heart: '❤️', - compass: '🧭', - 'book-heart': '📚', - 'shield-heart': '🛡️', - sparkles: '✨', - }; - - return iconMap[iconName] || '🤖'; -} - -export interface WebViewHandle { - goBack: () => void; - goForward: () => void; - reload: () => void; - stop: () => void; - zoomIn: () => void; - zoomOut: () => void; - resetZoom: () => void; - getWebview: () => any; - openDevTools: () => void; - print: () => void; - viewSource: () => void; - inspectElement: (x?: number, y?: number) => void; -} - -export const MultiWebViewContainer = forwardRef((props, ref) => { - const { - setIsLoading, - setCanGoBack, - setCanGoForward, - setCurrentUrl, - setPageTitle, - setFavicon, - setLoadProgress, - } = useBrowserStore(); - const { tabs, activeTabId, updateTab } = useTabsStore(); - const webviewRefs = useRef>({}); - const [isPersonalitySelectorOpen, setIsPersonalitySelectorOpen] = useState(false); - const [currentPersonality, setCurrentPersonality] = useState(null); - - // Load current personality on mount - useEffect(() => { - const loadPersonality = async () => { - try { - const personality = await window.electron.invoke('personalities:getCurrent'); - setCurrentPersonality(personality); - } catch (error) { - console.error('Failed to load current personality:', error); - } - }; - - loadPersonality(); - }, []); - - // Reload personality when selector closes - useEffect(() => { - if (!isPersonalitySelectorOpen) { - const loadPersonality = async () => { - try { - const personality = await window.electron.invoke('personalities:getCurrent'); - setCurrentPersonality(personality); - } catch (error) { - console.error('Failed to load current personality:', error); - } - }; - - loadPersonality(); - } - }, [isPersonalitySelectorOpen]); - - // Get active webview - const getActiveWebview = () => { - return activeTabId ? webviewRefs.current[activeTabId] : null; - }; - - // Expose methods to parent via ref - useImperativeHandle(ref, () => ({ - goBack: () => { - try { - getActiveWebview()?.goBack(); - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - goForward: () => { - try { - getActiveWebview()?.goForward(); - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - reload: () => { - try { - getActiveWebview()?.reload(); - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - stop: () => { - try { - getActiveWebview()?.stop(); - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - zoomIn: () => { - try { - const webview = getActiveWebview(); - if (webview) { - const currentZoom = webview.getZoomFactor(); - webview.setZoomFactor(currentZoom + 0.1); - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - zoomOut: () => { - try { - const webview = getActiveWebview(); - if (webview) { - const currentZoom = webview.getZoomFactor(); - webview.setZoomFactor(Math.max(0.5, currentZoom - 0.1)); - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - resetZoom: () => { - try { - getActiveWebview()?.setZoomFactor(1.0); - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - openDevTools: () => { - try { - const webview = getActiveWebview(); - if (webview) { - webview.openDevTools(); - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - print: () => { - try { - const webview = getActiveWebview(); - if (webview) { - webview.print(); - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - viewSource: () => { - try { - const webview = getActiveWebview(); - if (webview) { - const url = webview.getURL(); - if (url) { - // Open view-source in a new tab - const { addTab } = useTabsStore.getState(); - addTab(`view-source:${url}`); - } - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - inspectElement: (x?: number, y?: number) => { - try { - const webview = getActiveWebview(); - if (webview) { - if (x !== undefined && y !== undefined) { - webview.inspectElement(x, y); - } else { - webview.openDevTools(); - } - } - } catch (e) { - console.warn('WebView not ready:', e); - } - }, - getWebview: () => getActiveWebview(), - })); - - // Setup event listeners for a webview - const setupWebviewListeners = (webview: any, tabId: string) => { - if (!webview) return; - - const isActive = () => tabId === activeTabId; - - const handleLoadStart = () => { - if (isActive()) { - setIsLoading(true); - setLoadProgress(0); - } - }; - - const handleLoadStop = () => { - if (isActive()) { - setIsLoading(false); - setLoadProgress(100); - setCanGoBack(webview.canGoBack()); - setCanGoForward(webview.canGoForward()); - setPageTitle(webview.getTitle() || ''); - } - // Update tab info - updateTab(tabId, { - title: webview.getTitle() || webview.getURL(), - }); - }; - - const handleLoadProgress = (e: any) => { - if (isActive()) { - setLoadProgress(Math.floor(e.progress * 100)); - } - }; - - const handlePageTitleUpdated = (e: any) => { - const title = e.title || ''; - if (isActive()) { - setPageTitle(title); - } - // Update tab title - updateTab(tabId, { title }); - - // Update history - const url = webview.getURL(); - if (url) { - browserDataService - .addHistory({ - url, - title: title || url, - visitTime: Date.now(), - favicon: '', - }) - .catch((err) => console.error('Failed to update history:', err)); - } - }; - - const handlePageFaviconUpdated = (e: any) => { - if (e.favicons && e.favicons.length > 0) { - const favicon = e.favicons[0]; - if (isActive()) { - setFavicon(favicon); - } - updateTab(tabId, { favicon }); - } - }; - - const handleDidNavigate = (e: any) => { - if (isActive()) { - setCurrentUrl(e.url); - setCanGoBack(webview.canGoBack()); - setCanGoForward(webview.canGoForward()); - } - - // Clear the last programmatic URL since this is a real navigation - (webview as any).__lastProgrammaticUrl = e.url; - - // Update tab URL - updateTab(tabId, { url: e.url }); - - // Save to history (but not view-source pages) - if (!e.url.startsWith('view-source:')) { - const title = webview.getTitle() || e.url; - browserDataService - .addHistory({ - url: e.url, - title, - visitTime: Date.now(), - favicon: '', - }) - .catch((err) => console.error('Failed to save history:', err)); - } - }; - - const handleDidNavigateInPage = (e: any) => { - if (!e.isMainFrame) return; - if (isActive()) { - setCurrentUrl(e.url); - } - updateTab(tabId, { url: e.url }); - }; - - const handleNewWindow = (e: any) => { - e.preventDefault(); - webview.src = e.url; - }; - - const handleDidFailLoad = (e: any) => { - if (e.errorCode === -3) return; - if (e.isMainFrame && isActive()) { - console.error('Failed to load:', e.errorDescription); - setIsLoading(false); - } - }; - - webview.addEventListener('did-start-loading', handleLoadStart); - webview.addEventListener('did-stop-loading', handleLoadStop); - webview.addEventListener('did-navigate', handleDidNavigate); - webview.addEventListener('did-navigate-in-page', handleDidNavigateInPage); - webview.addEventListener('new-window', handleNewWindow); - webview.addEventListener('did-fail-load', handleDidFailLoad); - webview.addEventListener('load-progress', handleLoadProgress); - webview.addEventListener('page-title-updated', handlePageTitleUpdated); - webview.addEventListener('page-favicon-updated', handlePageFaviconUpdated); - - return () => { - webview.removeEventListener('did-start-loading', handleLoadStart); - webview.removeEventListener('did-stop-loading', handleLoadStop); - webview.removeEventListener('did-navigate', handleDidNavigate); - webview.removeEventListener('did-navigate-in-page', handleDidNavigateInPage); - webview.removeEventListener('new-window', handleNewWindow); - webview.removeEventListener('did-fail-load', handleDidFailLoad); - webview.removeEventListener('load-progress', handleLoadProgress); - webview.removeEventListener('page-title-updated', handlePageTitleUpdated); - webview.removeEventListener('page-favicon-updated', handlePageFaviconUpdated); - }; - }; - - // Update active tab info in browser store - useEffect(() => { - const activeTab = tabs.find((t) => t.id === activeTabId); - const activeWebview = getActiveWebview(); - - if (activeWebview) { - try { - setCanGoBack(activeWebview.canGoBack()); - setCanGoForward(activeWebview.canGoForward()); - setCurrentUrl(activeWebview.getURL() || ''); - setPageTitle(activeWebview.getTitle() || ''); - } catch { - // Webview not ready yet - use tab data - setCurrentUrl(activeTab?.url || ''); - setPageTitle(activeTab?.title || ''); - setCanGoBack(false); - setCanGoForward(false); - } - } else if (activeTab) { - // No webview yet (new tab or suspended), use tab data - setCurrentUrl(activeTab.url || ''); - setPageTitle(activeTab.title || ''); - setCanGoBack(false); - setCanGoForward(false); - } - }, [activeTabId, tabs]); - - // Navigate tab when URL changes (after initial mount) - useEffect(() => { - tabs.forEach((tab) => { - const webview = webviewRefs.current[tab.id]; - if (!webview || !tab.url) return; - - // Mark that this webview has been initialized - if (!(webview as any).__initialized) { - (webview as any).__initialized = true; - return; // Skip on first render - src attribute handles initial load - } - - // Only navigate if URL actually changed - try { - const currentUrl = webview.getURL?.() || ''; - // Only call loadURL if: - // 1. We have a current URL (webview is ready) - // 2. The URL is different from what we want to navigate to - // 3. The URL is not a view-source URL (let those load naturally) - if (currentUrl && currentUrl !== tab.url && !currentUrl.startsWith('view-source:')) { - // Store the last programmatically set URL to avoid loops - if ((webview as any).__lastProgrammaticUrl !== tab.url) { - (webview as any).__lastProgrammaticUrl = tab.url; - webview.loadURL(tab.url); - } - } - } catch { - // Webview might not be ready, ignore the error - // The src attribute will handle the navigation - } - }); - }, [tabs]); - - return ( - <> -
- {tabs.map((tab) => { - const isVisible = tab.id === activeTabId; - const shouldRenderWebview = !tab.isSuspended; - - return ( -
- {/* Suspended Tab Placeholder */} - {tab.isSuspended && isVisible && ( -
-
-
- {tab.favicon ? ( - - ) : ( - - - - )} -
-

Tab Suspended

-

This tab was suspended to save memory.

-

- {tab.title || tab.url || 'No title'} -

- -
-
- )} - - {/* Welcome Screen Overlay - shown when no URL */} - {!tab.url && !tab.isSuspended && isVisible && ( -
-
- - - -

Welcome to Open Browser

-

- Enter a URL or search query in the address bar to get started. -

-

- Click the AI button to chat with local models about any page. -

- - {/* Current Personality Display */} - {currentPersonality && ( -
-
- {getIconEmoji(currentPersonality.icon)} -
-
- Current AI: {currentPersonality.personName} -
-
- {currentPersonality.name} -
-
-
-
- )} - - {/* Personality Selection Button */} -
- -

- Customize how your AI assistant talks to you -

-
-
-
- )} - - {/* WebView - only render if not suspended and has URL */} - {shouldRenderWebview && tab.url && ( - { - if (el) { - webviewRefs.current[tab.id] = el; - // Setup listeners on mount - const cleanup = setupWebviewListeners(el, tab.id); - // Store cleanup function - (el as any).__cleanup = cleanup; - } else if (webviewRefs.current[tab.id]) { - // Cleanup on unmount - const cleanup = (webviewRefs.current[tab.id] as any).__cleanup; - if (cleanup) cleanup(); - delete webviewRefs.current[tab.id]; - } - }} - src={tab.url} - className="w-full h-full" - // @ts-ignore - webview is a custom Electron element - // Security: Use persistent partition for session data - partition="persist:main" - // Security: Disable popups to prevent popup spam and phishing - allowpopups="false" - // Security: Enable context isolation, allow javascript and plugins for full browsing - // Note: Webviews are sandboxed separately from the main renderer process - // Allow downloads by not restricting them in sandbox - webpreferences="contextIsolation=true,javascript=yes,plugins=yes,sandbox=true,enableBlinkFeatures=CSSBackdropFilter" - // User agent string for compatibility - use latest Chrome version - useragent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - /> - )} -
- ); - })} -
- - {/* Personality Selector Modal */} - setIsPersonalitySelectorOpen(false)} - /> - - ); -}); - -MultiWebViewContainer.displayName = 'MultiWebViewContainer'; diff --git a/src/renderer/components/Browser/NavigationBar.tsx b/src/renderer/components/Browser/NavigationBar.tsx index a67debf..2f813e3 100644 --- a/src/renderer/components/Browser/NavigationBar.tsx +++ b/src/renderer/components/Browser/NavigationBar.tsx @@ -3,7 +3,7 @@ import { useBrowserStore } from '../../store/browser'; import { useTabsStore } from '../../store/tabs'; import { useModelStore } from '../../store/models'; import { useChatStore } from '../../store/chat'; -import { WebViewHandle } from './MultiWebViewContainer'; +import { BrowserWindowHandle } from './BrowserWindowContainer'; import { browserDataService } from '../../services/browserData'; import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { SystemPromptSettings } from '../Settings/SystemPromptSettings'; @@ -11,10 +11,10 @@ import { DownloadDropdown } from './DownloadDropdown'; import { supportsVision } from '../../../shared/modelRegistry'; interface NavigationBarProps { - webviewRef: RefObject; + browserWindowRef: RefObject; } -export const NavigationBar: React.FC = ({ webviewRef }) => { +export const NavigationBar: React.FC = ({ browserWindowRef }) => { const { currentUrl, pageTitle, @@ -25,6 +25,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { canGoForward, isChatOpen, isBookmarked, + zoomLevel, setCurrentUrl, setIsBookmarked, toggleChat, @@ -46,7 +47,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { const [showSystemPromptSettings, setShowSystemPromptSettings] = useState(false); const [showDownloadDropdown, setShowDownloadDropdown] = useState(false); const [activeDownloadsCount, setActiveDownloadsCount] = useState(0); - const downloadButtonRef = useRef(null); + const downloadButtonRef = useRef>(null); // Sync inputValue with currentUrl when not focused (for tab changes) useEffect(() => { @@ -309,18 +310,18 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { }; const handleBack = () => { - webviewRef.current?.goBack(); + browserWindowRef.current?.goBack(); }; const handleForward = () => { - webviewRef.current?.goForward(); + browserWindowRef.current?.goForward(); }; const handleRefresh = () => { if (isLoading) { - webviewRef.current?.stop(); + browserWindowRef.current?.stop(); } else { - webviewRef.current?.reload(); + browserWindowRef.current?.reload(); } }; @@ -379,30 +380,9 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { } try { - // Get selected text - const selectedText = await webviewRef.current?.executeJavaScript( - 'window.getSelection().toString()' - ); - - if (!selectedText) { - alert('Please select some text first'); - return; - } - - // Open chat if not already open - if (!isChatOpen) { - toggleChat(); - } - - // Capture page context - const pageCapture = await window.electron.invoke('capture:forText'); - - // Send to AI with selected text in context - const prompt = `Please explain the following text:\n\n"${selectedText}"`; - await sendChatMessage(prompt, undefined, { - ...pageCapture, - selectedText, - }); + // Note: With BrowserWindow tabs, we can't directly execute JavaScript + // User should select text and use context menu instead + alert('Please select text and use the right-click context menu "Explain this"'); } catch (error) { console.error('Failed to explain selection:', error); alert('Failed to explain text. Please try again.'); @@ -538,7 +518,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.zoomIn(), + onClick: () => browserWindowRef.current?.zoomIn(), }, { label: 'Zoom Out', @@ -553,7 +533,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.zoomOut(), + onClick: () => browserWindowRef.current?.zoomOut(), }, { label: 'Reset Zoom', @@ -568,7 +548,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.resetZoom(), + onClick: () => browserWindowRef.current?.resetZoom(), }, { label: '', separator: true, onClick: () => {} }, { @@ -584,8 +564,9 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.print(), + onClick: () => browserWindowRef.current?.print(), }, + { label: '', separator: true, onClick: () => {} }, { label: 'View Page Source', shortcut: 'Ctrl+U', @@ -599,7 +580,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.viewSource(), + onClick: () => browserWindowRef.current?.viewSource(), disabled: !hasUrl, }, { @@ -615,7 +596,7 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { /> ), - onClick: () => webviewRef.current?.openDevTools(), + onClick: () => browserWindowRef.current?.openDevTools(), }, ]; @@ -809,6 +790,24 @@ export const NavigationBar: React.FC = ({ webviewRef }) => { )}
+ {/* Zoom Indicator - only show when zoom is not 100% */} + {zoomLevel !== 100 && ( +
+ + + + {zoomLevel}% +
+ )} + {/* Bookmark Toggle Button */} - - -
- - {/* URL Input */} -
-
- {/* Security Indicator / Favicon */} - {!isFocused && hasUrl ? ( - isSecure ? ( - - - - ) : currentUrl.startsWith("http://") ? ( - - - - ) : favicon ? ( - - ) : ( - - - - ) - ) : ( - - - - )} - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={handleFocus} - onBlur={handleBlur} - placeholder="Ask or enter URL..." - className="flex-1 bg-transparent outline-none text-sm text-foreground placeholder:text-muted-foreground" - /> - {isFocused && inputValue && ( - - )} -
- - {/* Suggestions Dropdown */} - {suggestions.length > 0 && isFocused && ( -
- {suggestions.map((suggestion, index) => ( -
handleSuggestionClick(suggestion.url)} - className={`px-4 py-2 cursor-pointer transition-colors ${ - index === selectedSuggestionIndex - ? "bg-accent" - : "hover:bg-accent" - }`} - > -
- - - -
-
- {suggestion.title} -
-
- {suggestion.url} -
- {suggestion.visitCount && suggestion.visitCount > 1 && ( -
- Visited {suggestion.visitCount} times -
- )} -
-
-
- ))} -
- )} -
- - {/* Bookmark Toggle Button */} - - - {/* History Button */} - - - {/* Bookmarks Button */} - - - {/* AI Toggle Button */} - - - - {/* Page Title Bar */} - {pageTitle && !isFocused && ( -
- {pageTitle} -
- )} - - {/* Loading Progress Bar */} - {isLoading && loadProgress < 100 && ( -
-
-
- )} -
- ); -}; diff --git a/src/renderer/components/Models/ModelManager.tsx b/src/renderer/components/Models/ModelManager.tsx index 41c042e..39e90c3 100644 --- a/src/renderer/components/Models/ModelManager.tsx +++ b/src/renderer/components/Models/ModelManager.tsx @@ -60,9 +60,16 @@ export const ModelManager: React.FC = () => { // Load service status loadServiceStatus(); + // Hide the active tab view so modal is interactive + window.electron.invoke('tabWindow:setActiveVisible', false).catch(console.error); + // Poll service status every 5 seconds const interval = setInterval(loadServiceStatus, 5000); - return () => clearInterval(interval); + return () => { + clearInterval(interval); + // Show the active tab view when modal closes + window.electron.invoke('tabWindow:setActiveVisible', true).catch(console.error); + }; } }, [isModelManagerOpen, refreshModels, loadModelsFolder, loadServiceStatus]); @@ -122,7 +129,9 @@ export const ModelManager: React.FC = () => { }; const handleForceKill = async () => { - if (!confirm('Are you sure you want to force kill the Ollama process? This may cause data loss.')) { + if ( + !confirm('Are you sure you want to force kill the Ollama process? This may cause data loss.') + ) { return; } try { @@ -138,7 +147,7 @@ export const ModelManager: React.FC = () => { const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; }; const formatUptime = (seconds: number) => { @@ -304,17 +313,23 @@ export const ModelManager: React.FC = () => { {serviceStatus.processStats.uptime > 0 && (
Uptime: - {formatUptime(serviceStatus.processStats.uptime)} + + {formatUptime(serviceStatus.processStats.uptime)} +
)}
Memory: - {formatBytes(serviceStatus.processStats.memory.rss)} + + {formatBytes(serviceStatus.processStats.memory.rss)} +
{serviceStatus.processStats.cpu > 0 && (
CPU: - {serviceStatus.processStats.cpu.toFixed(1)}% + + {serviceStatus.processStats.cpu.toFixed(1)}% +
)} @@ -323,18 +338,14 @@ export const ModelManager: React.FC = () => { Service is running but process details unavailable

) : ( -

- Service is not running -

+

Service is not running

)} )}
-
- Manage your local AI models -
+
Manage your local AI models