Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else.
Drag to reorder tabs
moveTab / moveActiveTab on the store, HTML5 drag on the strip with a marker showing where the drop will land, and 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 that is mid-run.
A tab being renamed is not draggable — a draggable ancestor swallows the mouse-drag that selects text inside its input.
The empty run after the last tab is a drop target, which is where the hand goes to say "put it at the end".
Order is per-session; tabOrder has never been persisted.
Ctrl+Shift+←/→ takes Shift because plain Ctrl+←/→ is readline's word-wise cursor motion inside the terminal — the same reasoning that left Ctrl+W alone.
Pop the browser view out into its own window
New 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 while you work in a terminal. Four commands, surfaced in the pane's toolbar as Open in own window / Put back in tab.
Window-only: the viewer, the proxy and the container are untouched, so popping out and back costs nothing and interrupts nothing the agent is doing.
Three things it rests on:
No capability lists that window, so it has no IPC surface. That is 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; this is a top-level document. The bounded port range and the token gate are what actually protect the port, in both cases.
The window is owned by the session, not the tab. The supervisor's teardown closes it, so a Stop, a stopped container or a removed project takes the window with it — a window onto a viewer that no longer exists is worse than no window. Closing the project's home tab leaves it open.
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.
A bug found on the way
lib.rs's on_window_event fires for every window, and its body stops every container and exits the process. Without a label() == "main" guard, closing the pop-out would have quit the app. Guarded, and written down in CLAUDE.md.
Verification
318 frontend tests and 260 Rust tests pass. New coverage: reorder semantics in the store (clamping, no-wrap, activation untouched), the drag interaction in MainTabs (which side of a tab the drop lands on, marker lifecycle, rename-blocks-drag), and the pop-out lifecycle in BrowserTab (only offered when live, iframe dropped while out, state read back for a window that outlived the tab, failure surfaced rather than faked).
tsc, vite build and cargo check clean; clippy adds no new warnings.
Not run as an app — the dev container is headless, so the drag feel and the real window want a human's eyes.
Docs
HOW-TO-USE.md gains tab-reordering and a Browser tab section covering the pop-out — that tab was not documented at all, and the Project Home table still said five tabs. CLAUDE.md records the reorder invariant (never treat a tab's position as identity), the pop-out's three constraints, and the window-event guard.
Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else.
## Drag to reorder tabs
`moveTab` / `moveActiveTab` on the store, HTML5 drag on the strip with a marker showing where the drop will land, and `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 that is mid-run.
- A tab being renamed is not draggable — a `draggable` ancestor swallows the mouse-drag that selects text inside its input.
- The empty run after the last tab is a drop target, which is where the hand goes to say "put it at the end".
- Order is per-session; `tabOrder` has never been persisted.
`Ctrl+Shift+←/→` takes Shift because plain `Ctrl+←/→` is readline's word-wise cursor motion inside the terminal — the same reasoning that left `Ctrl+W` alone.
## Pop the browser view out into its own window
New `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 while you work in a terminal. Four commands, surfaced in the pane's toolbar as **Open in own window** / **Put back in tab**.
Window-only: the viewer, the proxy and the container are untouched, so popping out and back costs nothing and interrupts nothing the agent is doing.
Three things it rests on:
- **No capability lists that window, so it has no IPC surface.** That is 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*; this is a top-level document. The bounded port range and the token gate are what actually protect the port, in both cases.
- **The window is owned by the session, not the tab.** The supervisor's teardown closes it, so a Stop, a stopped container or a removed project takes the window with it — a window onto a viewer that no longer exists is worse than no window. Closing the project's home tab leaves it open.
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.
## A bug found on the way
`lib.rs`'s `on_window_event` fires for **every** window, and its body stops every container and exits the process. Without a `label() == "main"` guard, closing the pop-out would have quit the app. Guarded, and written down in `CLAUDE.md`.
## Verification
- 318 frontend tests and 260 Rust tests pass. New coverage: reorder semantics in the store (clamping, no-wrap, activation untouched), the drag interaction in `MainTabs` (which side of a tab the drop lands on, marker lifecycle, rename-blocks-drag), and the pop-out lifecycle in `BrowserTab` (only offered when live, iframe dropped while out, state read back for a window that outlived the tab, failure surfaced rather than faked).
- `tsc`, `vite build` and `cargo check` clean; clippy adds no new warnings.
- **Not run as an app** — the dev container is headless, so the drag feel and the real window want a human's eyes.
## Docs
`HOW-TO-USE.md` gains tab-reordering and a **Browser tab** section covering the pop-out — that tab was not documented at all, and the Project Home table still said five tabs. `CLAUDE.md` records the reorder invariant (never treat a tab's position as identity), the pop-out's three constraints, and the window-event guard.
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>
Ten findings from the review of the previous commit, all applied.
**The tab drag is now pointer events, not HTML5 drag-and-drop.** Two
independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks
HTML5 drag inside the webview on Windows, and it cannot just be turned
off — `TerminalView` needs Tauri's native drag-drop event, which is the
only one that carries dropped *file paths*. And an HTML5 drag carries a
`DataTransfer`: released over any text field in the app, the default
handler types `term:<uuid>` into it, and in Config that is then saved
with the project. Pointer events have neither problem, and the drag is
measured from the tabs on screen rather than from the event target, so
the marker and the drop agree even over the marker itself. Escape
abandons a drag; a press under 4px stays a click; the click that ends a
drag does not select.
**`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound
on `document` in the capture phase, so in any input — the rename field,
Config, Settings — it was taking the OS's extend-selection chord *and*
silently reordering the strip. Guarded by `inTextField()`, which
excludes xterm's helper textarea: that is an input-method shim, and the
terminal is where the shortcut matters most.
**The pop-out's state is read from the window, never remembered.** The
pane is unmounted whenever another Project Home sub-tab is selected, so
"Keep on top" came back Off over a window still floating on top.
`get_browser_view_popout_state` returns both facts from the window
itself, and the change event carries them. `poppedOut` is tri-state:
until the answer arrives the iframe is not mounted, because guessing
"not popped out" is what flashes a second viewer onto the browser.
Also: `popout::close` and the off-status emit in the supervisor are
behind the same epoch guard as the deregistration above them, so a
supervisor whose teardown outlives a restart can no longer destroy the
*new* session's window; `close()` returns its `destroy()` error instead
of logging it and reporting success, since the pane restores its iframe
on success; the drop marker is `pointer-events-none` and is placed
before the first *visible* tab at or past the slot, so it neither
refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and
the "Keep on top" Toggle's accessible name now matches its visible text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run #265 — this workflow's first ever run — built the app on all three
platforms and then lost every bundle at the upload step:
GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
and download-artifact@v4+ are not currently supported on GHES.
v4 bundles @actions/artifact v2, whose isGhes() treats any
GITHUB_SERVER_URL that is not github.com, *.ghe.com or *.localhost as
GitHub Enterprise Server and throws before making a single request.
act_runner sets that variable to this Gitea instance, so v4 cannot work
here on any runner or any OS — and it fails *after* the whole Tauri
build has been paid for.
v3 uses the v1 artifact API, which Gitea implements. Both options this
workflow relies on, `if-no-files-found: error` and `retention-days`,
exist in v3.
The other workflows never hit this because they publish by curling the
Gitea releases API instead. Noted at the top of the file, with the
isGhes rule, so the pin is not "upgraded" back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.
The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:
Executable doesn't exist at …/chromium_headless_shell-1234/…
while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.
So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.
Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.
An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.
Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.
Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.
The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.
Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workflow artifacts do not work on this Gitea, in two different ways:
* upload-artifact@v4 cannot run at all. @actions/artifact v2's isGhes()
treats any GITHUB_SERVER_URL that is not github.com / *.ghe.com /
*.localhost as GitHub Enterprise Server and throws before making a
single request. act_runner sets it to this instance, so all three
platforms died with GHESNotSupportedError — after paying for the
whole Tauri build (run #265).
* @v3 uploads succeed and the files are downloadable by direct URL,
but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns
total_count 0 and the run page shows nothing (verified on run #267).
A build nobody can find is not a build.
So previews publish the way every other workflow here does: curl to the
releases API. One prerelease per preview, tagged `preview-<sha>`, with
all three platforms' bundles as assets — visible on the Releases page
with stable links.
The release is created in a job the three builds depend on rather than
get-or-created in each. They run concurrently, so per-job creation races
on one tag: the loser gets a 409, and the id parse then yields empty
while the step still reports success — the failure build-app.yml's
macOS job was hardened against after it happened for real. One creator
removes the race instead of handling it.
Asset upload keeps that hardening: delete-then-upload so a re-dispatch
replaces rather than 409s, --http1.1 and retries for the mid-stream
drops the macOS runner has produced (curl exit 92, exit 28), and an
explicit failure when a platform produced no bundles at all.
The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent
`v<x>.<y>.<z>` releases and separately deletes every release whose tag
does not start with `v[0-9]`, so previews are pruned by the cleanup
already in use and never crowd the real release list. sync-release.yml
is dispatch-only, so none of this reaches GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two version problems, one of them mine.
**Previews claimed x.y.0.** The preview workflow hard-coded the patch
number, so every preview installer reported 0.3.0 whatever it contained,
while the real build computes the patch from tags. It now runs the same
computation, so a preview is labelled with the version the release it
previews would carry.
**A new minor line started at the wrong number.** `compute-version`'s
fallback for "no tag matches this line yet" counted every commit in the
repository — fine as a bootstrap, wrong the moment a minor version is
bumped: the first 0.4 build would have been 0.4.234. A line nobody has
tagged is a new line, and a new line starts at .0.
With those fixed, VERSION moves to 0.4 — tab reordering, the browser
pop-out, opening pages in the container's browser and the Playwright
install fix are more than a patch bump. The next release is v0.4.0;
today's HEAD would have been 0.3.90 on the old line.
`app/package.json`, `package-lock.json`, `tauri.conf.json` and
`Cargo.toml` follow to 0.4.0. CI patches all four per build, so they are
the dev-time defaults rather than the source of truth — but a local
`tauri dev` shows them, so they should not still say 0.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**The drag showed no tab.** Moving to pointer events lost the drag image
the OS used to supply, leaving a dimmed source tab and a 2px line —
which reads as "some setting changed", not "I am holding this tab". A
copy of the tab now follows the cursor, carrying its glyph and its real
label, grabbed at the offset it was picked up by so it sits where the
tab was.
**The URL relay opened a different URL than the one on screen.**
Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as
`repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The
detector deleted *every* line break to undo PTY hard-wrapping, but a
terminal that wraps at a space emits the break **instead of** the space
— so deleting breaks also deletes the separators, gluing the following
paragraph onto the link and running the match past the host.
Only breaks the terminal inserted may be deleted, and those are exactly
the ones at the column width. The detector now takes a live column
getter and rejoins a line only when it is exactly that wide; every other
break becomes a space, which is also what stops a URL match. Lines
*longer* than the width are left alone — the stream had no break there,
so the one that follows is the application's own.
One case stays ambiguous: a URL whose length is an exact multiple of the
width is indistinguishable from one that was cut. That is pinned in a
test as known behaviour rather than papered over — the candidate is
shown in full and nothing opens without the user pressing Open.
**"In container" opened a page nobody could see.** It bound the browser
and stopped, leaving the user to find the Browser tab and press Start,
with nothing saying so — and from a terminal, no pane on screen at all.
Opening a page now starts the viewer if it isn't running, and the
terminal's prompt raises the pop-out window, because that caller has
nowhere else to put it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every push to the PR started two workflows on the same commit.
build-app.yml ran on pull_request and compiled all three platforms —
then published nothing, because every publishing step in it is gated on
`gitea.event_name == 'push'`. build-app-preview.yml compiled the same
three and published them. Six OS builds per push, half of them
unreachable.
So the PR trigger moves to the preview workflow, which was already doing
the identical compilation and has something to show for it.
build-app.yml is now push-to-main and manual dispatch only: releases.
Two things a pull_request event changes, handled rather than inherited:
`gitea.sha` can be the merge ref — not the commit anyone is testing, and
not something to hang a tag on — so the release's target comes from
`git rev-parse HEAD` in the checkout; and `gitea.ref_name` is the PR
number, so the release body uses `gitea.head_ref` when there is one.
The cost is one prerelease per PR commit touching app/**, which the
existing Cleanup Old Releases sweep already prunes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pointer-driven drag is still a mouse drag as far as the browser is
concerned, so moving a tab highlighted its label blue — something the OS
drag image never did, and the last visible difference between this and
a real drag.
`select-none` on the tab. The rename field gets `select-text` back:
`user-select` inherits, and selecting text is exactly what that field is
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening a page is a container probe, a browser launch, a page load and
often a viewer start — several seconds during which the only feedback
was the click itself. Worse from a terminal, where the result appears in
a pane the user is not looking at.
So: the backend emits progress on the existing `container-progress`
channel at each step, the Browser tab renders that line whenever it is
set — the progress belongs to the project, not to whoever pressed the
button, which is what lets a terminal-initiated open report anywhere at
all — and the terminal's "In container" now selects the project's
Browser tab before starting, so the line has somewhere to appear.
Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it
in local state, so `openProjectHomeTab` parks a request in the store and
the pane consumes it once. Consumed once, so it cannot fight the user's
own clicking afterwards.
Preview releases now prune themselves to the newest KEEP_PREVIEWS (2),
in a job that runs only if all three platforms published — a
half-finished run must not evict a good older build. The cleanup
workflow's manual sweep stays as the backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp
merged commit e379c58684 into main2026-08-11 18:20:15 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else.
Drag to reorder tabs
moveTab/moveActiveTabon the store, HTML5 drag on the strip with a marker showing where the drop will land, andCtrl+Shift+←/→for the same thing without a mouse.draggableancestor swallows the mouse-drag that selects text inside its input.tabOrderhas never been persisted.Ctrl+Shift+←/→takes Shift because plainCtrl+←/→is readline's word-wise cursor motion inside the terminal — the same reasoning that leftCtrl+Walone.Pop the browser view out into its own window
New
browser_view/popout.rsopens 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 while you work in a terminal. Four commands, surfaced in the pane's toolbar as Open in own window / Put back in tab.Window-only: the viewer, the proxy and the container are untouched, so popping out and back costs nothing and interrupts nothing the agent is doing.
Three things it rests on:
frame-srcconstrains what the app's document may embed; this is a top-level document. The bounded port range and the token gate are what actually protect the port, in both cases.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.
A bug found on the way
lib.rs'son_window_eventfires for every window, and its body stops every container and exits the process. Without alabel() == "main"guard, closing the pop-out would have quit the app. Guarded, and written down inCLAUDE.md.Verification
MainTabs(which side of a tab the drop lands on, marker lifecycle, rename-blocks-drag), and the pop-out lifecycle inBrowserTab(only offered when live, iframe dropped while out, state read back for a window that outlived the tab, failure surfaced rather than faked).tsc,vite buildandcargo checkclean; clippy adds no new warnings.Docs
HOW-TO-USE.mdgains tab-reordering and a Browser tab section covering the pop-out — that tab was not documented at all, and the Project Home table still said five tabs.CLAUDE.mdrecords the reorder invariant (never treat a tab's position as identity), the pop-out's three constraints, and the window-event guard.Workflow artifacts do not work on this Gitea, in two different ways: * upload-artifact@v4 cannot run at all. @actions/artifact v2's isGhes() treats any GITHUB_SERVER_URL that is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and throws before making a single request. act_runner sets it to this instance, so all three platforms died with GHESNotSupportedError — after paying for the whole Tauri build (run #265). * @v3 uploads succeed and the files are downloadable by direct URL, but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns total_count 0 and the run page shows nothing (verified on run #267). A build nobody can find is not a build. So previews publish the way every other workflow here does: curl to the releases API. One prerelease per preview, tagged `preview-<sha>`, with all three platforms' bundles as assets — visible on the Releases page with stable links. The release is created in a job the three builds depend on rather than get-or-created in each. They run concurrently, so per-job creation races on one tag: the loser gets a 409, and the id parse then yields empty while the step still reports success — the failure build-app.yml's macOS job was hardened against after it happened for real. One creator removes the race instead of handling it. Asset upload keeps that hardening: delete-then-upload so a re-dispatch replaces rather than 409s, --http1.1 and retries for the mid-stream drops the macOS runner has produced (curl exit 92, exit 28), and an explicit failure when a platform produced no bundles at all. The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent `v<x>.<y>.<z>` releases and separately deletes every release whose tag does not start with `v[0-9]`, so previews are pruned by the cleanup already in use and never crowd the real release list. sync-release.yml is dispatch-only, so none of this reaches GitHub. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>