From dd762d07bba2d059892d66b7dd55f6bb962847d7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:16 -0500 Subject: [PATCH] fix(desktop): only the foreground session may write the composer atoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/use-session-actions/utils.test.ts | 31 +++++++ .../hooks/use-session-actions/utils.ts | 87 +++++++++++++++---- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index a83f8a38402..ea40e7705ad 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 2c0913fb6dc..1f86fd7c406 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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