Rework the branch-mismatch banner
Make branch-drift warnings calm, accurate, intent-gated, and permanently dismissable.
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
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
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.
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
- Fragment register, not sentences. "This", "will", "continue", "here", "instead" each cost a
parse step. "Branch changed — was
X" is a fact plus a datum, in git's own voice. Rejected alternates: "Was onX" (too cryptic), "Previously onX" (long word, short meaning), "Branch changed since last message" (timing is implied — the banner only shows at send intent). - Name only the old branch, once. The current branch is already on screen in the composer's branch picker; repeating it is what bloated earlier drafts.
- "Restore branch" — a verb plus its object; the chip beside it is the referent. Bare "Restore" is ambiguous about what gets restored; embedding the branch name breaks on long names.
- Confirm only when dirty. The git status feeding the banner already knows if the tree is dirty. Dirty → modal (above); clean → immediate switch. No "are you sure" tax on the safe, common case.
- No "was that intentional?" question. The dismiss X is the "yes, intentional" answer.
- Drop "Move thread here". Sending already rebinds the thread
(
resolveThreadMetadataUpdateForNextTurn); a button duplicating the default outcome forces a choice where none exists. - Truncation: the single branch chip gets
max-width+ ellipsis, full name in the tooltip along with the consequence line.
Banner lifecycle
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.
| Rule | Behavior |
|---|---|
| Ack matches current mismatch | Banner suppressed. Picker tint stays (ambient truth, not a nag). |
| Checkout moves to a third branch | Pair no longer matches → banner eligible again. This is why the key is the pair, not the thread. |
| Send rebinds the thread | Ack cleared (mismatch gone; a future drift is a new event). |
| "Switch back" succeeds / branches re-align | Ack cleared. |
| Branch deleted & recreated under same name | Ack 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
- In
ChatView.tsx(composerBannerItems, ~line 3914): switch the item tovariant: "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" (existinghandleSwitchCheckoutToThread), with a confirm modal only when the working tree is dirty. - Gate the item on composer intent: focused or
hasSendableContent. Hysteresis: once shown, keep it mounted while the mismatch persists (no flicker on blur). - Session-scoped dismiss via existing
onDismiss(in-memory, keyed on the mismatch pair) — instant relief while PR 2 lands persistence. - Tests: extend
BranchToolbar.logic.test.ts/ add a small gating-logic unit (ChatView.logic.tsis the natural home for a pureshouldShowBranchMismatchBanner()).
PR 2 — Persistent cross-device dismissal contracts + server + client
- Contracts:
branchMismatchAck: { threadBranch, checkoutBranch } | nullon the thread schemas; newthread.branch-mismatch.acknowledgecommand + event. - Server: decider case (validate thread exists; store ack; clear on branch rebind), projector, SQL projection migration, snapshot queries, decoding defaults for old events.
- Client: dismiss X dispatches the command; banner + hint suppressed when ack matches the current pair; clearing rules per the table above.
- 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.