fix(desktop): only the foreground session may write the composer atoms

applyRuntimeInfo unconditionally mirrored a runtime's cwd, branch, model
and usage into the global composer atoms. Every tile create and session
branch called it, so opening a session in another worktree re-pointed the
MAIN pane's coding rail at that tile's repo — and persisted it, so the
wrong workspace cwd survived a restart.

Collect the patch first, then mirror it once behind a `foreground` gate.
Background callers still get the full patch for their own session state;
they just stop publishing into state they don't own.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 12:27:16 -05:00
parent fd90ef77be
commit dd762d07bb
2 changed files with 102 additions and 16 deletions

View file

@ -4,6 +4,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $desktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile } from '@/store/profile'
import { $currentBranch, $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import {
@ -65,6 +66,36 @@ describe('applyRuntimeInfo credential warnings', () => {
})
})
describe('applyRuntimeInfo foreground scoping', () => {
beforeEach(() => {
setCurrentCwd('/main-repo')
setCurrentBranch('main')
})
afterEach(() => {
setCurrentCwd('')
setCurrentBranch('')
})
it('publishes a foreground runtime into the composer atoms', () => {
const patch = applyRuntimeInfo({ branch: 'bb/feature', cwd: '/main-repo/worktree' })
expect($currentCwd.get()).toBe('/main-repo/worktree')
expect($currentBranch.get()).toBe('bb/feature')
expect(patch).toMatchObject({ branch: 'bb/feature', cwd: '/main-repo/worktree' })
})
it('keeps a background runtime out of the composer atoms but still returns its patch', () => {
const patch = applyRuntimeInfo({ branch: 'bb/tile', cwd: '/other-worktree' }, { foreground: false })
// The main pane's rail must stay on its own tree.
expect($currentCwd.get()).toBe('/main-repo')
expect($currentBranch.get()).toBe('main')
// ...while the caller still gets everything it needs for its own session.
expect(patch).toMatchObject({ branch: 'bb/tile', cwd: '/other-worktree' })
})
})
describe('isSessionGoneError', () => {
it('is true for 404 / session-not-found, false otherwise', () => {
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)

View file

@ -706,13 +706,72 @@ type SessionRuntimeStatePatch = Partial<
>
>
export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionRuntimeStatePatch | null {
interface ApplyRuntimeInfoOptions {
/**
* Whether this runtime belongs to the session the MAIN pane is showing.
* Foreground (the default) mirrors into the composer atoms every main-pane
* surface reads.
*
* A tile or a background branch must pass `false`: it owns a different
* worktree, and writing its cwd into `$currentCwd` re-pointed the main
* composer's coding rail (and the persisted workspace cwd) at the tile's
* repo the main rail painted a branch from a tree its session was never
* in. The returned patch still carries every field, so the caller's own
* per-session state is unaffected.
*/
foreground?: boolean
}
/** Mirror a session's runtime state into the composer atoms the MAIN pane
* renders from. Foreground sessions only see ApplyRuntimeInfoOptions. */
function publishRuntimeToComposer(state: SessionRuntimeStatePatch): void {
if (state.model !== undefined) {
setCurrentModel(state.model)
}
if (state.provider !== undefined) {
setCurrentProvider(state.provider)
}
if (state.cwd !== undefined) {
setCurrentCwd(state.cwd)
}
if (state.branch !== undefined) {
setCurrentBranch(state.branch)
}
if (state.personality !== undefined) {
setCurrentPersonality(state.personality)
}
if (state.reasoningEffort !== undefined) {
setCurrentReasoningEffort(state.reasoningEffort)
}
if (state.serviceTier !== undefined) {
setCurrentServiceTier(state.serviceTier)
}
if (state.fast !== undefined) {
setCurrentFastMode(state.fast)
}
if (state.yolo !== undefined) {
setYoloActive(state.yolo)
}
}
export function applyRuntimeInfo(
info: SessionRuntimeInfo | undefined,
{ foreground = true }: ApplyRuntimeInfoOptions = {}
): SessionRuntimeStatePatch | null {
if (!info) {
return null
}
const sessionState: SessionRuntimeStatePatch = {}
// App/profile-level reporting is session-independent — a tile's runtime
// reports backend skew and credential warnings just as usefully.
reportBackendContract(info.desktop_contract)
if (info.approval_mode !== undefined) {
@ -723,54 +782,50 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
reportInstallMethodWarning(info.install_warning)
const sessionState: SessionRuntimeStatePatch = {}
if (typeof info.model === 'string') {
setCurrentModel(info.model)
sessionState.model = info.model
}
if (typeof info.provider === 'string') {
setCurrentProvider(info.provider)
sessionState.provider = info.provider
}
if (info.cwd) {
setCurrentCwd(info.cwd)
sessionState.cwd = info.cwd
}
if (info.branch !== undefined) {
setCurrentBranch(info.branch || '')
sessionState.branch = info.branch || ''
}
if (typeof info.personality === 'string') {
const personality = normalizePersonalityValue(info.personality)
setCurrentPersonality(personality)
sessionState.personality = personality
sessionState.personality = normalizePersonalityValue(info.personality)
}
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
sessionState.reasoningEffort = info.reasoning_effort
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
sessionState.serviceTier = info.service_tier
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
sessionState.fast = info.fast
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
sessionState.yolo = info.yolo
}
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
if (foreground) {
publishRuntimeToComposer(sessionState)
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
}
}
return sessionState