From 057bb00c7d2842ffc80e60599a5d12924fcff65d Mon Sep 17 00:00:00 2001 From: Vaibhaav Date: Sun, 21 Jun 2026 20:36:12 +0530 Subject: [PATCH 01/10] feat(frontend): add live browser panel --- frontend/forge.config.ts | 1 + frontend/src/annotate-preload.ts | 1 + frontend/src/main.ts | 23 +- frontend/src/main/browser-view-host.test.ts | 40 +++ frontend/src/main/browser-view-host.ts | 313 ++++++++++++++++++ frontend/src/preload.ts | 30 ++ .../renderer/components/BrowserPanel.test.tsx | 112 +++++++ .../src/renderer/components/BrowserPanel.tsx | 104 ++++++ .../components/ProjectSettingsForm.test.tsx | 2 +- .../renderer/components/SessionInspector.tsx | 59 +++- .../renderer/components/SessionView.test.tsx | 36 +- .../src/renderer/components/SessionView.tsx | 29 +- .../renderer/hooks/useBrowserView.test.tsx | 142 ++++++++ frontend/src/renderer/hooks/useBrowserView.ts | 169 ++++++++++ frontend/src/renderer/lib/bridge.ts | 53 +++ frontend/src/renderer/styles.css | 88 +++++ frontend/src/renderer/test/setup.ts | 53 +++ frontend/vite.renderer.config.ts | 1 + 18 files changed, 1232 insertions(+), 24 deletions(-) create mode 100644 frontend/src/annotate-preload.ts create mode 100644 frontend/src/main/browser-view-host.test.ts create mode 100644 frontend/src/main/browser-view-host.ts create mode 100644 frontend/src/renderer/components/BrowserPanel.test.tsx create mode 100644 frontend/src/renderer/components/BrowserPanel.tsx create mode 100644 frontend/src/renderer/hooks/useBrowserView.test.tsx create mode 100644 frontend/src/renderer/hooks/useBrowserView.ts diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index e441d4a3..8c984a4a 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -75,6 +75,7 @@ const config: ForgeConfig = { build: [ { entry: "src/main.ts", config: "vite.main.config.ts", target: "main" }, { entry: "src/preload.ts", config: "vite.preload.config.ts", target: "preload" }, + { entry: "src/annotate-preload.ts", config: "vite.preload.config.ts", target: "preload" }, ], renderer: [{ name: "main_window", config: "vite.renderer.config.ts" }], }), diff --git a/frontend/src/annotate-preload.ts b/frontend/src/annotate-preload.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/frontend/src/annotate-preload.ts @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ed18a681..2801a9ea 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, type OpenDialogOptions } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, WebContentsView, type OpenDialogOptions } from "electron"; import { updateElectronApp } from "update-electron-app"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; @@ -11,6 +11,7 @@ import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./sha import type { DaemonStatus } from "./shared/daemon-status"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; +import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -33,6 +34,7 @@ let daemonStoppingProcess: ChildProcessWithoutNullStreams | null = null; let daemonStartPromise: Promise | null = null; let daemonStartEpoch = 0; let daemonStatus: DaemonStatus = { state: "stopped" }; +let browserViewHost: BrowserViewHost | null = null; const isDev = !app.isPackaged; @@ -89,6 +91,10 @@ function preloadPath(): string { return path.join(__dirname, "preload.js"); } +function annotatePreloadPath(): string { + return path.join(__dirname, "annotate-preload.js"); +} + // Runtime window/taskbar icon for Linux and Windows. macOS ignores this and // uses the .app bundle's .icns instead. Packaged: shipped via extraResource to // resources/icon.png; dev: the source asset under frontend/assets. @@ -105,6 +111,8 @@ function setDaemonStatus(nextStatus: DaemonStatus): void { } function createWindow(): void { + browserViewHost?.dispose(); + browserViewHost = null; mainWindow = new BrowserWindow({ width: 1320, height: 860, @@ -143,6 +151,15 @@ function createWindow(): void { } }); + browserViewHost = createBrowserViewHost({ + mainWindow, + ipcMain, + shell, + WebContentsView, + annotatePreloadPath: annotatePreloadPath(), + rendererOrigin: RENDERER_ORIGIN, + }); + void mainWindow.loadURL(rendererUrl()); if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") { @@ -152,6 +169,8 @@ function createWindow(): void { } mainWindow.on("closed", () => { + browserViewHost?.dispose(); + browserViewHost = null; mainWindow = null; }); } @@ -564,6 +583,8 @@ app.whenReady().then(() => { }); app.on("before-quit", () => { + browserViewHost?.dispose(); + browserViewHost = null; if (daemonProcess) { killDaemon(daemonProcess); } diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts new file mode 100644 index 00000000..eb934063 --- /dev/null +++ b/frontend/src/main/browser-view-host.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { clampBoundsToWindow, normalizeBrowserURL } from "./browser-view-host"; + +describe("normalizeBrowserURL", () => { + it("defaults localhost-style inputs to http", () => { + expect(normalizeBrowserURL("localhost:5173").href).toBe("http://localhost:5173/"); + expect(normalizeBrowserURL("127.0.0.1:3000").href).toBe("http://127.0.0.1:3000/"); + expect(normalizeBrowserURL("[::1]:4173").href).toBe("http://[::1]:4173/"); + }); + + it("defaults ordinary bare hosts to https", () => { + expect(normalizeBrowserURL("example.com").href).toBe("https://example.com/"); + }); + + it("rejects privileged or unsupported schemes", () => { + expect(() => normalizeBrowserURL("file:///C:/tmp/index.html")).toThrow(/unsupported/i); + expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i); + expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i); + }); +}); + +describe("clampBoundsToWindow", () => { + it("rounds and clamps bounds to the window content area", () => { + expect( + clampBoundsToWindow( + { x: -10.4, y: 20.6, width: 900.2, height: 700.8 }, + { width: 800, height: 600 }, + ), + ).toEqual({ x: 0, y: 21, width: 800, height: 579 }); + }); + + it("returns a zero-sized rectangle when the slot is outside the window", () => { + expect(clampBoundsToWindow({ x: 900, y: 10, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({ + x: 800, + y: 10, + width: 0, + height: 100, + }); + }); +}); diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts new file mode 100644 index 00000000..bd9cff72 --- /dev/null +++ b/frontend/src/main/browser-view-host.ts @@ -0,0 +1,313 @@ +import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent, Rectangle, View, WebContents } from "electron"; + +export type BrowserRect = Pick; + +export type BrowserNavState = { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; + error?: string; +}; + +type BrowserBoundsInput = { + viewId: string; + rect: BrowserRect; + visible: boolean; +}; + +type BrowserNavigateInput = { + viewId: string; + url: string; +}; + +type BrowserWebContents = Pick< + WebContents, + | "canGoBack" + | "canGoForward" + | "getTitle" + | "getURL" + | "goBack" + | "goForward" + | "isLoading" + | "loadURL" + | "on" + | "reload" + | "send" + | "setWindowOpenHandler" + | "stop" +> & { + close?: () => void; +}; + +type BrowserViewLike = View & { + webContents: BrowserWebContents; + setBounds: (bounds: BrowserRect) => void; + setVisible?: (visible: boolean) => void; +}; + +type BrowserWindowLike = { + contentView: { + addChildView: (view: BrowserViewLike) => void; + removeChildView?: (view: BrowserViewLike) => void; + }; + getContentBounds: () => BrowserRect; + webContents: Pick; +}; + +type ShellLike = { + openExternal: (url: string) => Promise; +}; + +type WebContentsViewConstructor = new (options: { + webPreferences: Electron.WebPreferences; +}) => BrowserViewLike; + +export type BrowserViewHostOptions = { + mainWindow: BrowserWindowLike; + ipcMain: Pick; + shell: ShellLike; + WebContentsView: WebContentsViewConstructor; + annotatePreloadPath: string; + rendererOrigin: string; +}; + +export type BrowserViewHost = { + dispose: () => void; + destroy: (viewId: string) => void; + destroyAll: () => void; +}; + +type BrowserEntry = { + view: BrowserViewLike; + state: BrowserNavState; +}; + +const OFFSCREEN_BOUNDS: BrowserRect = { x: -10_000, y: -10_000, width: 0, height: 0 }; +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +export function normalizeBrowserURL(input: string): URL { + const raw = input.trim(); + if (raw === "") { + throw new Error("URL is required"); + } + const candidate = withDefaultScheme(raw); + const url = new URL(candidate); + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + throw new Error(`Unsupported browser URL scheme: ${url.protocol}`); + } + return url; +} + +export function isAllowedBrowserURL(input: string, rendererOrigin?: string): boolean { + try { + const url = normalizeBrowserURL(input); + if (rendererOrigin && url.origin === rendererOrigin) return false; + return true; + } catch { + return false; + } +} + +export function clampBoundsToWindow(rect: BrowserRect, windowBounds: Pick): BrowserRect { + const rounded = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(0, Math.round(rect.width)), + height: Math.max(0, Math.round(rect.height)), + }; + const maxX = Math.max(0, Math.round(windowBounds.width)); + const maxY = Math.max(0, Math.round(windowBounds.height)); + const x = Math.min(Math.max(rounded.x, 0), maxX); + const y = Math.min(Math.max(rounded.y, 0), maxY); + return { + x, + y, + width: Math.min(rounded.width, Math.max(0, maxX - x)), + height: Math.min(rounded.height, Math.max(0, maxY - y)), + }; +} + +export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserViewHost { + const entries = new Map(); + const ipcDisposers: Array<() => void> = []; + + const ensure = (viewId: string): BrowserEntry => { + const existing = entries.get(viewId); + if (existing) return existing; + + const view = new options.WebContentsView({ + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: options.annotatePreloadPath, + sandbox: true, + }, + }); + view.setBounds(OFFSCREEN_BOUNDS); + view.setVisible?.(false); + options.mainWindow.contentView.addChildView(view); + + const state: BrowserNavState = emptyNavState(viewId); + const entry = { view, state }; + entries.set(viewId, entry); + hardenWebContents(view.webContents, options, entry); + wireNavEvents(view.webContents, options, entry); + return entry; + }; + + const setBounds = ({ viewId, rect, visible }: BrowserBoundsInput): void => { + const entry = entries.get(viewId); + if (!entry) return; + if (!visible) { + entry.view.setVisible?.(false); + entry.view.setBounds(OFFSCREEN_BOUNDS); + return; + } + const bounds = clampBoundsToWindow(rect, options.mainWindow.getContentBounds()); + entry.view.setBounds(bounds); + entry.view.setVisible?.(bounds.width > 0 && bounds.height > 0); + }; + + const navigate = async ({ viewId, url }: BrowserNavigateInput): Promise => { + const entry = ensure(viewId); + const normalized = normalizeBrowserURL(url); + if (!isAllowedBrowserURL(normalized.href, options.rendererOrigin)) { + throw new Error("Unsupported browser URL"); + } + await entry.view.webContents.loadURL(normalized.href); + return pushNavState(options, entry); + }; + + const destroy = (viewId: string): void => { + const entry = entries.get(viewId); + if (!entry) return; + entries.delete(viewId); + entry.view.setVisible?.(false); + entry.view.setBounds(OFFSCREEN_BOUNDS); + options.mainWindow.contentView.removeChildView?.(entry.view); + entry.view.webContents.close?.(); + }; + + const invokeNav = (viewId: string, action: (contents: BrowserWebContents) => void): BrowserNavState => { + const entry = entries.get(viewId); + if (!entry) return emptyNavState(viewId); + action(entry.view.webContents); + return pushNavState(options, entry); + }; + + const handle = ( + channel: string, + fn: (event: IpcMainInvokeEvent, ...args: Args) => Result, + ): void => { + options.ipcMain.handle(channel, fn); + ipcDisposers.push(() => options.ipcMain.removeHandler(channel)); + }; + const on = (channel: string, fn: (event: IpcMainEvent, ...args: Args) => void): void => { + options.ipcMain.on(channel, fn); + ipcDisposers.push(() => options.ipcMain.off(channel, fn)); + }; + + handle("browser:ensure", (event, sessionId: string) => pushNavState(options, ensure(scopedViewId(event, sessionId)))); + on("browser:setBounds", (_event, input: BrowserBoundsInput) => setBounds(input)); + handle("browser:navigate", (_event, input: BrowserNavigateInput) => navigate(input)); + handle("browser:goBack", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goBack())); + handle("browser:goForward", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goForward())); + handle("browser:reload", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.reload())); + handle("browser:stop", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.stop())); + on("browser:destroy", (_event, viewId: string) => destroy(viewId)); + + return { + dispose: () => { + ipcDisposers.splice(0).forEach((dispose) => dispose()); + for (const viewId of [...entries.keys()]) { + destroy(viewId); + } + }, + destroy, + destroyAll: () => { + for (const viewId of [...entries.keys()]) { + destroy(viewId); + } + }, + }; +} + +function withDefaultScheme(raw: string): string { + if (/^https?:\/\//i.test(raw)) return raw; + if (isLocalhostLike(raw)) return `http://${raw}`; + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw; + return `https://${raw}`; +} + +function isLocalhostLike(raw: string): boolean { + return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw); +} + +function emptyNavState(viewId: string): BrowserNavState { + return { + viewId, + url: "", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }; +} + +function scopedViewId(event: IpcMainInvokeEvent, sessionId: string): string { + return `${event.sender.id}:${sessionId}`; +} + +function hardenWebContents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void { + contents.setWindowOpenHandler(({ url }) => { + if (isAllowedBrowserURL(url, options.rendererOrigin)) { + void options.shell.openExternal(url); + } + return { action: "deny" }; + }); + const blockUnsafeNavigation = (event: Electron.Event, url: string) => { + if (!isAllowedBrowserURL(url, options.rendererOrigin)) { + event.preventDefault(); + entry.state = { ...entry.state, error: "Unsupported browser URL" }; + options.mainWindow.webContents.send("browser:navState", entry.state); + } + }; + contents.on("will-navigate", blockUnsafeNavigation); + contents.on("will-redirect", blockUnsafeNavigation); +} + +function wireNavEvents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void { + const update = () => { + pushNavState(options, entry); + }; + contents.on("did-navigate", update); + contents.on("did-navigate-in-page", update); + contents.on("page-title-updated", update); + contents.on("did-start-loading", update); + contents.on("did-stop-loading", update); + contents.on("did-fail-load", (_event, _errorCode, errorDescription) => { + entry.state = { ...readNavState(entry), error: String(errorDescription || "Unable to load page") }; + options.mainWindow.webContents.send("browser:navState", entry.state); + }); +} + +function pushNavState(options: BrowserViewHostOptions, entry: BrowserEntry): BrowserNavState { + entry.state = readNavState(entry); + options.mainWindow.webContents.send("browser:navState", entry.state); + return entry.state; +} + +function readNavState(entry: BrowserEntry): BrowserNavState { + const { webContents } = entry.view; + return { + viewId: entry.state.viewId, + url: webContents.getURL(), + title: webContents.getTitle(), + canGoBack: webContents.canGoBack(), + canGoForward: webContents.canGoForward(), + isLoading: webContents.isLoading(), + }; +} diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 15d69fa9..93e59b69 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -1,7 +1,19 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { BrowserNavState, BrowserRect } from "./main/browser-view-host"; import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; +export type BrowserBoundsInput = { + viewId: string; + rect: BrowserRect; + visible: boolean; +}; + +export type BrowserNavigateInput = { + viewId: string; + url: string; +}; + const api = { app: { getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise, @@ -22,6 +34,24 @@ const api = { telemetry: { getBootstrap: () => ipcRenderer.invoke("telemetry:getBootstrap") as Promise, }, + browser: { + ensure: (sessionId: string) => ipcRenderer.invoke("browser:ensure", sessionId) as Promise, + setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input), + navigate: (input: BrowserNavigateInput) => + ipcRenderer.invoke("browser:navigate", input) as Promise, + goBack: (viewId: string) => ipcRenderer.invoke("browser:goBack", viewId) as Promise, + goForward: (viewId: string) => ipcRenderer.invoke("browser:goForward", viewId) as Promise, + reload: (viewId: string) => ipcRenderer.invoke("browser:reload", viewId) as Promise, + stop: (viewId: string) => ipcRenderer.invoke("browser:stop", viewId) as Promise, + destroy: (viewId: string) => ipcRenderer.send("browser:destroy", viewId), + onNavState: (listener: (state: BrowserNavState) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, state: BrowserNavState) => listener(state); + ipcRenderer.on("browser:navState", wrapped); + return () => { + ipcRenderer.off("browser:navState", wrapped); + }; + }, + }, }; contextBridge.exposeInMainWorld("ao", api); diff --git a/frontend/src/renderer/components/BrowserPanel.test.tsx b/frontend/src/renderer/components/BrowserPanel.test.tsx new file mode 100644 index 00000000..d5808d86 --- /dev/null +++ b/frontend/src/renderer/components/BrowserPanel.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserPanel } from "./BrowserPanel"; +import type { BrowserNavState } from "../hooks/useBrowserView"; +import type { WorkspaceSession } from "../types/workspace"; + +const hookState = vi.hoisted(() => ({ + navigate: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + stop: vi.fn(), + navState: { + viewId: "42:sess-1", + url: "", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + } as BrowserNavState, +})); + +vi.mock("../hooks/useBrowserView", () => ({ + useBrowserView: () => ({ + viewId: "42:sess-1", + navState: hookState.navState, + slotRef: vi.fn(), + navigate: hookState.navigate, + goBack: hookState.goBack, + goForward: hookState.goForward, + reload: hookState.reload, + stop: hookState.stop, + }), +})); + +const session: WorkspaceSession = { + id: "sess-1", + workspaceId: "ws-1", + workspaceName: "my-app", + title: "do the thing", + provider: "claude-code", + kind: "worker", + branch: "feat/ns", + status: "working", + updatedAt: "2026-06-15T00:00:00Z", + prs: [], +}; + +describe("BrowserPanel", () => { + beforeEach(() => { + hookState.navigate.mockReset(); + hookState.goBack.mockReset(); + hookState.goForward.mockReset(); + hookState.reload.mockReset(); + hookState.stop.mockReset(); + hookState.navState = { + viewId: "42:sess-1", + url: "", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }; + }); + + it("navigates to the entered URL on submit", async () => { + render( undefined} poppedOut={false} session={session} />); + const input = screen.getByRole("textbox", { name: /browser url/i }); + + await userEvent.clear(input); + await userEvent.type(input, "localhost:5173{Enter}"); + + expect(hookState.navigate).toHaveBeenCalledWith("localhost:5173"); + }); + + it("binds navigation controls to nav state", async () => { + hookState.navState = { + viewId: "42:sess-1", + url: "http://localhost:5173/", + title: "Local app", + canGoBack: true, + canGoForward: false, + isLoading: true, + }; + render( undefined} poppedOut={false} session={session} />); + + await userEvent.click(screen.getByRole("button", { name: /back/i })); + await userEvent.click(screen.getByRole("button", { name: /stop/i })); + + expect(hookState.goBack).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /forward/i })).toBeDisabled(); + expect(hookState.stop).toHaveBeenCalled(); + }); + + it("shows empty and error states", () => { + hookState.navState = { ...hookState.navState, error: "Connection refused" }; + render( undefined} poppedOut={false} session={session} />); + + expect(screen.getByText("Enter a dev-server URL to preview it here.")).toBeInTheDocument(); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + }); + + it("toggles pop-out mode", async () => { + const onTogglePopOut = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: /pop out/i })); + + expect(onTogglePopOut).toHaveBeenCalledWith(true); + }); +}); diff --git a/frontend/src/renderer/components/BrowserPanel.tsx b/frontend/src/renderer/components/BrowserPanel.tsx new file mode 100644 index 00000000..44607c8b --- /dev/null +++ b/frontend/src/renderer/components/BrowserPanel.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { ArrowLeft, ArrowRight, Globe2, Maximize2, Minimize2, RefreshCw, X } from "lucide-react"; +import { useBrowserView } from "../hooks/useBrowserView"; +import type { WorkspaceSession } from "../types/workspace"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +type BrowserPanelProps = { + session: WorkspaceSession; + active: boolean; + poppedOut: boolean; + onTogglePopOut: (next: boolean) => void; +}; + +export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: BrowserPanelProps) { + const { navState, slotRef, navigate, goBack, goForward, reload, stop } = useBrowserView({ + sessionId: session.id, + active, + poppedOut, + }); + const [urlInput, setUrlInput] = useState(navState.url); + + useEffect(() => { + setUrlInput(navState.url); + }, [navState.url]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const nextURL = urlInput.trim(); + if (nextURL) void navigate(nextURL); + }; + + return ( +
+
+ + + +
+
+ +
+
+
+ {navState.url === "" ? ( +
+

Enter a dev-server URL to preview it here.

+
+ ) : null} + {navState.error ?

{navState.error}

: null} +
+
+ ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 99569a81..9ecb1cba 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -136,7 +136,7 @@ describe("ProjectSettingsForm", () => { }, }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); - }, 10_000); + }, 20_000); it("shows the daemon validation message when save fails", async () => { getMock.mockResolvedValue({ diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index edd99b9e..4f7b409a 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -7,7 +7,9 @@ import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; import type { PRState, PullRequestFacts, SessionStatus, WorkspaceSession } from "../types/workspace"; import { sortedPRs, workerDisplayStatus } from "../types/workspace"; +import { BrowserPanel } from "./BrowserPanel"; import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; import { cn } from "../lib/utils"; type ProjectConfig = components["schemas"]["ProjectConfig"]; @@ -67,9 +69,15 @@ const prStateTone: Record = { export function SessionInspector({ session, onOpenReviewerTerminal, + browserPoppedOut = false, + isInspectorVisible = true, + onToggleBrowserPopOut, }: { session?: WorkspaceSession; onOpenReviewerTerminal?: OpenReviewerTerminal; + browserPoppedOut?: boolean; + isInspectorVisible?: boolean; + onToggleBrowserPopOut?: (next: boolean) => void; }) { const [view, setView] = useState("summary"); @@ -104,7 +112,14 @@ export function SessionInspector({
{view === "summary" ? : null} {view === "reviews" ? : null} - {view === "browser" ? : null} + {view === "browser" ? ( + + ) : null}
); @@ -510,19 +525,37 @@ function reviewStatus(review?: ReviewRun): { return { label: "Complete", tone: "success", icon: