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
Owner

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.
jknapp added 1 commit 2026-08-11 13:54:03 +00:00
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
d73096c937
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>
jknapp added 1 commit 2026-08-11 14:40:54 +00:00
Fix the review's findings: drag on pointer events, read the window back
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
a41d93ea46
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>
jknapp added 1 commit 2026-08-11 15:00:10 +00:00
Pin the preview build's uploads to upload-artifact@v3
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m32s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
1207a21aae
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>
jknapp added 2 commits 2026-08-11 16:15:28 +00:00
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>
Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
f68d9c5788
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>
jknapp added 1 commit 2026-08-11 16:38:16 +00:00
Publish preview builds as a prerelease instead of workflow artifacts
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m30s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
63f3c54b95
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>
jknapp added 1 commit 2026-08-11 17:06:03 +00:00
Start the 0.4 line, and give previews the version they are previewing
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m34s
Build App / build-windows (pull_request) Successful in 5m17s
Build App / build-linux (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
5b18ce804f
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>
jknapp added 1 commit 2026-08-11 17:36:33 +00:00
Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
f239fa1c82
**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>
jknapp added 1 commit 2026-08-11 17:41:37 +00:00
One build per push: previews carry the PR check
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m27s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
5bd80a05bc
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>
jknapp added 1 commit 2026-08-11 18:00:11 +00:00
Stop the drag from selecting the tab's text
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m39s
Build App (Preview) / build-linux (pull_request) Successful in 5m56s
85ea3956e8
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>
jknapp added 1 commit 2026-08-11 18:13:25 +00:00
Say what "open in container" is doing, and land on the pane doing it
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
ab747ce53d
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 main 2026-08-11 18:20:15 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#19