mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
browser initial commit
This commit is contained in:
parent
e2c13f0f6f
commit
b0f5ed85c6
7 changed files with 690 additions and 11 deletions
69
apps/x/apps/main/src/browser/ipc.ts
Normal file
69
apps/x/apps/main/src/browser/ipc.ts
Normal file
|
|
@ -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<K extends keyof IPCChannels> = (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
args: IPCChannels[K]['req'],
|
||||
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
207
apps/x/apps/main/src/browser/view.ts
Normal file
207
apps/x/apps/main/src/browser/view.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
|
||||
isBrowserOpen
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label={isBrowserOpen ? "Close browser" : "Open browser"}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isBrowserOpen ? 'Close browser' : 'Open browser'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Back / Forward navigation */}
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -606,6 +654,7 @@ function App() {
|
|||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
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<Record<string, BaseConfig>>({})
|
||||
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() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && (
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4161,7 +4235,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4195,7 +4269,9 @@ function App() {
|
|||
)}
|
||||
</ContentHeader>
|
||||
|
||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
{isBrowserOpen ? (
|
||||
<BrowserPane onClose={handleCloseBrowser} />
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
tree={tree}
|
||||
|
|
@ -4568,6 +4644,8 @@ function App() {
|
|||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
250
apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx
Normal file
250
apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Embedded browser pane.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface BrowserState {
|
||||
url: string
|
||||
title: string
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
url: '',
|
||||
title: '',
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loading: false,
|
||||
}
|
||||
|
||||
/** Placeholder height we subtract from the inner bounds for the chrome row. */
|
||||
const CHROME_HEIGHT = 40
|
||||
|
||||
interface BrowserPaneProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
const [addressFocused, setAddressFocused] = useState(false)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
||||
|
||||
// ── Subscribe to state updates from main ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
||||
const next = incoming as BrowserState
|
||||
setState(next)
|
||||
})
|
||||
// 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) => {
|
||||
setState(initial as BrowserState)
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Keep the address bar in sync with the active page URL, but only when the
|
||||
// input isn't focused — don't clobber whatever the user is typing.
|
||||
useEffect(() => {
|
||||
if (!addressFocused) {
|
||||
setAddressValue(state.url)
|
||||
}
|
||||
}, [state.url, addressFocused])
|
||||
|
||||
// ── Visibility lifecycle ──────────────────────────────────────────────────
|
||||
// Show on mount, hide on unmount. The WebContentsView is expensive to
|
||||
// create but cheap to show/hide.
|
||||
useEffect(() => {
|
||||
void window.ipc.invoke('browser:setVisible', { visible: true })
|
||||
return () => {
|
||||
void window.ipc.invoke('browser:setVisible', { visible: false })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Bounds tracking ───────────────────────────────────────────────────────
|
||||
// The main process needs pixel-accurate bounds *relative to the window
|
||||
// content area*. getBoundingClientRect() returns viewport-relative coords,
|
||||
// which in Electron with no custom chrome equal content-area coords.
|
||||
const pushBounds = useCallback(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const bounds = {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
}
|
||||
const last = lastBoundsRef.current
|
||||
if (
|
||||
last &&
|
||||
last.x === bounds.x &&
|
||||
last.y === bounds.y &&
|
||||
last.width === bounds.width &&
|
||||
last.height === bounds.height
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastBoundsRef.current = bounds
|
||||
void window.ipc.invoke('browser:setBounds', bounds)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
|
||||
// Initial push + ResizeObserver for size changes.
|
||||
pushBounds()
|
||||
const ro = new ResizeObserver(() => pushBounds())
|
||||
ro.observe(el)
|
||||
|
||||
// The container may move without resizing (sidebar collapse, window
|
||||
// resize). Listen on window resize for that case.
|
||||
const onWindowResize = () => pushBounds()
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
|
||||
// Also poll briefly during layout transitions (the sidebar uses CSS
|
||||
// transitions that don't fire ResizeObserver every frame).
|
||||
const interval = window.setInterval(pushBounds, 100)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [pushBounds])
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = addressValue.trim()
|
||||
if (!trimmed) return
|
||||
void window.ipc.invoke('browser:navigate', { url: trimmed }).then((res) => {
|
||||
const result = res as { ok: boolean; error?: string }
|
||||
if (!result.ok && result.error) {
|
||||
console.error('browser:navigate failed', result.error)
|
||||
}
|
||||
})
|
||||
}, [addressValue])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
void window.ipc.invoke('browser:back', null)
|
||||
}, [])
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
void window.ipc.invoke('browser:forward', null)
|
||||
}, [])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
void window.ipc.invoke('browser:reload', null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
{/* Chrome row: back / forward / reload / address bar */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
|
||||
style={{ minHeight: CHROME_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={!state.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',
|
||||
)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForward}
|
||||
disabled={!state.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',
|
||||
)}
|
||||
aria-label="Forward"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
<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"
|
||||
aria-label="Reload"
|
||||
>
|
||||
{state.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<form onSubmit={handleSubmitAddress} className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={addressValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</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}
|
||||
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close browser"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</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 flex-1"
|
||||
data-browser-viewport
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue