diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts new file mode 100644 index 0000000000..398a67d208 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts index cdc713656a..65e0e2f50b 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts @@ -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