Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else. **Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for the same thing without a mouse. Reordering deliberately does not select what it moves, so a drag aimed at a background tab doesn't yank the main area away from a terminal mid-run. A tab being renamed is not draggable — a draggable ancestor swallows the mouse-drag that selects text in its input. **Pop the browser view out.** `browser_view/popout.rs` opens the view's existing token-bearing loopback URL as a second OS window, with a "Keep on top" toggle so it can float above the app. Window-only: the viewer, the proxy and the container are untouched, so popping out and back interrupts nothing. Three things it rests on: - No capability lists that window, so it has no IPC surface — right for a page served out of a container, and it must stay that way. - The app CSP is irrelevant to it: `frame-src` constrains what the app's document may *embed*, and this is a top-level document. The port range and the token gate are what actually protect it, unchanged. - The window is owned by the session, so the supervisor's teardown closes it. A window onto a viewer that no longer exists is worse than none. The pane drops its iframe while popped out — two viewers can both *drive* the browser, and two cursors on one page is not a feature. `lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It fires for every window and its body stops every container and exits, so without the guard closing a pop-out would quit the app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
|
||||
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
|
||||
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
||||
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||
const isBrowserViewPopoutOpen = vi.fn<() => Promise<boolean>>();
|
||||
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const pushToast = vi.fn();
|
||||
const setContainerProgress = vi.fn();
|
||||
|
||||
@@ -22,6 +26,11 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
checkBrowserViewSupport: () => checkBrowserViewSupport(),
|
||||
installBrowserViewSupport: () => installBrowserViewSupport(),
|
||||
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||
isBrowserViewPopoutOpen: () => isBrowserViewPopoutOpen(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -111,8 +120,34 @@ beforeEach(() => {
|
||||
storeState.containerProgress = {};
|
||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(false);
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "running",
|
||||
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||
host_port: 47820,
|
||||
container_port: 39321,
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
};
|
||||
|
||||
/** Render with the view already live, which is the only state that pops out. */
|
||||
async function renderLive() {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
setBrowserViewEnabled.mockResolvedValue(LIVE);
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||
});
|
||||
await screen.findByTitle("Playwright browser view for api-server");
|
||||
}
|
||||
|
||||
describe("BrowserTab", () => {
|
||||
it("does not offer to start anything while the container is stopped", async () => {
|
||||
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||||
@@ -325,4 +360,83 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("only offers a window of its own once there is something to watch", async () => {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
|
||||
// The window is showing it now — a second copy here would be a second
|
||||
// cursor on the same page.
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
// Still live, and still stoppable from the tab.
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the view back in the tab when the window is closed from here", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
|
||||
});
|
||||
|
||||
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the window on top on request", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: /above other windows/i }));
|
||||
});
|
||||
|
||||
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
|
||||
});
|
||||
|
||||
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
|
||||
// The window belongs to the backend, so reopening the tab has to read its
|
||||
// state back — otherwise the pane would render an iframe alongside it.
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(true);
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "error", detail: "no display" }),
|
||||
);
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,27 @@ import type {
|
||||
BrowserInstallTarget,
|
||||
BrowserSetupOutcome,
|
||||
BrowserViewChangedEvent,
|
||||
BrowserViewPopoutChangedEvent,
|
||||
BrowserViewStatus,
|
||||
PlaywrightDetection,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import {
|
||||
checkBrowserViewSupport,
|
||||
closeBrowserViewPopout,
|
||||
getBrowserViewStatus,
|
||||
installBrowserViewBrowser,
|
||||
installBrowserViewSupport,
|
||||
isBrowserViewPopoutOpen,
|
||||
openBrowserViewPopout,
|
||||
setBrowserViewEnabled,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import Toggle from "../../ui/Toggle";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -66,6 +72,9 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
const [job, setJob] = useState<SetupJob>(null);
|
||||
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
/** Whether the view is currently in its own window instead of this pane. */
|
||||
const [poppedOut, setPoppedOut] = useState(false);
|
||||
const [onTop, setOnTop] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||
@@ -95,8 +104,28 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
// The window is the backend's, not this component's: it survives the tab
|
||||
// being closed, the pane being unmounted and the view being torn down from
|
||||
// elsewhere. So its state is listened for, never assumed.
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
||||
if (event.payload.project_id === projectId && mounted.current) {
|
||||
setPoppedOut(event.payload.open);
|
||||
if (!event.payload.open) setOnTop(false);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
else un();
|
||||
});
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
isBrowserViewPopoutOpen(projectId)
|
||||
.then((open) => mounted.current && setPoppedOut(open))
|
||||
.catch(() => {});
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -129,6 +158,56 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pop the view out, or pull it back.
|
||||
*
|
||||
* Both are window operations only — the viewer keeps running either way — so
|
||||
* this is cheap enough to toggle freely and never interrupts what the agent
|
||||
* is doing in the browser.
|
||||
*/
|
||||
const popOut = useCallback(async () => {
|
||||
try {
|
||||
await openBrowserViewPopout(projectId, onTop);
|
||||
if (mounted.current) setPoppedOut(true);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open the browser in its own window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, onTop, pushToast]);
|
||||
|
||||
const popIn = useCallback(async () => {
|
||||
try {
|
||||
await closeBrowserViewPopout(projectId);
|
||||
if (mounted.current) setPoppedOut(false);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not close the browser window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, pushToast]);
|
||||
|
||||
const toggleOnTop = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOnTop(next);
|
||||
try {
|
||||
await setBrowserViewPopoutAlwaysOnTop(projectId, next);
|
||||
} catch (e) {
|
||||
if (mounted.current) setOnTop(!next);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not change the window's stacking",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/** Run one install. Every path clears the progress line it started. */
|
||||
const install = useCallback(
|
||||
async (which: Exclude<SetupJob, null>) => {
|
||||
@@ -225,11 +304,26 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && (
|
||||
{live && poppedOut && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Keep on top
|
||||
<Toggle
|
||||
checked={onTop}
|
||||
onChange={toggleOnTop}
|
||||
label="Keep the browser window above other windows"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{live && !poppedOut && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
{live && (
|
||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant={live ? "secondary" : "primary"}
|
||||
@@ -240,7 +334,28 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
{live && poppedOut ? (
|
||||
// The iframe is unmounted while the window is up, on purpose. Two
|
||||
// viewers on one browser both work, but both also *drive* it — two
|
||||
// cursors taking over the same page is not a feature.
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
<div className="max-w-[28rem] text-center">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This view is in its own window.
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
Move it to another screen, or keep it on top, and watch the browser while
|
||||
you work here. The view keeps running either way — closing the window
|
||||
brings it back into this tab.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Button size="md" variant="primary" onClick={popIn}>
|
||||
Put back in tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : live ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// Loopback only, and the URL carries the one-time session token the
|
||||
|
||||
Reference in New Issue
Block a user