fix(native): Fast Preview is authoritative over the sandbox interceptor - #6064
Open
tlgimenes wants to merge 8 commits into
Open
fix(native): Fast Preview is authoritative over the sandbox interceptor#6064tlgimenes wants to merge 8 commits into
tlgimenes wants to merge 8 commits into
Conversation
Fast Preview is sandbox-less and content-only: no daemon, no coding agent, no chat. It nonetheless rendered the vibecoding header cascade, where 5 of 21 states dispatch chat prompts into a composer Fast Preview disables (dead clicks) and 7 more can never match (lifecycle is pinned to running, workingTreeDirty and unpushed are hardcoded). Give it its own 7-state machine and one split button, in the editor's vocabulary rather than git's. - packages/ui: new SplitButton (on the existing unused ButtonGroup) and a `warning` Button variant using the existing --warning tokens. `disabled` disables only the primary half, so "Up to date" stays an inert pill whose menu still offers "Get latest". - cms-panel-state.ts: pure selector, 40 unit tests. Check state is encoded as colour (outcome) plus motion (progress): brand normally, warning when a check is not passing, spinner where the editor must wait, pulse where they can still act. - cms-header-actions.tsx: renderer reusing PublishDialog unchanged. - The branch lives at the mount point, so Fast Preview never mounts useSandboxEvents, useSandboxLifecycle or usePublishGate. usePublishGate is dropped from this path rather than disabled: canPublishDirectly defers to isDecoOnlyDiff under every policy, and a Fast Preview diff is deco-only by construction, so it always allowed. That also removes its 10s GitHub poll. The vibecoding cascade is untouched; its 39 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nerated-only churn Three defects found driving the real CMS in the native app. 1. The Draft state only looked at `aheadOfBase`, and the BranchMeta mapping pinned `workingTreeDirty: false`. That held for the GitHub-backed `/git/status` shim, which commits on every save — but the same contract is served by a real clone whenever the project isn't claimed as Fast Preview server-side, and there the editor's saves sit uncommitted. The button claimed "Up to date" over unpublished edits. Unpublished work is now `aheadOfBase > 0 || workingTreeDirty`, and `isLevelWithMergedPr` yields to a dirty tree so post-merge edits aren't swallowed either. 2. No saving state. A publish fired mid-write would ship whichever half of the edit had landed. Reads the same `decofileWriteMutationKey` the preview's autosave indicator uses, so edits and deletes are both covered. The menu is empty while saving: a branch-wins merge racing an in-flight write is how an edit goes missing. 3. `.deco/generate.digests.json` and `.deco/meta.gen.json` are rewritten on every save and never reverted by an undo, so a dirty-tree check counted them as work the publish diff couldn't find — "0 changes to publish" under a live button. `hasPublishableLocalWork` ignores generated artifacts. Known gap, deliberately left: on the commit-per-save backend, edit-then-undo leaves N commits with a net-zero diff, and `aheadOfBase > 0` still reads as Draft. Closing it needs a changed-file count from the `compare` call `githubGitStatus` already makes — an apps/api change, tracked separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Request approval" was invented copy for an action vibecoding already names. Reuse `thread.headerActions.submitForReview` instead — one fewer string, an existing pt-br translation, and the same action stops having two names depending on which toolbar you are in. The state it leads to follows the verb: "Waiting for approval" → "Waiting for review", so you no longer submit for review and then wait for approval. Icons on the primary and every menu entry: a rocket for publish, the repo's own GitHub mark for both GitHub links, and RefreshCw01 for Get latest — the glyph the Sync button already uses for the same operation. Chosen by `action` in the renderer so the state machine stays free of JSX. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nning Switching branches could leave the button on "Loading…" permanently. The selector collapsed "still fetching" and "failed permanently" into one state via `branch.kind !== "ready"`, so a status read that errored — the native API answers `not_ready` for a branch whose local repo isn't set up yet (local-api/src/routes/git.rs:329) — presented as a spinner that never resolves. TanStack had already stopped retrying; nothing was in flight. A failed read now renders "Retry" with the reason as its tooltip. Branch metadata that did arrive still wins, so a stale error can't mask real state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… view Switching branches rendered "Review & Publish" brand-green and then recoloured it to warning a beat later. Not a race — a waterfall. `checks` and `reviews` are gated on the PR number, so they cannot start until `usePrByBranch` returns, while `loading` reflected only that PR query. The button therefore committed to a confident, fully actionable state from data still missing the one signal that can contradict it. A UI that corrects itself in front of the user reads as broken even when it is converging. `isCmsStateSettling` holds one window until every query the state depends on has landed, so the button goes from Loading to its final answer and stays there. No extra requests — the same waterfall, just not narrated. Scoped to an open PR, since that is the only case where the dependent queries run at all: a Draft branch has no checks to wait for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…imation "Get latest" ran a server-side merge with no feedback at all — the branch was being rewritten under the editor while the button looked idle and clickable. It now holds the control with "Getting latest…" and a spinner, and Retry spins while its refetch is in flight. Publishing still outranks both. Also drops the pulse from "Review & Publish". It was meant to say "checks are running, you may still act", but animating a button the editor is free to click reads as something being wrong with it. Only a genuine wait animates now; the tooltip already carried the progress, so nothing is lost. `pulse` leaves the CMS descriptor entirely — SplitButton keeps the prop for other callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tlgimenes
force-pushed
the
claude/wise-watching-fog
branch
from
August 13, 2026 19:32
2154006 to
773dad2
Compare
tlgimenes
force-pushed
the
claude/fastpreview-upstream-authority
branch
from
August 13, 2026 19:33
9d873a1 to
5aacfd3
Compare
…tale state Editing an up-to-date branch went "Saving…" → "Up to date" → "Review & Publish". The save hooks fired their status invalidation with `void`, so the mutation resolved before the re-read landed. Every observer of "is a save in flight" — the header button, the preview's autosave indicator — was released onto the PREVIOUS status and rendered it as current: a clean, confident "Up to date" over an edit that already existed. Awaiting the invalidation makes the mutation mean what its observers read it to mean. Preferred over holding the button on `statusQuery.isFetching`, which is also true for a window-focus refetch and would have flashed "Saving…" when nothing was being saved — the same class of bug in a new place. Audit of the neighbouring transitions found one more: with no task branch the status query is disabled, so its data never arrives and the button sat on "Loading…" permanently. There is nothing to publish without a branch, so it renders nothing instead. The other paths are already covered: TanStack v5 awaits onSuccess before settling a mutation, so `getLatest` and the post-publish branch switch both span their own invalidations; a branch switch drops the status data, so the Loading state — not a stale one — is what shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop app answered `/api/:org/sandbox/*/git/*` from a local sandbox unconditionally (intercept/sandbox_ops.rs `is_handled` matches `["git", _]`), and `try_intercept` runs before any upstream forwarding — so the cloud's Fast Preview branch (api sandbox-proxy.ts:213) was never reached in the native app. There are zero references to fastPreview anywhere in apps/native/crates. sandbox_ops already declines when no local worktree exists, and its comment names Fast Preview as a case that falls through. It doesn't, because the worktree handle is derived from the REPOSITORY: a sandbox left over from vibecoding on the same repo claims the route for a branch it never checked out, then answers `repository not initialized` (routes/git.rs:329). So the flag has to be declared, not inferred. The webview sets `x-deco-fast-preview` on its sandbox git calls and try_intercept declines every `/sandbox/*` path carrying it. A routing hint, not a trust boundary: the API re-derives the flag from the vMCP's own metadata, so a wrong value can only route toward the authority, never around it. apps/api needed no change — resolveVmClaim returns before resolveSandboxProvider is ever called, so a Fast Preview project never resolves a runner and daemon-backed routes 503 via requireRunner. It was already authoritative; it was being bypassed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tlgimenes
force-pushed
the
claude/fastpreview-upstream-authority
branch
from
August 13, 2026 19:45
5aacfd3 to
edbbfb6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #6054.
The bug
In the desktop app, a Fast Preview project's
/git/statuswas answered from a local sandbox clone, not from the GitHub API. Symptoms seen while testing #6054:Loading…forever after a branch switch, withSandboxGitError: repository not initializedandfetchStatus: "paused"— the query had failed and stopped retryinggit/statusreporting head6abff372while/decofile(which the editor writes through) reported64cf81af— two different repositories for the same branch.deco/generate.digests.json) in a payload the GitHub-backed shim can never produce, since it hardcodesmodified: []Why
intercept/sandbox_ops.rs'sis_handledmatches["git", _]unconditionally, andtry_interceptis the upstream proxy's first decision — so the request never reaches the cloud, andsandbox-proxy.ts:213's Fast Preview branch is never evaluated in the native app.grep -rn "fastPreview\|fast_preview" apps/native/crates/returns zero hits. The desktop app has no concept of Fast Preview.sandbox_opsalready declines when no local worktree exists, and its comment names sandbox-less Fast Preview as a case that falls through there. It doesn't, because the worktree handle is derived from the repository: a sandbox left over from vibecoding on the same repo claims the route for a branch it has never checked out, androutes/git.rs:329then answersnot_ready("repository not initialized").Inference can't work here. The flag has to be declared.
The fix
The webview sets
x-deco-fast-previewon its sandbox git calls;try_interceptdeclines every/sandbox/*path carrying it, before any interceptor runs.A routing hint, not a trust boundary — it only decides whether to answer locally, and the API re-derives the flag from the vMCP's own metadata before serving anything. A wrong or forged value can only route the request to the authority, never around it.
Declining at
try_interceptrather than per-interceptor is what makes it authoritative: Fast Preview is sandbox-less by definition, so nothing under/sandbox/*is ours to serve.apps/api needed no change
resolveVmClaimreturnsnext()beforeresolveSandboxProvideris ever called (sandbox-proxy.ts:229), so a Fast Preview project never resolves a runner and daemon-backed routes 503 viarequireRunner. It was already authoritative — it was being bypassed.Testing
cargo test -p local-api --lib routes::intercept— 69 pass.cargo check,cargo clippy,cargo fmt --checkclean.bun test214 pass,bun run lint0 errors,bun run fmtclean.Follow-up not in scope
Only the two calls the CMS toolbar makes (
fetchGitStatus,rebaseGitBranch) send the header today.PublishDialog's calls still go through the old path — they run on explicit user action and fail visibly rather than silently spinning, but they should carry it too.🤖 Generated with Claude Code
Summary by cubic
Make Fast Preview authoritative for desktop sandbox git routes to prevent local interception. Previously the native app served
/api/:org/sandbox/*/git/*from a local sandbox, causing mismatched HEADs and “repository not initialized”; now Fast Preview-marked calls always go to the cloud API.apps/native): definex-deco-fast-previewand predicate;try_interceptnow receives headers and returnsNonefor any/sandbox/*when the header is"1"or"true"; upstream proxy passes headers; adds a unit test for the predicate/regression.apps/web): sendx-deco-fast-previewinfetchGitStatusandrebaseGitBranch; addFAST_PREVIEW_HEADERandSandboxGitCallOptions; plumb viasandboxFetchwhen{ fastPreview: true }is set.Written for commit edbbfb6. Summary will update on new commits.