fix(desktop): leave the entered project when switching profile or backend

A project id names a row in one backend's projects.db, but $projectScope
survived profile and connection switches. The fresh draft resolved its cwd
from the stale scope against the still-loaded old project tree, so a new
chat in profile B started in profile A's project folder.

Fixes #54990

Co-authored-by: Robin Rademacher <robinradx@gmail.com>
This commit is contained in:
Hermes Agent
2026-09-24 23:59:07 -05:00
committed by brooklyn!
parent 953ca9e094
commit 4869d3812c
4 changed files with 69 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout'
import { $projectScope, ALL_PROJECTS } from '@/store/project-scope'
import {
$activeSessionId,
$cronSessions,
@@ -100,6 +101,14 @@ describe('wipeSessionListsForGatewaySwitch', () => {
expect($currentBranch.get()).toBe('')
})
it("leaves the outgoing backend's project scope so the next draft cannot start in it (#54990)", () => {
$projectScope.set('p_old_backend')
wipeSessionListsForGatewaySwitch()
expect($projectScope.get()).toBe(ALL_PROJECTS)
})
it("forgets the previous backend's in-memory paging state", () => {
const page = {
messages: Array.from({ length: 120 }, (_, index) => ({

View File

@@ -8,6 +8,7 @@ import { invalidateCronJobsRequests, setCronJobs } from '@/store/cron'
import { resetSessionsLimit } from '@/store/layout'
import { resetLiveSync } from '@/store/live-sync'
import { invalidateProfileListFetches } from '@/store/profile'
import { exitProjectScope } from '@/store/project-scope'
import {
$unreadFinishedSessionIds,
setActiveSessionId,
@@ -195,6 +196,9 @@ export function wipeSessionListsForGatewaySwitch(): void {
// has never seen them, so drop the "already pushed" bookkeeping and let the
// next reconcile re-assert the whole set against the new backend.
resetSessionPinMirror()
// Project ids belong to the outgoing backend's projects.db; a scope left
// entered would root the next draft's cwd in the old source's project.
exitProjectScope()
setSessions([])
setSessionProfilesTruncated({})
setSessionProfilesUsage({})

View File

@@ -16,6 +16,7 @@ const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({
$gateway,
activeGateway: () => null,
activeGatewayConnectionId: () => null,
// Activation now verifies the socket's route before publishing the profile.
activeGatewayProfileKey: () => ensureGatewayForProfile.mock.lastCall?.[0] ?? $activeGatewayProfile.get(),
@@ -44,10 +45,15 @@ const {
$profiles,
ensureGatewayProfile,
invalidateProfileListFetches,
newSessionInProfile,
prewarmProfileBackend,
refreshProfiles
refreshProfiles,
selectProfile
} = await import('./profile')
const { $projectScope, ALL_PROJECTS } = await import('./project-scope')
const { $projectTree, resolveNewSessionCwd } = await import('./projects')
const { $poolLimits } = await import('@/store/pool-limits')
const { $connectionsRegistry } = await import('@/store/connection-registry-state')
@@ -375,3 +381,38 @@ describe('stale profile-list fetches across a backend switch (#85731)', () => {
expect($profiles.get().map(profile => profile.name)).toEqual(['default', 'coder'])
})
})
describe("profile switch leaves the previous profile's project (#54990)", () => {
const enterDefaultProfileProject = () => {
$projectTree.set([{ id: 'p_app1', label: 'app1', path: '/work/app1', repos: [] } as never])
$projectScope.set('p_app1')
}
afterEach(() => {
$projectScope.set(ALL_PROJECTS)
$projectTree.set([])
})
it.each([
['selectProfile', selectProfile],
['newSessionInProfile', newSessionInProfile]
])('%s to another profile does not root the fresh draft in the old project', (_name, open) => {
enterDefaultProfileProject()
expect(resolveNewSessionCwd()).toBe('/work/app1')
// The gateway swap is async: the old profile's project tree is still loaded
// when the fresh draft resolves its cwd.
open('sinan')
expect($projectScope.get()).toBe(ALL_PROJECTS)
expect(resolveNewSessionCwd()).not.toBe('/work/app1')
})
it('keeps the entered project when the draft stays on the active profile', () => {
enterDefaultProfileProject()
newSessionInProfile('default')
expect(resolveNewSessionCwd()).toBe('/work/app1')
})
})

View File

@@ -29,6 +29,7 @@ import {
import { notifyError } from '@/store/notifications'
import { $poolLimits } from '@/store/pool-limits'
import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override'
import { exitProjectScope } from '@/store/project-scope'
import { $connection, clearComposerSelectionOwner, setComposerSelectionOwner, setConnection } from '@/store/session'
import type { SessionOwnerRoute } from '@/store/session-request-router'
import { resetStarmapGraph } from '@/store/starmap'
@@ -955,6 +956,7 @@ export function selectProfile(name: string): void {
captureNewChatSource(profilePickConnectionId(target))
if (switching) {
leaveForeignProjectScope(target)
requestFreshSession()
}
@@ -1025,6 +1027,16 @@ function activateOnCurrentSource(target: string): Promise<void> {
return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target)
}
// A project id names a row in ONE backend's projects.db. A draft headed for
// another profile (or source) must not resolve its cwd from the scope entered on
// the current one: the fresh draft runs before the gateway swap refreshes the
// project tree, so it would start in the previous profile's project (#54990).
function leaveForeignProjectScope(profile: string, connectionId: null | string = activeGatewayConnectionId()): void {
if (profile !== normalizeProfileKey($activeGatewayProfile.get()) || connectionId !== activeGatewayConnectionId()) {
exitProjectScope()
}
}
// Pin the next new chat to `name` (legacy profile-only door) so session.create
// reads the profile the user clicked "+" under, not whatever
// $activeGatewayProfile holds once an in-flight profile swap settles (#79005).
@@ -1045,6 +1057,7 @@ export function pinNewChatProfile(name: string): string {
// message lands in the right place.
export function newSessionInProfile(name: string): void {
const target = pinNewChatProfile(name)
leaveForeignProjectScope(target)
requestFreshSession()
// #81094: surface the failed dial instead of failing silently.
void activateOnCurrentSource(target).catch((error: unknown) => {
@@ -1072,6 +1085,7 @@ export function newSessionInAgent(route: AgentProfileRoute): void {
$newChatProfile.set(captured.profile)
$newChatRoute.set(captured)
captureNewChatSource(captured.connectionId)
leaveForeignProjectScope(captured.profile, captured.connectionId)
requestFreshSession()
// #81094: surface the failed dial instead of failing silently.
void ensureGatewayAgent(captured.connectionId, captured.profile).catch((error: unknown) => {