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 && (
+
+ )}
+
{/* Bookmark Toggle Button */}
@@ -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
setIsModelManagerOpen(false)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
diff --git a/src/renderer/components/Settings/PersonalitySelector.tsx b/src/renderer/components/Settings/PersonalitySelector.tsx
index e7ce97c..9cd4823 100644
--- a/src/renderer/components/Settings/PersonalitySelector.tsx
+++ b/src/renderer/components/Settings/PersonalitySelector.tsx
@@ -16,6 +16,11 @@ export const PersonalitySelector: React.FC = ({ isOpen
useEffect(() => {
if (isOpen) {
loadPersonalities();
+ // Hide the active tab view so modal is interactive
+ window.electron.invoke('tabWindow:setActiveVisible', false).catch(console.error);
+ } else {
+ // Show the active tab view when modal closes
+ window.electron.invoke('tabWindow:setActiveVisible', true).catch(console.error);
}
}, [isOpen]);
diff --git a/src/renderer/components/Settings/SystemPromptSettings.tsx b/src/renderer/components/Settings/SystemPromptSettings.tsx
index e8fd27b..7d109b8 100644
--- a/src/renderer/components/Settings/SystemPromptSettings.tsx
+++ b/src/renderer/components/Settings/SystemPromptSettings.tsx
@@ -168,8 +168,8 @@ export const SystemPromptSettings: React.FC = ({ isOp
- Enable advanced reasoning and tool calling for complex tasks. Disable for faster,
- direct responses to simple questions.
+ Enable advanced reasoning and tool calling for complex tasks. Disable for
+ faster, direct responses to simple questions.
{
+ // Title updated
+ const unsubTitleUpdated = window.electron.on(
+ 'tab-title-updated',
+ ({ tabId, title }: { tabId: string; title: string }) => {
+ updateTab(tabId, { title });
+ // If it's the active tab, update browser state
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setPageTitle(title);
+ }
+ }
+ );
+
+ // Favicon updated
+ const unsubFaviconUpdated = window.electron.on(
+ 'tab-favicon-updated',
+ ({ tabId, favicon }: { tabId: string; favicon: string }) => {
+ updateTab(tabId, { favicon });
+ // If it's the active tab, update browser state
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setFavicon(favicon);
+ }
+ }
+ );
+
+ // Loading started
+ const unsubLoadingStart = window.electron.on(
+ 'tab-loading-start',
+ ({ tabId }: { tabId: string }) => {
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setIsLoading(true);
+ }
+ }
+ );
+
+ // Loading stopped
+ const unsubLoadingStop = window.electron.on(
+ 'tab-loading-stop',
+ ({
+ tabId,
+ canGoBack,
+ canGoForward,
+ }: {
+ tabId: string;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ }) => {
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setIsLoading(false);
+ setCanGoBack(canGoBack);
+ setCanGoForward(canGoForward);
+ }
+ }
+ );
+
+ // Navigation
+ const unsubDidNavigate = window.electron.on(
+ 'tab-did-navigate',
+ ({
+ tabId,
+ url,
+ canGoBack,
+ canGoForward,
+ }: {
+ tabId: string;
+ url: string;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ }) => {
+ updateTab(tabId, { url });
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setCurrentUrl(url);
+ setCanGoBack(canGoBack);
+ setCanGoForward(canGoForward);
+ }
+ }
+ );
+
+ // In-page navigation
+ const unsubDidNavigateInPage = window.electron.on(
+ 'tab-did-navigate-in-page',
+ ({ tabId, url }: { tabId: string; url: string }) => {
+ updateTab(tabId, { url });
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ setCurrentUrl(url);
+ }
+ }
+ );
+
+ // Request new tab (from popups/target="_blank")
+ const unsubRequestNew = window.electron.on('tab-request-new', ({ url }: { url: string }) => {
+ const { addTab } = useTabsStore.getState();
+ addTab(url);
+ });
+
+ // Load error
+ const unsubLoadError = window.electron.on(
+ 'tab-load-error',
+ ({ tabId, errorDescription }: { tabId: string; errorDescription: string }) => {
+ console.error(`Tab ${tabId} load error:`, errorDescription);
+ // Could show an error message here
+ }
+ );
+
+ // Tab activated
+ const unsubActivated = window.electron.on(
+ 'tab-activated',
+ ({
+ url,
+ title,
+ canGoBack,
+ canGoForward,
+ }: {
+ url: string;
+ title: string;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ }) => {
+ setCurrentUrl(url);
+ setPageTitle(title);
+ setCanGoBack(canGoBack);
+ setCanGoForward(canGoForward);
+ }
+ );
+
+ // Zoom level changed
+ const unsubZoomChanged = window.electron.on(
+ 'tab-zoom-changed',
+ ({ tabId, zoomFactor }: { tabId: string; zoomLevel: number; zoomFactor: number }) => {
+ const activeTabId = useTabsStore.getState().activeTabId;
+ if (tabId === activeTabId) {
+ // Convert zoom factor to percentage (1.0 = 100%, 1.5 = 150%)
+ const percentage = Math.round(zoomFactor * 100);
+ setZoomLevel(percentage);
+ }
+ }
+ );
+
+ // Cleanup
+ return () => {
+ unsubTitleUpdated();
+ unsubFaviconUpdated();
+ unsubLoadingStart();
+ unsubLoadingStop();
+ unsubDidNavigate();
+ unsubDidNavigateInPage();
+ unsubRequestNew();
+ unsubLoadError();
+ unsubActivated();
+ unsubZoomChanged();
+ };
+ }, [
+ updateTab,
+ setIsLoading,
+ setCanGoBack,
+ setCanGoForward,
+ setCurrentUrl,
+ setPageTitle,
+ setFavicon,
+ setZoomLevel,
+ ]);
+}
diff --git a/src/renderer/store/tabs.ts b/src/renderer/store/tabs.ts
index 04018f5..6d730d3 100644
--- a/src/renderer/store/tabs.ts
+++ b/src/renderer/store/tabs.ts
@@ -23,7 +23,7 @@ export const useTabsStore = create((set, get) => ({
tabs: [],
activeTabId: null,
- addTab: (url = '') => {
+ addTab: async (url = '') => {
// Generate a unique ID combining UUID, timestamp, and counter
const uniqueId = `${crypto.randomUUID()}-${Date.now()}-${++tabCounter}`;
@@ -49,15 +49,30 @@ export const useTabsStore = create((set, get) => ({
activeTabId: newTab.id,
}));
+ // Create the BrowserWindow tab in the main process
+ try {
+ await window.electron.invoke('tabWindow:create', uniqueId, url);
+ await window.electron.invoke('tabWindow:setActive', uniqueId);
+ } catch (error) {
+ console.error('Failed to create tab window:', error);
+ }
+
// Auto-save after adding tab
get().saveTabs();
},
- closeTab: (tabId: string) => {
+ closeTab: async (tabId: string) => {
const state = get();
const tabIndex = state.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) return;
+ // Close the BrowserWindow tab in the main process
+ try {
+ await window.electron.invoke('tabWindow:close', tabId);
+ } catch (error) {
+ console.error('Failed to close tab window:', error);
+ }
+
const newTabs = state.tabs.filter((t) => t.id !== tabId);
// If closing the last tab, remove it first then create a new empty tab
@@ -88,7 +103,7 @@ export const useTabsStore = create((set, get) => ({
get().saveTabs();
},
- setActiveTab: (tabId: string) => {
+ setActiveTab: async (tabId: string) => {
set((state) => ({
tabs: state.tabs.map((tab) => ({
...tab,
@@ -99,15 +114,33 @@ export const useTabsStore = create((set, get) => ({
activeTabId: tabId,
}));
+ // Set active tab in the main process
+ try {
+ await window.electron.invoke('tabWindow:setActive', tabId);
+ } catch (error) {
+ console.error('Failed to set active tab:', error);
+ }
+
// Auto-save after switching tabs
get().saveTabs();
},
- updateTab: (tabId: string, updates: Partial) => {
+ updateTab: async (tabId: string, updates: Partial) => {
+ const oldTab = get().tabs.find((t) => t.id === tabId);
+
set((state) => ({
tabs: state.tabs.map((tab) => (tab.id === tabId ? { ...tab, ...updates } : tab)),
}));
+ // If URL changed, navigate the browser window
+ if (updates.url && oldTab && updates.url !== oldTab.url) {
+ try {
+ await window.electron.invoke('tabWindow:navigate', tabId, updates.url);
+ } catch (error) {
+ console.error('Failed to navigate tab:', error);
+ }
+ }
+
// Debounced save (title/url updates happen frequently)
setTimeout(() => get().saveTabs(), 1000);
},