fix(desktop): new chat honours the active profile instead of rubberbanding to default (#45057)

The top "New Session" button (and /new, the keyboard shortcut) cleared
$newChatProfile to null, meaning "use the live gateway context". But
createBackendSessionForSend turned a null into an omitted `profile` param on
session.create. In global-remote mode one backend serves every profile, so an
omitted profile silently binds the new chat to the launch (default) profile's
home/state.db — the session "rubberbands back to default" even though the rail
still shows the selected profile. The per-profile "+" worked because it sets
$newChatProfile explicitly.

Resolve a null $newChatProfile to the active gateway profile at the single
session-creation chokepoint so session.create always carries the live profile.
Harmless for single-profile and local-pooled users: a backend resolves its own
launch profile to None (_profile_home), so passing it changes nothing.
This commit is contained in:
brooklyn! 2026-06-12 11:38:56 -05:00 committed by GitHub
parent d62979a6f3
commit 79c3ed3cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 129 additions and 6 deletions

View file

@ -0,0 +1,119 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session'
import type { ClientSessionState } from '../../types'
import { useSessionActions } from './use-session-actions'
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
deleteSession: vi.fn(),
getSessionMessages: vi.fn(),
listAllProfileSessions: vi.fn(),
setApiRequestProfile: vi.fn(),
setSessionArchived: vi.fn()
}))
const RUNTIME_SESSION_ID = 'rt-new-001'
function Harness({
onReady,
requestGateway
}: {
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})
useEffect(() => {
onReady(actions.createBackendSessionForSend)
}, [actions.createBackendSessionForSend, onReady])
return null
}
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
}
return {} as never
})
$currentCwd.set('')
profileSetup()
let create: ((preview?: string | null) => Promise<string | null>) | null = null
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
await waitFor(() => expect(create).not.toBeNull())
await create!()
return createParams
}
describe('createBackendSessionForSend profile routing', () => {
afterEach(() => {
cleanup()
$newChatProfile.set(null)
$activeGatewayProfile.set('default')
vi.restoreAllMocks()
})
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
// The "rubberband to default" bug: the top New Session button clears
// $newChatProfile to null. In global-remote mode one backend serves every
// profile, so an omitted `profile` lands the chat on the launch (default)
// profile. The session must instead carry the active gateway profile.
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'coder' })
})
it('honours an explicit per-profile "+" selection', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('coder')
$newChatProfile.set('analyst')
})
expect(params).toMatchObject({ profile: 'analyst' })
})
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
const params = await createWith(() => {
$activeGatewayProfile.set('default')
$newChatProfile.set(null)
})
expect(params).toMatchObject({ profile: 'default' })
})
})

View file

@ -407,13 +407,17 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,