Merge pull request #74938 from NousResearch/bb/rail-own-worktree

fix(desktop): a session's coding rail follows its own worktree
This commit is contained in:
brooklyn! 2026-07-30 16:54:37 -05:00 committed by GitHub
commit 3a2b332985
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 115 additions and 24 deletions

View file

@ -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

View file

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

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