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
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
const A = homeTabKey("a");
const B = terminalTabKey("b");
const C = terminalTabKey("c");
function seed(tabOrder: string[], activeTabKey: string | null = null) {
useAppState.setState({
tabOrder,
activeTabKey,
activeSessionId: null,
selectedProjectId: null,
});
}
const order = () => useAppState.getState().tabOrder;
describe("tab reordering", () => {
beforeEach(() => seed([A, B, C]));
it("moves a tab to an earlier slot", () => {
useAppState.getState().moveTab(C, 0);
expect(order()).toEqual([C, A, B]);
});
it("moves a tab to a later slot", () => {
useAppState.getState().moveTab(A, 2);
expect(order()).toEqual([B, C, A]);
});
it("clamps a destination past the ends rather than dropping the tab", () => {
useAppState.getState().moveTab(A, 99);
expect(order()).toEqual([B, C, A]);
useAppState.getState().moveTab(A, -5);
expect(order()).toEqual([A, B, C]);
});
it("ignores a tab that isn't in the strip", () => {
useAppState.getState().moveTab("term:gone", 0);
expect(order()).toEqual([A, B, C]);
});
it("does not change what's active — dragging a tab is not selecting it", () => {
seed([A, B, C], B);
useAppState.getState().moveTab(C, 0);
const state = useAppState.getState();
expect(state.tabOrder).toEqual([C, A, B]);
expect(state.activeTabKey).toBe(B);
});
it("nudges the active tab with the keyboard, in both directions", () => {
seed([A, B, C], B);
useAppState.getState().moveActiveTab(-1);
expect(order()).toEqual([B, A, C]);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("stops the active tab at the ends instead of wrapping it around", () => {
seed([A, B, C], A);
useAppState.getState().moveActiveTab(-1);
// A held-down key must not teleport the tab to the far end.
expect(order()).toEqual([A, B, C]);
});
it("does nothing when no tab is active", () => {
seed([A, B, C], null);
useAppState.getState().moveActiveTab(1);
expect(order()).toEqual([A, B, C]);
});
it("keeps Ctrl+1..9 addressing the strip as reordered", () => {
seed([A, B, C], A);
useAppState.getState().moveTab(C, 0);
useAppState.getState().focusTabIndex(0);
expect(useAppState.getState().activeTabKey).toBe(C);
});
});
+33
View File
@@ -75,6 +75,10 @@ interface AppState {
setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void;
focusTabIndex: (index: number) => void;
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
moveTab: (key: string, toIndex: number) => void;
/** Nudge the active tab left/right — the keyboard route to the same thing. */
moveActiveTab: (delta: number) => void;
// Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>;
@@ -274,6 +278,35 @@ export const useAppState = create<AppState>((set) => ({
? { ...patch, selectedProjectId: tabKeyId(key) }
: patch;
}),
// Reordering is deliberately *only* a reordering: dragging a tab does not
// select it, so a drag can be aimed at a background tab without yanking the
// main area (and a running terminal's focus) away mid-gesture.
moveTab: (key, toIndex) =>
set((state) => {
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
moveActiveTab: (delta) =>
set((state) => {
const key = state.activeTabKey;
if (!key) return {};
const from = state.tabOrder.indexOf(key);
if (from === -1) return {};
// Clamped, not wrapped: a tab dragged off the end would otherwise
// reappear at the other end, which reads as a bug on a held-down key.
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
if (from === to) return {};
const tabOrder = [...state.tabOrder];
tabOrder.splice(from, 1);
tabOrder.splice(to, 0, key);
return { tabOrder };
}),
// Container progress
containerProgress: {},