From dd762d07bba2d059892d66b7dd55f6bb962847d7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:16 -0500 Subject: [PATCH 1/3] 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 From c48d9a9c6dc38da03160a582eef9f07b855ffe16 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:23 -0500 Subject: [PATCH 2/3] fix(desktop): mark tile and branch runtimes as background A tile and a branched session each live in their own worktree and render from their own SessionView slice, so neither is the main pane's session. Pass foreground: false at both call sites. --- .../src/app/session/hooks/use-session-actions/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 266a33b65fd..9bcabd6d3bb 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -504,7 +504,9 @@ export function useSessionActions({ upsertOptimisticSession(created, stored, null, null) } - const runtimeInfo = applyRuntimeInfo(created.info) + // A tile lives in its OWN worktree — it must not publish its cwd/branch + // into the composer atoms the main pane renders from. + const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false }) updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) openSessionTile(stored, dir) @@ -1182,7 +1184,9 @@ export function useSessionActions({ routedSessionId ) - const runtimeInfo = applyRuntimeInfo(branched.info) + // The branch opens as its own tile in the parent's worktree, not as the + // primary session — keep its runtime out of the main composer atoms. + const runtimeInfo = applyRuntimeInfo(branched.info, { foreground: false }) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) if (runtimeInfo) { From 8f4ab7ad2c53a5ffda2ba4ff4f1f29d7a94633ac Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 12:27:23 -0500 Subject: [PATCH 3/3] fix(desktop): coding rail reads only its own worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row fell back to the global $repoStatus whenever repoPath was blank, painting the main pane's branch and ± onto a tile whose cwd hadn't resolved yet. The fallback bought nothing — the primary computed is keyed to $currentCwd, which is empty in exactly that case — and cost a rail showing a tree the session was never in. --- .../app/chat/composer/status-stack/coding-row.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 6ad820765e5..4d25320d952 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -16,8 +16,6 @@ import { DiffCount } from '@/components/ui/diff-count' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { - $repoStatus, - $repoWorktrees, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd @@ -69,10 +67,13 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const s = t.statusStack.coding const p = t.sidebar.projects const resolvedRepoPath = repoPath?.trim() || undefined - // Per-cwd slice when this surface knows its worktree (tiles); otherwise the - // primary main-pane computed — so a blank/missing repoPath still paints. - const status = useStore(resolvedRepoPath ? repoStatusForCwd(resolvedRepoPath) : $repoStatus) - const worktrees = useStore(resolvedRepoPath ? repoWorktreesForCwd(resolvedRepoPath) : $repoWorktrees) + // This surface's OWN worktree, always — never the primary's. The row used to + // fall back to the global `$repoStatus` for a blank repoPath, which painted + // the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That + // fallback bought nothing (the primary's computed is keyed to `$currentCwd`, + // which is blank in exactly the same case) and cost a wrong-tree rail. + const status = useStore(repoStatusForCwd(resolvedRepoPath)) + const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath)) // While mounted, keep this worktree in the coding-status refresh set so the // turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise