fix(desktop): don't claim a stranger's cwd as the selected session's workspace

An unnamed `session.info` was treated as describing whatever the pane had
selected. The gateway stamps `stored_session_id: session_key or ""`, so every
not-yet-persisted session emits one, and `broadcast_session_info` / the
approvals loop re-emit for every live session at once. An unscoped event
applies exactly when no session is active, so with nothing selected each of
those repointed `$currentCwd` and claimed it for the null selection — the file
tree, coding rail and statusbar painted a folder no selected conversation
owned, until the next `releaseWorkspaceCwdOwner` dropped the claim and they
un-painted it.

Require the event to be bound to the pane's own runtime before an absent id
reads as the selection. The case the allowance exists for — a lazy session that
is the pane's runtime but is not persisted yet — still adopts and owns its cwd.
This commit is contained in:
Brooklyn Nicholson
2026-08-28 18:34:21 -05:00
committed by brooklyn!
parent 3340bbbdad
commit 0401e08884
2 changed files with 131 additions and 7 deletions

View File

@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
$currentCwd,
$selectedStoredSessionId,
$workspaceCwdOwner,
releaseWorkspaceCwdOwner,
setCurrentCwd
} from '@/store/session'
import { handleSessionInfoEvent } from './session-info'
import type { GatewayEventContext } from './types'
// `_session_info` stamps `stored_session_id: session_key or ""`, so every
// not-yet-persisted session on the gateway emits an UNNAMED session.info that
// still carries a real cwd.
function sessionInfoEvent({
activeSessionId,
cwd,
explicitSid = '',
storedSessionId = ''
}: {
activeSessionId: null | string
cwd: string
explicitSid?: string
storedSessionId?: string
}): GatewayEventContext {
const sessionId = explicitSid || activeSessionId
return {
deps: {
activeGatewayProfile: 'default',
activeSessionIdRef: { current: activeSessionId },
hydrateFromStoredSession: vi.fn(),
lastCwdInfoSessionRef: { current: null },
queryClient: { invalidateQueries: vi.fn() },
refreshHermesConfig: vi.fn(),
scheduleSessionsRefresh: vi.fn(),
sessionInterrupted: () => false,
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn(state => state),
upsertToolCall: vi.fn()
},
event: { profile: 'default', session_id: explicitSid, type: 'session.info' },
explicitSid,
fromActiveSource: () => true,
isActiveEvent: !!sessionId && sessionId === activeSessionId,
occurredAt: Date.now() / 1000,
payload: { cwd, stored_session_id: storedSessionId },
scheduleConfigRefresh: vi.fn(),
sessionId
} as unknown as GatewayEventContext
}
describe('handleSessionInfoEvent workspace ownership', () => {
beforeEach(() => {
$selectedStoredSessionId.set(null)
$workspaceCwdOwner.set(null)
setCurrentCwd('')
})
afterEach(() => {
$selectedStoredSessionId.set(null)
$workspaceCwdOwner.set(null)
setCurrentCwd('')
})
// #55831 / the "workspace pane visible with no agent selected" report: with
// nothing selected an unscoped event is exactly the one that applies, and
// `broadcast_session_info` re-emits for EVERY live session at once. Adopting
// those repointed the pane at a stranger's folder and claimed it for the null
// selection, so the tree/coding rail painted it until the next release
// un-painted it — a flicker per fan-out, with no agent selected at all.
it('ignores an unnamed broadcast from a session the pane is not bound to', () => {
releaseWorkspaceCwdOwner()
const unowned = $workspaceCwdOwner.get()
handleSessionInfoEvent(sessionInfoEvent({ activeSessionId: null, cwd: '/repo/someone-elses-worktree' }))
expect($currentCwd.get()).toBe('')
expect($workspaceCwdOwner.get()).toBe(unowned)
})
it('does not let a fan-out of unnamed broadcasts walk the workspace path', () => {
const cwds = ['/repo/one', '/repo/two', '/repo/three']
for (const cwd of cwds) {
handleSessionInfoEvent(sessionInfoEvent({ activeSessionId: null, cwd }))
}
expect($currentCwd.get()).toBe('')
})
// The case the absent-id allowance exists for: a lazy session that has not
// been persisted yet is still the runtime this pane is bound to, so its cwd
// must be adopted and owned — otherwise the workspace reads as un-owned for
// the rest of the conversation.
it('adopts an unnamed session.info from the pane its own runtime', () => {
$selectedStoredSessionId.set('selected-session')
handleSessionInfoEvent(
sessionInfoEvent({ activeSessionId: 'runtime-1', cwd: '/repo/mine', explicitSid: 'runtime-1' })
)
expect($currentCwd.get()).toBe('/repo/mine')
expect($workspaceCwdOwner.get()).toBe('selected-session')
})
})

View File

@@ -38,16 +38,26 @@ import type { GatewayEventContext } from './types'
*
* 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
* marked un-owned for the rest of the conversation. Matching goes through the
* lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root
* a pinned-row selection may hold still read as one conversation.
* marked un-owned for the rest of the conversation. That only reads as the
* selection when the event is the pane's OWN runtime, though: an unscoped
* event applies precisely when no session is active, and the fan-outs
* (`broadcast_session_info`, the approvals loop) re-emit for every live
* session at once, each with its own cwd. As an unconditional wildcard those
* repoint `$currentCwd` and claim it for whatever is selected — nothing, in
* the report this comes from — until the next release drops the claim again.
* Hence `boundToPane`: with no binding there is no evidence, and no evidence
* must not become an ownership claim.
*
* Matching goes through the lineage (`sessionMatchesStoredId`) so a
* compression-rotated tip and the root a pinned-row selection may hold still
* read as one conversation.
*/
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean {
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined, boundToPane: boolean): boolean {
const infoStoredSessionId = storedSessionId?.trim() || null
const selected = $selectedStoredSessionId.get() ?? null
if (!infoStoredSessionId) {
return true
return boundToPane
}
// A named session cannot describe a fresh draft. Treating a null selection as
@@ -91,7 +101,10 @@ function maybeRebindPaneToRebuiltRuntime(ctx: GatewayEventContext): boolean {
const selected = $selectedStoredSessionId.get()
if (!selected || !sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
// A rebuilt runtime announces itself for a conversation that is already
// persisted, so it always names one; an unnamed payload has no lineage to
// match and must not capture the pane's active runtime id.
if (!selected || !sessionInfoDescribesSelectedSession(payload.stored_session_id, false)) {
return false
}
@@ -180,7 +193,10 @@ 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)) {
if (
typeof payload?.cwd === 'string' &&
sessionInfoDescribesSelectedSession(payload.stored_session_id, isActiveEvent || rebound)
) {
// 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