fix(desktop): gate the session.info branch write on the selected session

0401e08884 made the session.info cwd write require the event to describe
the selected stored session, but the branch write a few lines below
stayed unconditional. A background Kanban worker's runtime update that
reached the pane's active-runtime path could still flip the composer's
coding-rail branch to the worker's PR worktree while the default chat
stayed selected. cwd and branch now share one identity check.

Co-authored-by: ClintonEmok <54935030+ClintonEmok@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-24 23:23:28 -05:00
committed by brooklyn!
parent 0e31fd3b45
commit fe3c6837be
2 changed files with 57 additions and 7 deletions

View File

@@ -4,10 +4,12 @@ import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import {
$activeSessionId,
$currentBranch,
$currentCwd,
$selectedStoredSessionId,
$workspaceCwdOwner,
releaseWorkspaceCwdOwner,
setCurrentBranch,
setCurrentCwd
} from '@/store/session'
@@ -19,11 +21,13 @@ import type { GatewayEventContext } from './types'
// still carries a real cwd.
function sessionInfoEvent({
activeSessionId,
branch,
cwd,
explicitSid = '',
storedSessionId = ''
}: {
activeSessionId: null | string
branch?: string
cwd: string
explicitSid?: string
storedSessionId?: string
@@ -49,7 +53,7 @@ function sessionInfoEvent({
fromActiveSource: () => true,
isActiveEvent: !!sessionId && sessionId === activeSessionId,
occurredAt: Date.now() / 1000,
payload: { cwd, stored_session_id: storedSessionId },
payload: { branch, cwd, stored_session_id: storedSessionId },
scheduleConfigRefresh: vi.fn(),
sessionId
} as unknown as GatewayEventContext
@@ -60,12 +64,14 @@ describe('handleSessionInfoEvent workspace ownership', () => {
$selectedStoredSessionId.set(null)
$workspaceCwdOwner.set(null)
setCurrentCwd('')
setCurrentBranch('')
})
afterEach(() => {
$selectedStoredSessionId.set(null)
$workspaceCwdOwner.set(null)
setCurrentCwd('')
setCurrentBranch('')
})
// #55831 / the "workspace pane visible with no agent selected" report: with
@@ -109,6 +115,43 @@ describe('handleSessionInfoEvent workspace ownership', () => {
expect($workspaceCwdOwner.get()).toBe('selected-session')
})
// #92888: a background Kanban worker's runtime update reaches the pane's
// active-runtime path while the default Bot Chat stays selected. It names the
// worker's own stored session and its PR worktree; neither the path nor the
// branch may move onto the composer, while the selected chat's own update
// still publishes both.
it("keeps another session's worktree cwd and branch off the selected chat's composer", () => {
$selectedStoredSessionId.set('default-bot-chat')
setCurrentCwd('/repo/main-checkout')
setCurrentBranch('main')
handleSessionInfoEvent(
sessionInfoEvent({
activeSessionId: 'runtime-1',
branch: 'kanban/pr-42',
cwd: '/repo/.worktrees/pr-42',
explicitSid: 'runtime-1',
storedSessionId: 'kanban-worker'
})
)
expect($currentCwd.get()).toBe('/repo/main-checkout')
expect($currentBranch.get()).toBe('main')
handleSessionInfoEvent(
sessionInfoEvent({
activeSessionId: 'runtime-1',
branch: 'feature/mine',
cwd: '/repo/main-checkout',
explicitSid: 'runtime-1',
storedSessionId: 'default-bot-chat'
})
)
expect($currentBranch.get()).toBe('feature/mine')
expect($workspaceCwdOwner.get()).toBe('default-bot-chat')
})
it('keeps runtime state identity when a heartbeat only restates cached fields', () => {
const original = {
...createClientSessionState('stored-1'),

View File

@@ -40,7 +40,8 @@ import type { GatewayEventContext } from './types'
/**
* Whether a `session.info` payload's `stored_session_id` may be treated as the
* selected conversation's, so its cwd can be claimed for it (#71254).
* selected conversation's, so its cwd and branch can be claimed for it
* (#71254, #92888).
*
* Absent is not the same as different: the backend omits the id on a
* not-yet-built (`lazy`) session, and refusing there would leave the workspace
@@ -199,10 +200,16 @@ export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
// Active-session model/provider still flows through the session state
// cache via updateSessionState → syncRuntimeMetadataToView below.
if (
typeof payload?.cwd === 'string' &&
sessionInfoDescribesSelectedSession(payload.stored_session_id, isActiveEvent || rebound)
) {
// cwd and branch together name the workspace, so both need the event to
// be the selected conversation's. A background Kanban worker's update
// otherwise repointed the composer's branch at its PR worktree while
// the default chat stayed selected (#92888).
const describesSelectedWorkspace = sessionInfoDescribesSelectedSession(
payload?.stored_session_id,
isActiveEvent || rebound
)
if (typeof payload?.cwd === 'string' && describesSelectedWorkspace) {
// The active session's agent can relocate itself (new repo/worktree
// via the terminal). When the SAME active session's cwd actually
// moves, follow it — refresh the project tree + scope so the sidebar
@@ -227,7 +234,7 @@ export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
}
}
if (typeof payload?.branch === 'string') {
if (typeof payload?.branch === 'string' && describesSelectedWorkspace) {
setCurrentBranch(payload.branch)
}