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 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) { 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