From 4869d3812c2d3197b9647675bb9e1d8e4a5547eb Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 23:59:07 -0500 Subject: [PATCH] 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 --- apps/desktop/src/store/gateway-switch.test.ts | 9 ++++ apps/desktop/src/store/gateway-switch.ts | 4 ++ apps/desktop/src/store/profile.test.ts | 43 ++++++++++++++++++- apps/desktop/src/store/profile.ts | 14 ++++++ 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts index 7d5d479742..7b3e960b27 100644 --- a/apps/desktop/src/store/gateway-switch.test.ts +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -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) => ({ diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index d7703f065d..6bbd337657 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -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({}) diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index 33c444f296..003e680967 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index da5118c228..7ee2363398 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -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 { 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) => {