Rework the branch-mismatch banner

Make branch-drift warnings calm, accurate, intent-gated, and permanently dismissable.

t3code · July 23, 2026 · follows PR #2284 · plan reviewed by Codex (gpt-5.6-sol)

Problem

PR #2284 stopped T3 from silently rewriting a thread's branch when the shared local checkout moves, and added a warning banner when the stored thread.branch differs from the current checkout. The detection is correct — but the presentation punishes the most common innocent workflow: finish issue A, checkout a branch for issue B, then reopen thread A just to read it. A non-dismissible amber warning appears on every old thread, at thread-open time, even though the only risk exists at send time.

User report (via X): “The mismatch is valid, but the banner fires during passive browsing. Gives you ‘oh shit something broke’ vibes.” — separate-PR-per-issue workflow, local checkout mode, threads reopened for context only.

Current vs. proposed UI

Before — current banner (warning, always on, not dismissable)

Fires the moment a mismatched thread is opened. Two competing actions, no dismiss, warning color for a self-healing condition.

After — passive browsing (composer empty & unfocused): nothing

Reply…

No banner, no hint. The branch picker in the composer toolbar already shows the current checkout and got a warning tint in #2284 — that is the ambient signal. Reading a thread carries zero risk.

After — composer focused or has content: one line, one verb, dismissable

Also add a test for the empty-scope case|

A fragment, not a sentence: fact, then the one datum. The current branch is already named in the branch picker below, so it never appears here. Chip tooltip carries the long form: "This thread last ran on feat/issue-438-user-scope-harness-slice1. Sending will continue on the current branch."

Confirm modal — only when the working tree has uncommitted changes

Switch to feat/…slice1?

You have uncommitted changes. They'll carry over to the other branch, or block the switch if they conflict.

Cancel Switch branch

Clean working tree → "Restore branch" switches immediately, no modal. A checkout is only risky when changes are in flight; an unconditional modal would rebuild the annoyance one level deeper.

Copy decisions

Banner lifecycle

No mismatch thread.branch = checkout Quiet picker tint only, no banner Info banner shown neutral + dismiss + switch Resolved: send rebinds thread · “Switch back” restores checkout · branches re-align Dismissed: ack {slice1, slice2} stored checkout moves composer intent ✕ dismiss suppressed until a different mismatch pair appears

Intent gate: the banner renders only when the composer is focused or has content (deriveComposerSendState already computes "has sendable content"). Passive browsing shows nothing beyond the existing branch-picker tint. The mismatch resolver itself (resolveLocalCheckoutBranchMismatch, BranchToolbar.logic.ts:111) is unchanged.

Dismissal design PR 2

Dismissing acknowledges one specific mismatch: the structured pair { threadBranch, checkoutBranch }, stored per thread as a single "last acknowledged mismatch" (not a list). Server-side, because the reporting user works across three machines and a phone — localStorage dismissal would resurrect the banner on every device.

RuleBehavior
Ack matches current mismatchBanner suppressed. Picker tint stays (ambient truth, not a nag).
Checkout moves to a third branchPair no longer matches → banner eligible again. This is why the key is the pair, not the thread.
Send rebinds the threadAck cleared (mismatch gone; a future drift is a new event).
"Switch back" succeeds / branches re-alignAck cleared.
Branch deleted & recreated under same nameAck still suppresses — acceptable; the warning is about names, documented in a comment. No commit-identity tracking.

Transport: a narrow, presentation-named command (e.g. thread.branch-mismatch.acknowledge) rather than overloading the semantic thread.meta.update. Codex review sized this honestly: it touches the command schema and thread schemas in packages/contracts/src/orchestration.ts, the event payload, the in-memory projector, the SQL projection (dedicated columns — needs a migration adding the ack field), snapshot queries, and backward-decoding defaults. It's a real migration, not a schema tweak — hence its own PR.

What we're deliberately not doing

Auto-syncing thread.branch on turn completion. Dropped after Codex review

The original plan had the server rebind a local-checkout thread to whatever branch was checked out when its turn completed, so an agent running git checkout -b wouldn't trigger the warning on its own thread. Codex review killed it, correctly: the checkout is shared mutable state. Final checkout position proves the checkout changed during the turn's window — not that this turn changed it. If the user (or another thread) checks out c while thread B runs, B would silently rebind to c — recreating the exact silent-drift bug #2284 fixed. The expectedBranch guard in decider.ts:517 doesn't help: it compares thread metadata, not repo state, and silently substitutes instead of rejecting. Safe attribution needs instrumented checkout ops or per-repo turn ownership — deferred until that exists.

The agent-creates-a-branch case is instead absorbed by the presentation fix: it becomes a calm, dismissable info line that's factually accurate ("the checkout has moved" — it has, the agent moved it), shown only when the user is about to send, and sending continues on the agent's new branch — which is what the user wants anyway.

Also not touching: the branch-picker warning tint and popover from #2284 (kept as-is), the mismatch detection logic, worktree threads (can't mismatch — resolver returns null when worktreePath is set), and non-git / ~-rooted sessions (no branch to compare).

Implementation plan

PR 1 — Presentation & intent gating client-only, small

  1. In ChatView.tsx (composerBannerItems, ~line 3914): switch the item to variant: "info", git-branch icon, single-line copy ("Branch changed — was <chip>", consequence in the chip tooltip); remove the "Move thread here" button; switch action becomes "Restore branch" (existing handleSwitchCheckoutToThread), with a confirm modal only when the working tree is dirty.
  2. Gate the item on composer intent: focused or hasSendableContent. Hysteresis: once shown, keep it mounted while the mismatch persists (no flicker on blur).
  3. Session-scoped dismiss via existing onDismiss (in-memory, keyed on the mismatch pair) — instant relief while PR 2 lands persistence.
  4. Tests: extend BranchToolbar.logic.test.ts / add a small gating-logic unit (ChatView.logic.ts is the natural home for a pure shouldShowBranchMismatchBanner()).

PR 2 — Persistent cross-device dismissal contracts + server + client

  1. Contracts: branchMismatchAck: { threadBranch, checkoutBranch } | null on the thread schemas; new thread.branch-mismatch.acknowledge command + event.
  2. Server: decider case (validate thread exists; store ack; clear on branch rebind), projector, SQL projection migration, snapshot queries, decoding defaults for old events.
  3. Client: dismiss X dispatches the command; banner + hint suppressed when ack matches the current pair; clearing rules per the table above.
  4. Tests: decider unit tests (ack set/clear), projection round-trip, client suppression logic.

Open question for later, only if users still ask: a global warnOnBranchDrift server setting as a full opt-out. Per-pair dismissal should make it unnecessary.