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
+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
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.
`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`)
- **`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
@@ -84,8 +87,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Use `--text-disabled` rather than `disabled:opacity-50`.
- **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.
- 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.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump,
`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/`)
@@ -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
`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.
- **`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`
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