Reorder tabs by dragging, and pop the browser view into its own window #19

Merged
jknapp merged 11 commits from feature/tab-reorder-browser-popout into main 2026-08-11 18:20:15 +00:00
15 changed files with 1068 additions and 127 deletions
Showing only changes of commit d73096c937 - Show all commits
+19 -2
View File
@@ -59,6 +59,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The - **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current. `home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
`moveTab` deliberately does not activate what it moves.
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`) - **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models - **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow - **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
@@ -84,8 +87,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Use `--text-disabled` rather than `disabled:opacity-50`. Use `--text-disabled` rather than `disabled:opacity-50`.
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`. - **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word. - **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump. - Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal. `Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's
`kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is
why tab-moving takes Shift.
### Backend Structure (`app/src-tauri/src/`) ### Backend Structure (`app/src-tauri/src/`)
@@ -104,6 +109,18 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
OAuth listener, wrong for remote control of a browser. Host ports are confined to OAuth listener, wrong for remote control of a browser. Host ports are confined to
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them; `47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project. a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
- **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view
can be watched on another monitor or pinned on top while the main window is used for work.
Three things it rests on: no capability lists that window, so it has **no IPC surface** — do
not give it one; the app CSP does not apply, because it is a top-level document rather than a
frame, and the token gate is what protects the port in both cases; and the window is owned by
the *session*, so the supervisor's teardown closes it rather than leaving a window onto a
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
the browser.
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
container stopped, process exited.
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest` - **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs` installs into `~/.npm/_npx/<hash>/node_modules`, not any `node_modules`, so `detect.rs`
globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also
+39 -1
View File
@@ -191,6 +191,11 @@ Anthropic-backend project uses that token without its own login. See
terminal tab to rename it, jump to its project home, or close it; double-click to rename inline. terminal tab to rename it, jump to its project home, or close it; double-click to rename inline.
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
a terminal. a terminal.
**Drag a tab to reorder it.** A line shows where it will land, and dropping it does not change
which tab you are looking at — so you can rearrange the strip without pulling focus away from a
terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the *active* tab the same way
without the mouse. The order is per-session: it is not saved when you quit.
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs - **Status indicators (top right)** — Docker connection and container image availability. Each pairs
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
the built-in help. the built-in help.
@@ -211,7 +216,7 @@ for selecting a project and for two quick controls that appear on hover — star
Claude terminal. Everything else about a project lives in Project Home. Claude terminal. Everything else about a project lives in Project Home.
The header shows the project name, its status, how long the container has been up, and the action The header shows the project name, its status, how long the container has been up, and the action
buttons. Below that are five tabs: buttons. Below that are six tabs:
| Tab | What it's for | | Tab | What it's for |
|---|---| |---|---|
@@ -220,6 +225,7 @@ buttons. Below that are five tabs:
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) | | **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) | | **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
| **Files** | Browse, download and upload files inside the container | | **Files** | Browse, download and upload files inside the container |
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
### Sessions ### Sessions
@@ -257,6 +263,37 @@ included, and each tile opens a list of what it found.
The counts are only available while the container is running. The counts are only available while the container is running.
### The Browser Tab
When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you
that browser live — and lets you take it over with your own mouse and keyboard.
It is **off by default and opted into per project**, and it never installs anything on its own.
Opening the tab only *probes* the container, so it can tell you what is missing before you ask for
a view; installing Playwright and downloading a browser are separate, labelled buttons that state
what they cost before you press them. See
[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not
pre-installed.
Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the
container and reached over a token-gated listener on your machine's loopback address. Nothing is
exposed off the machine.
#### Watching it while you work
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
terminal. This is a window change only: the browser and the view keep running throughout, so
popping out and back costs nothing and interrupts nothing.
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
two viewers would both be able to *drive* the browser, and two cursors on one page is not useful.
**Put back in tab**, or just closing the window, brings it back.
The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and
stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes
it, because a window showing a viewer that no longer exists is worse than no window.
--- ---
## Project Management ## Project Management
@@ -1119,6 +1156,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
| **Ctrl+Tab** | Switch to the next tab | | **Ctrl+Tab** | Switch to the next tab |
| **Ctrl+Shift+Tab** | Switch to the previous tab | | **Ctrl+Shift+Tab** | Switch to the previous tab |
| **Ctrl+1****Ctrl+9** | Jump to the first through ninth tab | | **Ctrl+1****Ctrl+9** | Jump to the first through ninth tab |
| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) |
> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word > **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word
> before the cursor, and it is used constantly in the terminal this app is built around. Binding it > before the cursor, and it is used constantly in the terminal this app is built around. Binding it
+62 -1
View File
@@ -5,7 +5,7 @@
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use crate::browser_view::install::{self, BrowserSetupOutcome}; use crate::browser_view::install::{self, BrowserSetupOutcome};
use crate::browser_view::{manager, BrowserViewStatus}; use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus};
use crate::AppState; use crate::AppState;
/// Turn the pane on or off for a project. /// Turn the pane on or off for a project.
@@ -97,6 +97,67 @@ pub async fn install_browser_view_browser(
install::install_browser(&app_handle, &project_id, &container_id, target).await install::install_browser(&app_handle, &project_id, &container_id, target).await
} }
/// Detach the view into a window of its own, or raise the one already open.
///
/// Host-side and window-only: the viewer keeps running exactly as it was, and
/// this touches neither the container nor the proxy. Requires a *live* view,
/// because a window with nothing behind it is not worth opening — the pane
/// only offers the button in that state, and this enforces it.
#[tauri::command]
pub async fn open_browser_view_popout(
project_id: String,
always_on_top: bool,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let status = manager().status(&project_id).await;
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
return Err(
"The browser view isn't running. Start it before opening it in its own window."
.to_string(),
);
};
let name = state
.projects_store
.get(&project_id)
.map(|p| p.name)
.unwrap_or_else(|| "Triple-C".to_string());
popout::open(&app_handle, &project_id, &name, url, always_on_top)
}
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
#[tauri::command]
pub async fn close_browser_view_popout(
project_id: String,
app_handle: AppHandle,
) -> Result<(), String> {
popout::close(&app_handle, &project_id);
Ok(())
}
/// Whether the pop-out is open — asked on tab open, since a window can outlive
/// the pane that spawned it.
#[tauri::command]
pub async fn is_browser_view_popout_open(
project_id: String,
app_handle: AppHandle,
) -> Result<bool, String> {
Ok(popout::is_open(&app_handle, &project_id))
}
/// Pin the pop-out above other windows, so it can be watched while working in
/// the main one.
#[tauri::command]
pub async fn set_browser_view_popout_always_on_top(
project_id: String,
on_top: bool,
app_handle: AppHandle,
) -> Result<(), String> {
popout::set_always_on_top(&app_handle, &project_id, on_top)
}
/// The project's container, or a sentence saying why there isn't one. /// The project's container, or a sentence saying why there isn't one.
/// ///
/// Every command here needs a *running* container, and every one of them used /// Every command here needs a *running* container, and every one of them used
+6
View File
@@ -64,6 +64,7 @@
pub mod commands; pub mod commands;
pub mod detect; pub mod detect;
pub mod install; pub mod install;
pub mod popout;
pub mod proxy; pub mod proxy;
use std::collections::HashMap; use std::collections::HashMap;
@@ -474,6 +475,11 @@ async fn supervise(
} }
} }
// A pop-out outlives the tab, so nothing else would take it down: the
// window would sit there showing a frozen last frame of a viewer that no
// longer exists. The session owns it, and this is where the session ends.
popout::close(&app, &project_id);
let enabled = manager().is_enabled(&project_id).await; let enabled = manager().is_enabled(&project_id).await;
emit(&app, &project_id, &BrowserViewStatus::off(enabled)); emit(&app, &project_id, &BrowserViewStatus::off(enabled));
} }
+158
View File
@@ -0,0 +1,158 @@
//! The browser view in a window of its own.
//!
//! Watching a browser and working in a terminal are the same task done at the
//! same time, and a tab can only be one of them. So the pane can be detached
//! into a second OS window — put on the other monitor, or pinned on top of
//! whatever else is in front.
//!
//! ## Why this is a native window and not a second iframe
//!
//! The window loads the *same* token-bearing loopback URL the pane's iframe
//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level
//! document. That has two consequences worth stating:
//!
//! - It is a **remote-origin** webview. No capability lists this window, so it
//! has no IPC surface at all — `invoke` is not reachable from it, which is
//! exactly right for a page served out of a container. Do not add one.
//! - The app CSP does not apply, and does not need to: `frame-src` exists to
//! constrain what the *app's* document may embed, and this is not embedded.
//! The port is still confined to [`crate::browser_view::proxy`]'s range and
//! still gated by the session token, which is what actually protects it.
//!
//! ## Lifetime
//!
//! The window is owned by the session, not by the user's patience: when a view
//! stops — the user pressed Stop, the container went away, the viewer died —
//! the supervisor's teardown calls [`close`], because a window left showing a
//! dead viewer is worse than no window. The reverse is not true; closing the
//! window leaves the view running, and the pane takes it back into the tab.
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
/// Emitted when a pop-out opens or closes. Payload: `{ project_id, open }`.
///
/// The window can close without the app asking it to — the user hits its X, or
/// a teardown takes it — so the pane learns about it the same way it learns
/// about everything else here, by listening.
const POPOUT_EVENT: &str = "browser-view-popout-changed";
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
/// this never fires in practice; it exists so a hand-edited `projects.json`
/// cannot produce a label Tauri rejects at build time.
pub fn window_label(project_id: &str) -> String {
let id: String = project_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
format!("browser-view-{}", id)
}
/// Open the pop-out, or raise it if it is already open.
///
/// `url` is the live session's URL; the caller has already established that the
/// view is running, because there is nothing to show otherwise.
pub fn open(
app: &AppHandle,
project_id: &str,
project_name: &str,
url: &str,
always_on_top: bool,
) -> Result<(), String> {
let label = window_label(project_id);
if let Some(window) = app.get_webview_window(&label) {
// Asking twice means "I can't see it", not "open another".
let _ = window.unminimize();
let _ = window.set_focus();
let _ = window.set_always_on_top(always_on_top);
emit(app, project_id, true);
return Ok(());
}
let parsed = url
.parse()
.map_err(|e| format!("The browser view's address is not a URL: {}", e))?;
let project_id_owned = project_id.to_string();
let app_for_event = app.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
.title(format!("{} — browser", project_name))
.inner_size(1100.0, 820.0)
.min_inner_size(480.0, 360.0)
.always_on_top(always_on_top)
.build()
.map_err(|e| format!("Could not open the browser window: {}", e))?;
// Closed from its own titlebar, this is the only thing that tells the pane
// to take the view back into the tab.
window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) {
emit(&app_for_event, &project_id_owned, false);
}
});
log::info!("Browser view: popped out for project {}", project_id);
emit(app, project_id, true);
Ok(())
}
/// Close the pop-out if there is one. Safe to call when there isn't.
///
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
/// window-event handler treats that as a request to quit for the main window.
/// Nothing here should ever be able to be mistaken for that.
pub fn close(app: &AppHandle, project_id: &str) {
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
if let Err(e) = window.destroy() {
log::warn!(
"Browser view: could not close the pop-out for project {}: {}",
project_id,
e
);
}
}
// Unconditional: `Destroyed` covers the normal path, but a window that was
// already gone still owes the pane an answer.
emit(app, project_id, false);
}
pub fn is_open(app: &AppHandle, project_id: &str) -> bool {
app.get_webview_window(&window_label(project_id)).is_some()
}
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> {
let Some(window) = app.get_webview_window(&window_label(project_id)) else {
return Ok(());
};
window
.set_always_on_top(on_top)
.map_err(|e| format!("Could not change the window's stacking: {}", e))
}
fn emit(app: &AppHandle, project_id: &str, open: bool) {
let _ = app.emit(
POPOUT_EVENT,
serde_json::json!({ "project_id": project_id, "open": open }),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_are_derived_from_the_project_and_are_tauri_safe() {
assert_eq!(
window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"),
"browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"
);
assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d");
}
#[test]
fn distinct_projects_get_distinct_windows() {
assert_ne!(window_label("alpha"), window_label("beta"));
}
}
+12
View File
@@ -328,6 +328,14 @@ pub fn run() {
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// This handler fires for *every* window, and what follows stops
// containers and exits the process. Only the main window means
// that. Secondary windows — the browser view's pop-out — are
// closed and reopened freely and must just close.
if window.label() != "main" {
return;
}
let state = window.state::<AppState>(); let state = window.state::<AppState>();
let lifecycle = state.lifecycle.clone(); let lifecycle = state.lifecycle.clone();
@@ -428,6 +436,10 @@ pub fn run() {
browser_view::commands::check_browser_view_support, browser_view::commands::check_browser_view_support,
browser_view::commands::install_browser_view_support, browser_view::commands::install_browser_view_support,
browser_view::commands::install_browser_view_browser, browser_view::commands::install_browser_view_browser,
browser_view::commands::open_browser_view_popout,
browser_view::commands::close_browser_view_popout,
browser_view::commands::is_browser_view_popout_open,
browser_view::commands::set_browser_view_popout_always_on_top,
// Shared Claude Code auth token // Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code, commands::auth_token_commands::submit_claude_token_code,
+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");
});
});
+109 -11
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 { useShallow } from "zustand/react/shallow";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects"; 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 * One strip for both main-area tab kinds: Project Home views () and
* terminals (). * 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() { export default function MainTabs() {
const { sessions, close } = useTerminal(); const { sessions, close } = useTerminal();
const { projects, update } = useProjects(); const { projects, update } = useProjects();
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState( const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState(
useShallow((s) => ({ useShallow((s) => ({
tabOrder: s.tabOrder, tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey, activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey, setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab, closeHomeTab: s.closeHomeTab,
moveTab: s.moveTab,
})), })),
); );
const [menu, setMenu] = useState<ContextMenuState | null>(null); const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null); const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState(""); const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null); 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(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
@@ -135,16 +144,66 @@ 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 ${ `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 active
? "bg-[var(--bg-primary)] text-[var(--text-primary)]" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`; }${dragging ? " opacity-40" : ""}`;
return ( const endDrag = () => {
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs"> setDragKey(null);
{tabOrder.map((key) => { 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; const active = activeTabKey === key;
if (isHomeTab(key)) { if (isHomeTab(key)) {
@@ -153,7 +212,6 @@ export default function MainTabs() {
if (!project) return null; if (!project) return null;
return ( return (
<div <div
key={key}
role="tab" role="tab"
aria-selected={active} aria-selected={active}
tabIndex={0} tabIndex={0}
@@ -164,7 +222,8 @@ export default function MainTabs() {
setActiveTabKey(key); setActiveTabKey(key);
} }
}} }}
className={tabClass(active)} {...dragProps(key, index, false)}
className={tabClass(active, dragKey === key)}
> >
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span> <span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}> <span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
@@ -203,7 +262,6 @@ export default function MainTabs() {
return ( return (
<div <div
key={key}
role="tab" role="tab"
aria-selected={active} aria-selected={active}
tabIndex={0} tabIndex={0}
@@ -219,7 +277,8 @@ export default function MainTabs() {
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY }); setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}} }}
onDoubleClick={() => startRename(session.id)} onDoubleClick={() => startRename(session.id)}
className={tabClass(active)} {...dragProps(key, index, isRenaming)}
className={tabClass(active, dragKey === key)}
> >
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span> <span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? ( {isRenaming ? (
@@ -263,8 +322,47 @@ export default function MainTabs() {
</button> </button>
</div> </div>
); );
};
return (
<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);
}
}}
>
{tabOrder.map((key, index) => {
const tab = renderTab(key, index);
if (!tab) return null;
return (
<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 && (() => { {menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId); const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session const hasCustom = session
@@ -13,6 +13,10 @@ const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>(); const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>(); const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => 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 pushToast = vi.fn();
const setContainerProgress = vi.fn(); const setContainerProgress = vi.fn();
@@ -22,6 +26,11 @@ vi.mock("../../../lib/tauri-commands", () => ({
checkBrowserViewSupport: () => checkBrowserViewSupport(), checkBrowserViewSupport: () => checkBrowserViewSupport(),
installBrowserViewSupport: () => installBrowserViewSupport(), installBrowserViewSupport: () => installBrowserViewSupport(),
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b), 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", () => ({ vi.mock("@tauri-apps/api/event", () => ({
@@ -111,8 +120,34 @@ beforeEach(() => {
storeState.containerProgress = {}; storeState.containerProgress = {};
getBrowserViewStatus.mockResolvedValue(OFF); getBrowserViewStatus.mockResolvedValue(OFF);
checkBrowserViewSupport.mockResolvedValue(READY); 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", () => { describe("BrowserTab", () => {
it("does not offer to start anything while the container is stopped", async () => { it("does not offer to start anything while the container is stopped", async () => {
render(<BrowserTab project={{ ...project, status: "stopped" }} active />); render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
@@ -325,4 +360,83 @@ describe("BrowserTab", () => {
expect(await screen.findByText("Off")).toBeInTheDocument(); expect(await screen.findByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull(); 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 wouldnt 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();
});
}); });
+117 -2
View File
@@ -4,21 +4,27 @@ import type {
BrowserInstallTarget, BrowserInstallTarget,
BrowserSetupOutcome, BrowserSetupOutcome,
BrowserViewChangedEvent, BrowserViewChangedEvent,
BrowserViewPopoutChangedEvent,
BrowserViewStatus, BrowserViewStatus,
PlaywrightDetection, PlaywrightDetection,
Project, Project,
} from "../../../lib/types"; } from "../../../lib/types";
import { import {
checkBrowserViewSupport, checkBrowserViewSupport,
closeBrowserViewPopout,
getBrowserViewStatus, getBrowserViewStatus,
installBrowserViewBrowser, installBrowserViewBrowser,
installBrowserViewSupport, installBrowserViewSupport,
isBrowserViewPopoutOpen,
openBrowserViewPopout,
setBrowserViewEnabled, setBrowserViewEnabled,
setBrowserViewPopoutAlwaysOnTop,
} from "../../../lib/tauri-commands"; } from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
import AccordionSection from "../../ui/AccordionSection"; import AccordionSection from "../../ui/AccordionSection";
import Button from "../../ui/Button"; import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator"; import StatusIndicator from "../../ui/StatusIndicator";
import Toggle from "../../ui/Toggle";
interface Props { interface Props {
project: Project; project: Project;
@@ -66,6 +72,9 @@ export default function BrowserTab({ project, active }: Props) {
const [job, setJob] = useState<SetupJob>(null); const [job, setJob] = useState<SetupJob>(null);
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null); const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
const [setupError, setSetupError] = useState<string | 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 pushToast = useAppState((s) => s.pushToast);
const setContainerProgress = useAppState((s) => s.setContainerProgress); const setContainerProgress = useAppState((s) => s.setContainerProgress);
const progress = useAppState((s) => s.containerProgress[project.id]); const progress = useAppState((s) => s.containerProgress[project.id]);
@@ -95,8 +104,28 @@ export default function BrowserTab({ project, active }: Props) {
return () => dispose?.(); return () => dispose?.();
}, [projectId]); }, [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(() => { useEffect(() => {
if (!active || !running) return; if (!active || !running) return;
isBrowserViewPopoutOpen(projectId)
.then((open) => mounted.current && setPoppedOut(open))
.catch(() => {});
getBrowserViewStatus(projectId) getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s)) .then((s) => mounted.current && setStatus(s))
.catch(() => {}); .catch(() => {});
@@ -129,6 +158,56 @@ export default function BrowserTab({ project, active }: Props) {
[projectId, pushToast], [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. */ /** Run one install. Every path clears the progress line it started. */
const install = useCallback( const install = useCallback(
async (which: Exclude<SetupJob, null>) => { async (which: Exclude<SetupJob, null>) => {
@@ -225,11 +304,26 @@ export default function BrowserTab({ project, active }: Props) {
</span> </span>
)} )}
<div className="flex-1" /> <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)}> <Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
Reload Reload
</Button> </Button>
)} )}
{live && (
<Button size="md" onClick={poppedOut ? popIn : popOut}>
{poppedOut ? "Put back in tab" : "Open in own window"}
</Button>
)}
<Button <Button
size="md" size="md"
variant={live ? "secondary" : "primary"} variant={live ? "secondary" : "primary"}
@@ -240,7 +334,28 @@ export default function BrowserTab({ project, active }: Props) {
</Button> </Button>
</div> </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 <iframe
key={reloadKey} key={reloadKey}
// Loopback only, and the URL carries the one-time session token the // Loopback only, and the URL carries the one-time session token the
+12
View File
@@ -11,6 +11,7 @@ import { useTerminal } from "./useTerminal";
* Ctrl+Shift+W close the active tab * Ctrl+Shift+W close the active tab
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous) * Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
* Ctrl+1..9 jump to the nth tab * Ctrl+1..9 jump to the nth tab
* Ctrl+Shift+/ move the active tab along the strip
*/ */
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const { open: openTerminal, close: closeTerminal } = useTerminal(); const { open: openTerminal, close: closeTerminal } = useTerminal();
@@ -51,6 +52,17 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
// dragging a tab does. Shift is what keeps it clear of the terminal:
// Ctrl+←/→ is readline's word-wise cursor motion.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
if (!state.activeTabKey) return;
e.preventDefault();
e.stopPropagation();
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
return;
}
if (e.shiftKey) return; if (e.shiftKey) return;
// Ctrl+1..9 — jump to tab // Ctrl+1..9 — jump to tab
+19
View File
@@ -201,6 +201,25 @@ export const installBrowserViewBrowser = (
browser: BrowserInstallTarget, browser: BrowserInstallTarget,
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser }); ) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
/**
* Detach the live view into its own OS window, or raise it if already open.
*
* Window-only: the viewer, the proxy and the container are untouched, so
* popping out and back costs nothing. The window loads the same token-bearing
* loopback URL as the pane, and has no IPC access.
*/
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
export const closeBrowserViewPopout = (projectId: string) =>
invoke<void>("close_browser_view_popout", { projectId });
/** Asked on tab open: the window outlives the pane, so its state has to be read back. */
export const isBrowserViewPopoutOpen = (projectId: string) =>
invoke<boolean>("is_browser_view_popout_open", { projectId });
/** Pin the pop-out above other windows — the point of popping it out at all. */
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
// Shared Claude Code auth token — one `claude setup-token` run authenticates // Shared Claude Code auth token — one `claude setup-token` run authenticates
// every Anthropic-backend project. The token itself is never exposed here: it // every Anthropic-backend project. The token itself is never exposed here: it
// lives in the OS keychain and is injected as a container env var. // lives in the OS keychain and is injected as a container env var.
+12
View File
@@ -514,6 +514,18 @@ export interface BrowserViewChangedEvent {
status: BrowserViewStatus; status: BrowserViewStatus;
} }
/**
* Payload of the `browser-view-popout-changed` event.
*
* The pop-out window can close without the pane asking the user hits its X,
* or the session tears down and takes it so this is the only reliable way to
* know whether it is on screen.
*/
export interface BrowserViewPopoutChangedEvent {
project_id: string;
open: boolean;
}
/** Payload of the `claude-token-progress` event: milestones during /** Payload of the `claude-token-progress` event: milestones during
* `acquire_claude_token`. Never contains the token. */ * `acquire_claude_token`. Never contains the token. */
/** /**
+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; setActiveTabKey: (key: string) => void;
cycleTab: (delta: number) => void; cycleTab: (delta: number) => void;
focusTabIndex: (index: 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. // Inline container progress, replacing the blocking progress modal.
containerProgress: Record<string, string>; containerProgress: Record<string, string>;
@@ -274,6 +278,35 @@ export const useAppState = create<AppState>((set) => ({
? { ...patch, selectedProjectId: tabKeyId(key) } ? { ...patch, selectedProjectId: tabKeyId(key) }
: patch; : 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 // Container progress
containerProgress: {}, containerProgress: {},