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
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import MainTabs from "./MainTabs";
import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState";
import type { Project, TerminalSession } from "../../lib/types";
const close = vi.fn();
const sessions: TerminalSession[] = [
{
id: "s1",
projectId: "p1",
projectName: "api-server",
sessionName: "claude",
sessionType: "claude",
},
{
id: "s2",
projectId: "p1",
projectName: "api-server",
sessionName: "shell",
sessionType: "bash",
},
] as unknown as TerminalSession[];
const projects: Project[] = [
{
id: "p1",
name: "api-server",
status: "running",
permission_mode: "bypass",
renamed_session_names: {},
},
] as unknown as Project[];
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({ sessions, close }),
}));
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ projects, update: vi.fn() }),
}));
const HOME = homeTabKey("p1");
const S1 = terminalTabKey("s1");
const S2 = terminalTabKey("s2");
/**
* A stand-in for the DataTransfer jsdom doesn't implement. It only has to
* carry the tab key, which is what a drop falls back to reading.
*/
function dataTransfer() {
const store: Record<string, string> = {};
return {
effectAllowed: "",
dropEffect: "",
setData: (format: string, value: string) => {
store[format] = value;
},
getData: (format: string) => store[format] ?? "",
};
}
/**
* A dragover carrying a real `clientX`.
*
* jsdom has no `DragEvent`, so Testing Library's synthesized one is a plain
* `Event` with no pointer coordinates — and the coordinate is the whole point
* here, since it decides which side of a tab the drop lands on. A `MouseEvent`
* has one, and React reads it the same way.
*/
function dragOverAt(el: Element, clientX: number, dt: ReturnType<typeof dataTransfer>) {
const event = new MouseEvent("dragover", { bubbles: true, cancelable: true, clientX });
Object.defineProperty(event, "dataTransfer", { value: dt });
fireEvent(el, event);
}
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
function place(el: Element, left: number, width = 100) {
el.getBoundingClientRect = () =>
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
}
const order = () => useAppState.getState().tabOrder;
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({
tabOrder: [HOME, S1, S2],
activeTabKey: HOME,
activeSessionId: null,
projects,
});
});
describe("MainTabs reordering", () => {
it("drags a tab to the front", () => {
render(<MainTabs />);
const tabs = screen.getAllByRole("tab");
tabs.forEach((tab, i) => place(tab, i * 100));
const dt = dataTransfer();
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
// Left half of the first tab — the marker sits before it.
dragOverAt(tabs[0], 10, dt);
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
fireEvent.drop(tabs[0], { dataTransfer: dt });
expect(order()).toEqual([S2, HOME, S1]);
});
it("drops after the tab when the pointer is past its midpoint", () => {
render(<MainTabs />);
const tabs = screen.getAllByRole("tab");
tabs.forEach((tab, i) => place(tab, i * 100));
const dt = dataTransfer();
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
dragOverAt(tabs[1], 190, dt);
fireEvent.drop(tabs[1], { dataTransfer: dt });
expect(order()).toEqual([S1, HOME, S2]);
});
it("dragging does not steal the selection", () => {
render(<MainTabs />);
const tabs = screen.getAllByRole("tab");
tabs.forEach((tab, i) => place(tab, i * 100));
const dt = dataTransfer();
fireEvent.dragStart(tabs[1], { dataTransfer: dt });
dragOverAt(tabs[2], 290, dt);
fireEvent.drop(tabs[2], { dataTransfer: dt });
expect(order()).toEqual([HOME, S2, S1]);
expect(useAppState.getState().activeTabKey).toBe(HOME);
});
it("shows no drop marker until a drag is under way", () => {
render(<MainTabs />);
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
});
it("clears the marker when the drag ends without a drop", () => {
render(<MainTabs />);
const tabs = screen.getAllByRole("tab");
tabs.forEach((tab, i) => place(tab, i * 100));
const dt = dataTransfer();
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
dragOverAt(tabs[0], 10, dt);
fireEvent.dragEnd(tabs[2], { dataTransfer: dt });
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
expect(order()).toEqual([HOME, S1, S2]);
});
it("leaves a tab being renamed undraggable, so its text stays selectable", () => {
render(<MainTabs />);
const tabs = screen.getAllByRole("tab");
expect(tabs[1]).toHaveAttribute("draggable", "true");
fireEvent.doubleClick(tabs[1]);
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
expect(screen.getAllByRole("tab")[1]).toHaveAttribute("draggable", "false");
});
});
+219 -121
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects";
@@ -28,22 +28,31 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
/**
* One strip for both main-area tab kinds: Project Home views (⌂) and
* terminals (▣).
*
* Tabs are draggable. The drag is HTML5's, not a pointer-event
* reimplementation, so the OS supplies the drag image and the Escape-to-cancel
* behaviour for free; the only thing tracked here is where the drop would land.
* `Ctrl+Shift+←/→` does the same thing without a mouse.
*/
export default function MainTabs() {
const { sessions, close } = useTerminal();
const { projects, update } = useProjects();
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
useShallow((s) => ({
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab,
moveTab: s.moveTab,
})),
);
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
/** The tab being dragged, and the slot it would drop into. */
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
useEffect(() => {
if (!menu) return;
@@ -135,136 +144,225 @@ export default function MainTabs() {
}
};
const tabClass = (active: boolean) =>
const tabClass = (active: boolean, dragging: boolean) =>
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
active
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`;
}${dragging ? " opacity-40" : ""}`;
const endDrag = () => {
setDragKey(null);
setDropIndex(null);
};
/**
* Drag props shared by both tab kinds.
*
* A tab in rename mode is not draggable: a `draggable` ancestor swallows the
* mouse-drag that selects text inside the input, which would make the rename
* field impossible to select in.
*/
const dragProps = (key: string, index: number, renaming: boolean) => ({
draggable: !renaming,
onDragStart: (e: React.DragEvent<HTMLDivElement>) => {
e.dataTransfer.effectAllowed = "move";
// Some platforms refuse to start a drag with an empty payload.
e.dataTransfer.setData("text/plain", key);
setDragKey(key);
setDropIndex(index);
},
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
if (!dragKey) return; // not our drag — a file dropped on the strip isn't one
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientX > rect.left + rect.width / 2;
setDropIndex(index + (after ? 1 : 0));
},
onDragEnd: endDrag,
});
/** Drop lands wherever the marker is showing, and only there. */
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
const key = dragKey ?? e.dataTransfer.getData("text/plain");
if (!key || dropIndex === null) return endDrag();
e.preventDefault();
const from = tabOrder.indexOf(key);
// `dropIndex` is a slot in the strip as it looks *now*; `moveTab` places the
// tab after pulling it out, so every slot past the tab's own shifts down one.
moveTab(key, dropIndex > from ? dropIndex - 1 : dropIndex);
endDrag();
};
const dropMarker = (
<div
aria-hidden="true"
data-testid="tab-drop-marker"
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0"
/>
);
const renderTab = (key: string, index: number) => {
const active = activeTabKey === key;
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
{...dragProps(key, index, false)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
{project.name}
</span>
<ProjectStatusIndicator status={project.status} iconOnly />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeHomeTab(projectId);
}}
aria-label={`Close ${project.name} home tab`}
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
const sessionId = tabKeyId(key);
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
return (
<div
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(terminalTabKey(session.id));
}
}}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
{...dragProps(key, index, isRenaming)}
className={tabClass(active, dragKey === key)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label="Rename tab"
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
) : (
<span className="truncate max-w-[180px]" title={displayLabel}>
{displayLabel}
</span>
)}
{badge && (
<span
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
title={`Permission mode: ${badge.text}`}
>
{badge.text}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
aria-label={`Close ${displayLabel}`}
title="Close terminal"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
};
return (
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
{tabOrder.map((key) => {
const active = activeTabKey === key;
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
return (
<div
key={key}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
{project.name}
</span>
<ProjectStatusIndicator status={project.status} iconOnly />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeHomeTab(projectId);
}}
aria-label={`Close ${project.name} home tab`}
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
<div
className="flex items-center h-full"
role="tablist"
aria-label="Open tabs"
onDrop={onDrop}
onDragLeave={(e) => {
// Leaving the strip entirely (not crossing between tabs) parks the
// marker, so a drag aborted outside doesn't leave one behind.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropIndex(null);
}
const sessionId = tabKeyId(key);
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
}}
>
{tabOrder.map((key, index) => {
const tab = renderTab(key, index);
if (!tab) return null;
return (
<div
key={key}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(terminalTabKey(session.id));
}
}}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label="Rename tab"
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
) : (
<span className="truncate max-w-[180px]" title={displayLabel}>
{displayLabel}
</span>
)}
{badge && (
<span
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
title={`Permission mode: ${badge.text}`}
>
{badge.text}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
aria-label={`Close ${displayLabel}`}
title="Close terminal"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
<Fragment key={key}>
{dragKey !== null && dropIndex === index && dropMarker}
{tab}
</Fragment>
);
})}
{/* The empty run after the last tab is a drop target too — it is where
the hand naturally goes to say "put it at the end". */}
<div
className="flex-1 self-stretch"
onDragOver={(e) => {
if (!dragKey) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropIndex(tabOrder.length);
}}
>
{dragKey !== null && dropIndex === tabOrder.length && dropMarker}
</div>
{menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session