diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts new file mode 100644 index 00000000..ce9404e0 --- /dev/null +++ b/apps/x/apps/main/src/browser/ipc.ts @@ -0,0 +1,69 @@ +import { BrowserWindow } from 'electron'; +import { ipc } from '@x/shared'; +import { browserViewManager, type BrowserState } from './view.js'; + +type IPCChannels = ipc.IPCChannels; + +type InvokeHandler = ( + event: Electron.IpcMainInvokeEvent, + args: IPCChannels[K]['req'], +) => IPCChannels[K]['res'] | Promise; + +type BrowserHandlers = { + 'browser:setBounds': InvokeHandler<'browser:setBounds'>; + 'browser:setVisible': InvokeHandler<'browser:setVisible'>; + 'browser:navigate': InvokeHandler<'browser:navigate'>; + 'browser:back': InvokeHandler<'browser:back'>; + 'browser:forward': InvokeHandler<'browser:forward'>; + 'browser:reload': InvokeHandler<'browser:reload'>; + 'browser:getState': InvokeHandler<'browser:getState'>; +}; + +/** + * Browser-specific IPC handlers, exported as a plain object so they can be + * spread into the main `registerIpcHandlers({...})` call in ipc.ts. This + * mirrors the convention of keeping feature handlers flat and namespaced by + * channel prefix (`browser:*`). + */ +export const browserIpcHandlers: BrowserHandlers = { + 'browser:setBounds': async (_event, args) => { + browserViewManager.setBounds(args); + return { ok: true }; + }, + 'browser:setVisible': async (_event, args) => { + browserViewManager.setVisible(args.visible); + return { ok: true }; + }, + 'browser:navigate': async (_event, args) => { + return browserViewManager.navigate(args.url); + }, + 'browser:back': async () => { + return browserViewManager.back(); + }, + 'browser:forward': async () => { + return browserViewManager.forward(); + }, + 'browser:reload': async () => { + browserViewManager.reload(); + return { ok: true }; + }, + 'browser:getState': async () => { + return browserViewManager.getState(); + }, +}; + +/** + * Wire the BrowserViewManager's state-updated event to all renderer windows + * as a `browser:didUpdateState` push. Must be called once after the main + * window is created so the manager has a window to attach to. + */ +export function setupBrowserEventForwarding(): void { + browserViewManager.on('state-updated', (state: BrowserState) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('browser:didUpdateState', state); + } + } + }); +} diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts new file mode 100644 index 00000000..328b2b3c --- /dev/null +++ b/apps/x/apps/main/src/browser/view.ts @@ -0,0 +1,207 @@ +import { BrowserWindow, WebContentsView, session, shell } from 'electron'; +import { EventEmitter } from 'node:events'; + +/** + * 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. + * + * 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. + */ + +const PARTITION = 'persist:rowboat-browser'; + +// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers +// that sniff the UA looking for "real browser" shapes. +const SPOOF_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; + +const HOME_URL = 'https://www.google.com'; + +export interface BrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface BrowserState { + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; +} + +const EMPTY_STATE: BrowserState = { + url: '', + title: '', + canGoBack: false, + canGoForward: false, + loading: false, +}; + +export class BrowserViewManager extends EventEmitter { + private window: BrowserWindow | null = null; + private view: WebContentsView | null = null; + private attached = false; + private visible = false; + private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; + + attach(window: BrowserWindow): void { + this.window = window; + window.on('closed', () => { + this.window = null; + this.view = null; + this.attached = false; + 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. + const browserSession = session.fromPartition(PARTITION); + browserSession.setUserAgent(SPOOF_UA); + + const view = new WebContentsView({ + webPreferences: { + session: browserSession, + 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; + return view; + } + + private wireEvents(view: WebContentsView): void { + const wc = view.webContents; + + const emit = () => this.emit('state-updated', this.snapshotState()); + + wc.on('did-navigate', emit); + wc.on('did-navigate-in-page', emit); + wc.on('did-start-loading', emit); + wc.on('did-stop-loading', emit); + wc.on('did-finish-load', emit); + wc.on('did-fail-load', 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); + 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) { + if (!this.attached) { + contentView.addChildView(view); + this.attached = true; + } + 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; + if (this.view && 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; + return { + url: wc.getURL(), + title: wc.getTitle(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + loading: wc.isLoading(), + }; + } +} + +export const browserViewManager = new BrowserViewManager(); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5a6e37f0..ec1a0aaa 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,6 +52,7 @@ import { replaceTrackBlockYaml, deleteTrackBlock, } from '@x/core/dist/knowledge/track/fileops.js'; +import { browserIpcHandlers } from './browser/ipc.js'; /** * Convert markdown to a styled HTML document for PDF/DOCX export. @@ -825,5 +826,7 @@ export function setupIpcHandlers() { 'billing:getInfo': async () => { return await getBillingInfo(); }, + // Embedded browser handlers (WebContentsView + navigation) + ...browserIpcHandlers, }); } diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index e8c6ee53..e6e5c016 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -31,6 +31,8 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; +import { browserViewManager } from "./browser/view.js"; +import { setupBrowserEventForwarding } from "./browser/ipc.js"; const execAsync = promisify(exec); @@ -175,6 +177,10 @@ function createWindow() { } }); + // Attach the embedded browser pane manager to this window. + // The WebContentsView is created lazily on first `browser:setVisible`. + browserViewManager.attach(win); + if (app.isPackaged) { win.loadURL("app://-/index.html"); } else { @@ -216,6 +222,7 @@ app.whenReady().then(async () => { await initConfigs(); setupIpcHandlers(); + setupBrowserEventForwarding(); createWindow(); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 31881fe0..3a4cb5ac 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -57,6 +57,7 @@ import { OnboardingModal } from '@/components/onboarding' import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog' import { TrackModal } from '@/components/track-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' +import { BrowserPane } from '@/components/browser-pane/BrowserPane' import { VersionHistoryPanel } from '@/components/version-history-panel' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' @@ -455,6 +456,8 @@ function FixedSidebarToggle({ meetingSummarizing, meetingAvailable, onToggleMeeting, + isBrowserOpen, + onToggleBrowser, leftInsetPx, }: { onNewChat: () => void @@ -463,6 +466,8 @@ function FixedSidebarToggle({ meetingSummarizing: boolean meetingAvailable: boolean onToggleMeeting: () => void + isBrowserOpen: boolean + onToggleBrowser: () => void leftInsetPx: number }) { const { toggleSidebar } = useSidebar() @@ -528,6 +533,49 @@ function FixedSidebarToggle({ )} + + + + + {isBrowserOpen ? 'Close browser' : 'Open browser'} + + {/* Back / Forward navigation */} + {isCollapsed && ( + <> + + + + )} ) } @@ -606,6 +654,7 @@ function App() { const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) const [isGraphOpen, setIsGraphOpen] = useState(false) + const [isBrowserOpen, setIsBrowserOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ @@ -2714,6 +2763,24 @@ function App() { setIsChatSidebarOpen(prev => !prev) }, []) + // Browser is an overlay on the middle pane: opening it forces the chat + // sidebar to be visible on the right; closing it restores whatever the + // middle pane was showing previously (file/graph/task/chat). + const handleToggleBrowser = useCallback(() => { + setIsBrowserOpen(prev => { + const next = !prev + if (next) { + setIsChatSidebarOpen(true) + setIsRightPaneMaximized(false) + } + return next + }) + }, []) + + const handleCloseBrowser = useCallback(() => { + setIsBrowserOpen(false) + }, []) + const toggleRightPaneMaximize = useCallback(() => { setIsChatSidebarOpen(true) setIsRightPaneMaximized(prev => !prev) @@ -2797,6 +2864,9 @@ function App() { case 'file': setSelectedBackgroundTask(null) setIsGraphOpen(false) + // Navigating to a file dismisses the browser overlay so the file is + // visible in the middle pane. + setIsBrowserOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -2809,6 +2879,7 @@ function App() { case 'graph': setSelectedBackgroundTask(null) setSelectedPath(null) + setIsBrowserOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -2819,6 +2890,7 @@ function App() { case 'task': setSelectedPath(null) setIsGraphOpen(false) + setIsBrowserOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -2826,6 +2898,8 @@ function App() { case 'chat': setSelectedPath(null) setIsGraphOpen(false) + // Don't touch isBrowserOpen here — chat navigation should land in + // the right sidebar when the browser overlay is active. setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) @@ -3101,7 +3175,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask + const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3964,7 +4038,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4006,7 +4080,7 @@ function App() { onNewChat: handleNewChatTab, onSelectRun: (runIdToLoad) => { cancelRecordingIfActive() - if (selectedPath || isGraphOpen) { + if (selectedPath || isGraphOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4016,8 +4090,8 @@ function App() { switchChatTab(existingTab.id) return } - // In two-pane mode, keep current knowledge/graph context and just swap chat context. - if (selectedPath || isGraphOpen) { + // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. + if (selectedPath || isGraphOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4041,14 +4115,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen) { + if (selectedPath || isGraphOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen) { + if (selectedPath || isGraphOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4146,7 +4220,7 @@ function App() { Version history )} - {!selectedPath && !isGraphOpen && !selectedTask && ( + {!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && ( + + +
+ setAddressValue(e.target.value)} + onFocus={(e) => { + setAddressFocused(true) + e.currentTarget.select() + }} + onBlur={() => setAddressFocused(false)} + placeholder="Enter URL or search..." + className={cn( + 'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground', + 'placeholder:text-muted-foreground/60', + 'focus:border-border focus:outline-hidden', + )} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + /> +
+ {state.title && ( +
+ {state.title} +
+ )} + + + + {/* 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. */} +
+
+ ) +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 1ba5fce0..628dd41d 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -626,6 +626,71 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // Embedded browser (WebContentsView) channels + 'browser:setBounds': { + req: z.object({ + x: z.number().int(), + y: z.number().int(), + width: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + }), + res: z.object({ ok: z.literal(true) }), + }, + 'browser:setVisible': { + req: z.object({ visible: z.boolean() }), + res: z.object({ ok: z.literal(true) }), + }, + 'browser:navigate': { + 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' }, + ), + }), + res: z.object({ + ok: z.boolean(), + error: z.string().optional(), + }), + }, + 'browser:back': { + req: z.null(), + res: z.object({ ok: z.boolean() }), + }, + 'browser:forward': { + req: z.null(), + res: z.object({ ok: z.boolean() }), + }, + 'browser:reload': { + req: z.null(), + res: z.object({ ok: z.literal(true) }), + }, + 'browser:getState': { + req: z.null(), + res: z.object({ + 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(), + }), + res: z.null(), + }, // Billing channels 'billing:getInfo': { req: z.null(),