added tabs

This commit is contained in:
Arjun 2026-04-11 22:20:25 +05:30
parent 8991c562d0
commit 25f28564d8
4 changed files with 436 additions and 232 deletions

View file

@ -12,6 +12,9 @@ type InvokeHandler<K extends keyof IPCChannels> = (
type BrowserHandlers = {
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
'browser:newTab': InvokeHandler<'browser:newTab'>;
'browser:switchTab': InvokeHandler<'browser:switchTab'>;
'browser:closeTab': InvokeHandler<'browser:closeTab'>;
'browser:navigate': InvokeHandler<'browser:navigate'>;
'browser:back': InvokeHandler<'browser:back'>;
'browser:forward': InvokeHandler<'browser:forward'>;
@ -34,6 +37,15 @@ export const browserIpcHandlers: BrowserHandlers = {
browserViewManager.setVisible(args.visible);
return { ok: true };
},
'browser:newTab': async (_event, args) => {
return browserViewManager.newTab(args.url);
},
'browser:switchTab': async (_event, args) => {
return browserViewManager.switchTab(args.tabId);
},
'browser:closeTab': async (_event, args) => {
return browserViewManager.closeTab(args.tabId);
},
'browser:navigate': async (_event, args) => {
return browserViewManager.navigate(args.url);
},

View file

@ -1,16 +1,17 @@
import { BrowserWindow, WebContentsView, session, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
/**
* Embedded browser pane implementation.
*
* A single lazy-created WebContentsView is hosted on top of the main
* BrowserWindow's contentView, positioned by pixel bounds the renderer
* computes via ResizeObserver.
* Each browser tab owns its own WebContentsView. Only the active tab's view is
* attached to the main window at a time, but inactive tabs keep their own page
* history and loaded state in memory so switching tabs feels immediate.
*
* The view uses a persistent session partition so cookies/localStorage/
* form-fill state survive app restarts, and spoofs a standard Chrome UA so
* sites like Google (OAuth) don't reject it as an embedded browser.
* All tabs share one persistent session partition so cookies/localStorage/
* form-fill state survive app restarts, and the browser surface spoofs a
* standard Chrome UA so sites like Google (OAuth) don't reject it.
*/
const PARTITION = 'persist:rowboat-browser';
@ -29,7 +30,8 @@ export interface BrowserBounds {
height: number;
}
export interface BrowserState {
export interface BrowserTabState {
id: string;
url: string;
title: string;
canGoBack: boolean;
@ -37,18 +39,28 @@ export interface BrowserState {
loading: boolean;
}
export interface BrowserState {
activeTabId: string | null;
tabs: BrowserTabState[];
}
type BrowserTab = {
id: string;
view: WebContentsView;
};
const EMPTY_STATE: BrowserState = {
url: '',
title: '',
canGoBack: false,
canGoForward: false,
loading: false,
activeTabId: null,
tabs: [],
};
export class BrowserViewManager extends EventEmitter {
private window: BrowserWindow | null = null;
private view: WebContentsView | null = null;
private attached = false;
private browserSession: Session | null = null;
private tabs = new Map<string, BrowserTab>();
private tabOrder: string[] = [];
private activeTabId: string | null = null;
private attachedTabId: string | null = null;
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
@ -56,54 +68,78 @@ export class BrowserViewManager extends EventEmitter {
this.window = window;
window.on('closed', () => {
this.window = null;
this.view = null;
this.attached = false;
this.browserSession = null;
this.tabs.clear();
this.tabOrder = [];
this.activeTabId = null;
this.attachedTabId = null;
this.visible = false;
});
}
private ensureView(): WebContentsView {
if (this.view) return this.view;
if (!this.window) {
throw new Error('BrowserViewManager: no window attached');
}
// One shared session across all BrowserViewManager instances in this
// process, keyed by partition name. Setting the UA on the session covers
// requests the webContents issues before the first page is loaded.
private getSession(): Session {
if (this.browserSession) return this.browserSession;
const browserSession = session.fromPartition(PARTITION);
browserSession.setUserAgent(SPOOF_UA);
this.browserSession = browserSession;
return browserSession;
}
private emitState(): void {
this.emit('state-updated', this.snapshotState());
}
private getTab(tabId: string | null): BrowserTab | null {
if (!tabId) return null;
return this.tabs.get(tabId) ?? null;
}
private getActiveTab(): BrowserTab | null {
return this.getTab(this.activeTabId);
}
private normalizeUrl(rawUrl: string): string {
let url = rawUrl.trim();
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
url = `https://${url}`;
}
return url;
}
private isEmbeddedTabUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || url === 'about:blank';
}
private createView(tabId: string): WebContentsView {
const view = new WebContentsView({
webPreferences: {
session: browserSession,
session: this.getSession(),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
// Also set UA on the webContents directly — belt-and-braces for sites
// that inspect the request-level UA vs the navigator UA.
view.webContents.setUserAgent(SPOOF_UA);
this.wireEvents(view);
this.view = view;
this.wireEvents(tabId, view);
return view;
}
private wireEvents(view: WebContentsView): void {
private wireEvents(tabId: string, view: WebContentsView): void {
const wc = view.webContents;
const emit = () => this.emit('state-updated', this.snapshotState());
const emit = () => this.emitState();
// Defensively re-apply bounds on navigation events. Electron's
// WebContentsView is known to occasionally reset its laid-out bounds on
// navigation (a behavior carried over from the deprecated BrowserView),
// which manifests as the view "spilling" outside its intended pane.
// Re-applying after every navigation/load event is cheap and idempotent.
// Electron occasionally drops WebContentsView layout on navigation.
// Re-applying the cached bounds is cheap and keeps the active tab pinned
// to the renderer-computed viewport.
const reapplyBounds = () => {
if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) {
if (
this.attachedTabId === tabId &&
this.visible &&
this.bounds.width > 0 &&
this.bounds.height > 0
) {
view.setBounds(this.bounds);
}
};
@ -118,105 +154,20 @@ export class BrowserViewManager extends EventEmitter {
wc.on('did-fail-load', () => { reapplyBounds(); emit(); });
wc.on('page-title-updated', emit);
// Pop-ups / target="_blank" — hand off to the OS browser for now.
// The embedded pane is single-tab in v1.
wc.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
if (this.isEmbeddedTabUrl(url)) {
void this.newTab(url);
} else {
void shell.openExternal(url);
}
return { action: 'deny' };
});
}
setVisible(visible: boolean): void {
if (!this.window) return;
const view = visible ? this.ensureView() : this.view;
if (!view) return;
const contentView = this.window.contentView;
if (visible) {
// Order: attach FIRST, then setBounds. Calling setBounds on an
// unattached WebContentsView can leave it in a state where the next
// attach uses default bounds, blanking the renderer area.
if (!this.attached) {
contentView.addChildView(view);
this.attached = true;
}
// The renderer only asks us to show the view after it has pushed a
// fresh non-zero rect, so applying the cached bounds here should land
// the surface in the correct pane immediately on attach.
view.setBounds(this.bounds);
this.visible = true;
// First-time load — land on a useful page rather than about:blank.
const currentUrl = view.webContents.getURL();
if (!currentUrl || currentUrl === 'about:blank') {
void view.webContents.loadURL(HOME_URL);
}
} else {
if (this.attached) {
contentView.removeChildView(view);
this.attached = false;
}
this.visible = false;
}
}
setBounds(bounds: BrowserBounds): void {
this.bounds = bounds;
// Only apply to the view if it's currently attached and visible.
// Applying to a detached view appears to put Electron's WebContentsView
// into a bad state on subsequent attach (renderer area blanks out).
if (this.view && this.attached && this.visible) {
this.view.setBounds(bounds);
}
}
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
try {
const view = this.ensureView();
// If the user typed "example.com" without a scheme, assume https.
// Schemes are already filtered at the IPC boundary, so we know it's
// not file://, javascript:, etc.
let url = rawUrl.trim();
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
url = `https://${url}`;
}
await view.webContents.loadURL(url);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
back(): { ok: boolean } {
if (!this.view) return { ok: false };
const history = this.view.webContents.navigationHistory;
if (!history.canGoBack()) return { ok: false };
history.goBack();
return { ok: true };
}
forward(): { ok: boolean } {
if (!this.view) return { ok: false };
const history = this.view.webContents.navigationHistory;
if (!history.canGoForward()) return { ok: false };
history.goForward();
return { ok: true };
}
reload(): void {
if (!this.view) return;
this.view.webContents.reload();
}
getState(): BrowserState {
return this.snapshotState();
}
private snapshotState(): BrowserState {
if (!this.view) return { ...EMPTY_STATE };
const wc = this.view.webContents;
private snapshotTabState(tab: BrowserTab): BrowserTabState {
const wc = tab.view.webContents;
return {
id: tab.id,
url: wc.getURL(),
title: wc.getTitle(),
canGoBack: wc.navigationHistory.canGoBack(),
@ -224,6 +175,189 @@ export class BrowserViewManager extends EventEmitter {
loading: wc.isLoading(),
};
}
private syncAttachedView(): void {
if (!this.window) return;
const contentView = this.window.contentView;
const activeTab = this.getActiveTab();
if (!this.visible || !activeTab) {
const attachedTab = this.getTab(this.attachedTabId);
if (attachedTab) {
contentView.removeChildView(attachedTab.view);
}
this.attachedTabId = null;
return;
}
if (this.attachedTabId && this.attachedTabId !== activeTab.id) {
const attachedTab = this.getTab(this.attachedTabId);
if (attachedTab) {
contentView.removeChildView(attachedTab.view);
}
this.attachedTabId = null;
}
if (this.attachedTabId !== activeTab.id) {
contentView.addChildView(activeTab.view);
this.attachedTabId = activeTab.id;
}
if (this.bounds.width > 0 && this.bounds.height > 0) {
activeTab.view.setBounds(this.bounds);
}
}
private createTab(initialUrl: string): BrowserTab {
if (!this.window) {
throw new Error('BrowserViewManager: no window attached');
}
const tabId = randomUUID();
const tab: BrowserTab = {
id: tabId,
view: this.createView(tabId),
};
this.tabs.set(tabId, tab);
this.tabOrder.push(tabId);
this.activeTabId = tabId;
this.syncAttachedView();
this.emitState();
const targetUrl =
initialUrl === 'about:blank'
? HOME_URL
: this.normalizeUrl(initialUrl);
void tab.view.webContents.loadURL(targetUrl).catch(() => {
this.emitState();
});
return tab;
}
private ensureInitialTab(): BrowserTab {
const activeTab = this.getActiveTab();
if (activeTab) return activeTab;
return this.createTab(HOME_URL);
}
private destroyTab(tab: BrowserTab): void {
tab.view.webContents.removeAllListeners();
if (!tab.view.webContents.isDestroyed()) {
tab.view.webContents.close();
}
}
setVisible(visible: boolean): void {
this.visible = visible;
if (visible) {
this.ensureInitialTab();
}
this.syncAttachedView();
}
setBounds(bounds: BrowserBounds): void {
this.bounds = bounds;
const activeTab = this.getActiveTab();
if (activeTab && this.attachedTabId === activeTab.id && this.visible) {
activeTab.view.setBounds(bounds);
}
}
async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> {
try {
const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL);
return { ok: true, tabId: tab.id };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
switchTab(tabId: string): { ok: boolean } {
if (!this.tabs.has(tabId)) return { ok: false };
if (this.activeTabId === tabId) return { ok: true };
this.activeTabId = tabId;
this.syncAttachedView();
this.emitState();
return { ok: true };
}
closeTab(tabId: string): { ok: boolean } {
const tab = this.tabs.get(tabId);
if (!tab) return { ok: false };
if (this.tabOrder.length <= 1) return { ok: false };
const closingIndex = this.tabOrder.indexOf(tabId);
const nextActiveTabId =
this.activeTabId === tabId
? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null
: this.activeTabId;
if (this.attachedTabId === tabId && this.window) {
this.window.contentView.removeChildView(tab.view);
this.attachedTabId = null;
}
this.tabs.delete(tabId);
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
this.activeTabId = nextActiveTabId;
this.destroyTab(tab);
this.syncAttachedView();
this.emitState();
return { ok: true };
}
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
try {
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
await activeTab.view.webContents.loadURL(this.normalizeUrl(rawUrl));
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
back(): { ok: boolean } {
const activeTab = this.getActiveTab();
if (!activeTab) return { ok: false };
const history = activeTab.view.webContents.navigationHistory;
if (!history.canGoBack()) return { ok: false };
history.goBack();
return { ok: true };
}
forward(): { ok: boolean } {
const activeTab = this.getActiveTab();
if (!activeTab) return { ok: false };
const history = activeTab.view.webContents.navigationHistory;
if (!history.canGoForward()) return { ok: false };
history.goForward();
return { ok: true };
}
reload(): void {
const activeTab = this.getActiveTab();
if (!activeTab) return;
activeTab.view.webContents.reload();
}
getState(): BrowserState {
return this.snapshotState();
}
private snapshotState(): BrowserState {
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
return {
activeTabId: this.activeTabId,
tabs: this.tabOrder
.map((tabId) => this.tabs.get(tabId))
.filter((tab): tab is BrowserTab => tab != null)
.map((tab) => this.snapshotTabState(tab)),
};
}
}
export const browserViewManager = new BrowserViewManager();

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react'
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
import { TabBar } from '@/components/tab-bar'
import { cn } from '@/lib/utils'
/**
@ -9,11 +10,12 @@ import { cn } from '@/lib/utils'
* Renders a transparent placeholder div whose bounds are reported to the
* main process via `browser:setBounds`. The actual browsing surface is an
* Electron WebContentsView layered on top of the renderer by the main
* process this component only owns the chrome (address bar, nav, spinner)
* and the sizing/visibility lifecycle.
* process this component only owns the chrome (tabs, address bar, nav
* buttons) and the sizing/visibility lifecycle.
*/
interface BrowserState {
interface BrowserTabState {
id: string
url: string
title: string
canGoBack: boolean
@ -21,58 +23,73 @@ interface BrowserState {
loading: boolean
}
const EMPTY_STATE: BrowserState = {
url: '',
title: '',
canGoBack: false,
canGoForward: false,
loading: false,
interface BrowserState {
activeTabId: string | null
tabs: BrowserTabState[]
}
const EMPTY_STATE: BrowserState = {
activeTabId: null,
tabs: [],
}
/** Placeholder height we subtract from the inner bounds for the chrome row. */
const CHROME_HEIGHT = 40
interface BrowserPaneProps {
onClose: () => void
}
const getActiveTab = (state: BrowserState) =>
state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
const getBrowserTabTitle = (tab: BrowserTabState) => {
const title = tab.title.trim()
if (title) return title
const url = tab.url.trim()
if (!url) return 'New tab'
try {
const parsed = new URL(url)
return parsed.hostname || parsed.href
} catch {
return url.replace(/^https?:\/\//i, '') || 'New tab'
}
}
export function BrowserPane({ onClose }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
const activeTabIdRef = useRef<string | null>(null)
const addressFocusedRef = useRef(false)
const viewportRef = useRef<HTMLDivElement>(null)
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const viewVisibleRef = useRef(false)
// ── Subscribe to state updates from main ──────────────────────────────────
useEffect(() => {
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
const next = incoming as BrowserState
setState(next)
if (!addressFocusedRef.current) {
setAddressValue(next.url)
}
})
// Kick an initial state fetch so the chrome reflects wherever the view
// was left from a previous session (or empty, if never loaded).
void window.ipc.invoke('browser:getState', null).then((initial) => {
const next = initial as BrowserState
setState(next)
if (!addressFocusedRef.current) {
setAddressValue(next.url)
}
})
return cleanup
const activeTab = getActiveTab(state)
const applyState = useCallback((next: BrowserState) => {
const previousActiveTabId = activeTabIdRef.current
activeTabIdRef.current = next.activeTabId
setState(next)
const nextActiveTab = getActiveTab(next)
if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) {
setAddressValue(nextActiveTab?.url ?? '')
}
}, [])
// ── Bounds tracking ───────────────────────────────────────────────────────
// The main process needs pixel-accurate bounds *relative to the window
// content area*. getBoundingClientRect() returns viewport-relative coords,
// which in Electron with hiddenInset titleBar equal content-area coords.
//
// Reads layout synchronously and posts an IPC update only when the rect
// actually changed. Cheap enough to call from a RAF loop or observer.
useEffect(() => {
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
applyState(incoming as BrowserState)
})
void window.ipc.invoke('browser:getState', null).then((initial) => {
applyState(initial as BrowserState)
})
return cleanup
}, [applyState])
const setViewVisible = useCallback((visible: boolean) => {
if (viewVisibleRef.current === visible) return
viewVisibleRef.current = visible
@ -82,6 +99,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
const measureBounds = useCallback(() => {
const el = viewportRef.current
if (!el) return null
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
const rect = el.getBoundingClientRect()
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
@ -89,25 +107,25 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
? Math.min(rect.right, chatSidebarRect.left)
: rect.right
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
// native view bounds are in unzoomed window coordinates, so we have to
// convert back using the renderer zoom factor.
// native view bounds are in unzoomed window coordinates, so convert back
// using the renderer zoom factor before calling into the main process.
const left = Math.ceil(rect.left * zoomFactor)
const top = Math.ceil(rect.top * zoomFactor)
const right = Math.floor(clampedRightCss * zoomFactor)
const bottom = Math.floor(rect.bottom * zoomFactor)
const width = right - left
const height = bottom - top
// A zero-sized rect means the element isn't laid out yet or has been
// collapsed behind the chat pane — hide the native view in that case.
if (width <= 0 || height <= 0) return null
const bounds = {
return {
x: left,
y: top,
width,
height,
}
return bounds
}, [])
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
@ -138,24 +156,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
return bounds
}, [measureBounds, pushBounds, setViewVisible])
// Defensively re-push bounds whenever the underlying page state changes.
// Electron's WebContentsView can drop its laid-out rect on navigation,
// and even though main re-applies on the same events, pushing from the
// renderer ensures we use the *current* layout (in case the chat sidebar
// or window resized while the page was loading).
useEffect(() => {
syncView()
}, [state.url, state.loading, syncView])
}, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView])
// ── Visibility lifecycle ──────────────────────────────────────────────────
// Order matters: push bounds FIRST so the WCV is created at the right
// position, THEN setVisible. Otherwise the view gets attached at stale
// (or zero) cached bounds and visually spills until the next bounds push.
//
// We wait one animation frame after mount so React's commit + the browser's
// layout pass for any sibling that just mounted (e.g. the chat sidebar) are
// both reflected in getBoundingClientRect. A single RAF is enough — by then
// the renderer has painted the post-commit DOM at least once.
useEffect(() => {
let cancelled = false
const rafId = requestAnimationFrame(() => {
@ -170,21 +174,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
}
}, [setViewVisible, syncView])
// Ongoing bounds tracking. Observes everything that can move our viewport:
// - the viewport div itself (local size changes)
// - the SidebarInset ancestor (changes when chat sidebar mounts/resizes)
// - the document root (window resize, devicePixelRatio changes)
//
// ResizeObserver fires after layout but before paint, so by the time we
// call pushBounds the rect is current. Multiple observers may fire in the
// same frame — RAF-coalesce them so we only post one IPC per frame.
useEffect(() => {
const el = viewportRef.current
if (!el) return
// The sidebar-inset main element is the immediate flex parent whose
// width shrinks when a sibling appears. Walking up the tree is more
// robust than passing a ref through props.
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
const documentElement = el.ownerDocument.documentElement
@ -210,7 +203,23 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
}
}, [syncView])
// ── Actions ───────────────────────────────────────────────────────────────
const handleNewTab = useCallback(() => {
void window.ipc.invoke('browser:newTab', {}).then((res) => {
const result = res as { ok: boolean; error?: string }
if (!result.ok && result.error) {
console.error('browser:newTab failed', result.error)
}
})
}, [])
const handleSwitchTab = useCallback((tabId: string) => {
void window.ipc.invoke('browser:switchTab', { tabId })
}, [])
const handleCloseTab = useCallback((tabId: string) => {
void window.ipc.invoke('browser:closeTab', { tabId })
}, [])
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
e.preventDefault()
const trimmed = addressValue.trim()
@ -236,10 +245,27 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
}, [])
return (
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
>
{/* Chrome row: back / forward / reload / address bar */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex h-9 shrink-0 items-stretch border-b border-border bg-sidebar">
<TabBar
tabs={state.tabs}
activeTabId={state.activeTabId ?? ''}
getTabTitle={getBrowserTabTitle}
getTabId={(tab) => tab.id}
onSwitchTab={handleSwitchTab}
onCloseTab={handleCloseTab}
layout="scroll"
/>
<button
type="button"
onClick={handleNewTab}
className="flex h-9 w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="New browser tab"
>
<Plus className="size-4" />
</button>
</div>
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
style={{ minHeight: CHROME_HEIGHT }}
@ -247,10 +273,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
<button
type="button"
onClick={handleBack}
disabled={!state.canGoBack}
disabled={!activeTab?.canGoBack}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
state.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
activeTab?.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Back"
>
@ -259,10 +285,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
<button
type="button"
onClick={handleForward}
disabled={!state.canGoForward}
disabled={!activeTab?.canGoForward}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
state.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
activeTab?.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Forward"
>
@ -271,10 +297,14 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
<button
type="button"
onClick={handleReload}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
disabled={!activeTab}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
activeTab ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Reload"
>
{state.loading ? (
{activeTab?.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
@ -291,7 +321,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
}}
onBlur={() => {
addressFocusedRef.current = false
setAddressValue(state.url)
setAddressValue(activeTab?.url ?? '')
}}
placeholder="Enter URL or search..."
className={cn(
@ -304,11 +334,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
autoCapitalize="off"
/>
</form>
{state.title && (
<div className="hidden max-w-[220px] shrink-0 truncate pl-2 text-xs text-muted-foreground sm:block">
{state.title}
</div>
)}
<button
type="button"
onClick={onClose}
@ -319,9 +344,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
</button>
</div>
{/* Viewport placeholder the WebContentsView is layered on top of this
area from the main process. The div itself stays transparent so the
user sees the web page, not a React element. */}
<div
ref={viewportRef}
className="relative min-h-0 min-w-0 flex-1"

View file

@ -640,6 +640,34 @@ const ipcSchemas = {
req: z.object({ visible: z.boolean() }),
res: z.object({ ok: z.literal(true) }),
},
'browser:newTab': {
req: z.object({
url: z.string().min(1).refine(
(u) => {
const lower = u.trim().toLowerCase();
if (lower.startsWith('javascript:')) return false;
if (lower.startsWith('file://')) return false;
if (lower.startsWith('chrome://')) return false;
if (lower.startsWith('chrome-extension://')) return false;
return true;
},
{ message: 'Unsafe URL scheme' },
).optional(),
}),
res: z.object({
ok: z.boolean(),
tabId: z.string().optional(),
error: z.string().optional(),
}),
},
'browser:switchTab': {
req: z.object({ tabId: z.string().min(1) }),
res: z.object({ ok: z.boolean() }),
},
'browser:closeTab': {
req: z.object({ tabId: z.string().min(1) }),
res: z.object({ ok: z.boolean() }),
},
'browser:navigate': {
req: z.object({
url: z.string().min(1).refine(
@ -674,20 +702,28 @@ const ipcSchemas = {
'browser:getState': {
req: z.null(),
res: z.object({
url: z.string(),
title: z.string(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
loading: z.boolean(),
activeTabId: z.string().nullable(),
tabs: z.array(z.object({
id: z.string(),
url: z.string(),
title: z.string(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
loading: z.boolean(),
})),
}),
},
'browser:didUpdateState': {
req: z.object({
url: z.string(),
title: z.string(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
loading: z.boolean(),
activeTabId: z.string().nullable(),
tabs: z.array(z.object({
id: z.string(),
url: z.string(),
title: z.string(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
loading: z.boolean(),
})),
}),
res: z.null(),
},