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

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:
2026-08-11 06:50:40 -07:00
co-authored by Claude Opus 5
parent 57b6b71772
commit d73096c937
15 changed files with 1068 additions and 127 deletions
+117 -2
View File
@@ -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