fix(desktop): keep live gateway across profile switches

This commit is contained in:
KBANTH
2026-08-14 22:36:01 +07:00
committed by Teknium
parent 077c04755e
commit f9f5c14f9a
2 changed files with 72 additions and 13 deletions
@@ -0,0 +1,67 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { ChatRoutesSurface } from './surfaces'
import type { WiringActions } from './types'
vi.mock('@/contrib/react/use-contributions', () => ({ useContributions: vi.fn() }))
vi.mock('@/store/gateway', () => ({ $gateway: atom<unknown>(null) }))
vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') }))
vi.mock('@/store/session', () => ({
$freshDraftReady: atom(false),
$gatewayState: atom('open')
}))
vi.mock('../chat', () => ({
ChatView: ({ gateway }: { gateway: { id?: string } | null }) => <div data-testid="gateway">{gateway?.id}</div>
}))
vi.mock('../chat/sidebar', () => ({ ChatSidebar: () => null }))
vi.mock('../right-sidebar/terminal/chrome', () => ({ TerminalPaneChrome: () => null }))
vi.mock('../shell/hooks/use-status-snapshot', () => ({ useStatusSnapshot: () => ({}) }))
vi.mock('../shell/hooks/use-statusbar-items', () => ({ useStatusbarItems: () => ({ leftStatusbarItems: [], statusbarItems: [] }) }))
vi.mock('../shell/statusbar-controls', () => ({ StatusbarControls: () => null }))
vi.mock('../routes', () => ({
contributedRoutes: () => [],
NEW_CHAT_ROUTE: '/new',
ROUTES_AREA: 'routes',
sessionRoute: (id: string) => `/${id}`
}))
vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) }))
vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
afterEach(() => {
cleanup()
$gateway.set(null)
$activeGatewayProfile.set('default')
})
describe('ChatRoutesSurface', () => {
it('passes the live gateway after an open-to-open profile switch', () => {
const gatewayA = { id: 'a' } as unknown as HermesGateway
const gatewayB = { id: 'b' } as unknown as HermesGateway
$gateway.set(gatewayA)
const actions = { getGateway: () => $gateway.get() } as unknown as WiringActions
render(
<MemoryRouter>
<ChatRoutesSurface actions={actions} />
</MemoryRouter>
)
expect(screen.getByTestId('gateway').textContent).toBe('a')
act(() => {
$gateway.set(gatewayB)
$activeGatewayProfile.set('other')
})
expect(screen.getByTestId('gateway').textContent).toBe('b')
})
})
+5 -13
View File
@@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router'
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $gateway } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $freshDraftReady, $gatewayState } from '@/store/session'
@@ -102,10 +103,9 @@ export const StatusbarSurface = memo(function StatusbarSurface({
})
/** The workspace pane: the real route table (chat + full-page views + plugin
* routes). Subscribes to `$gatewayState` and ROUTES_AREA itself; the gateway
* instance + voice cap arrive as props so a reconnect/config load re-renders
* only this surface. ChatView subscribes to its own session atoms, so
* streaming never round-trips through the controller. */
* routes). Subscribes to the gateway instance/state and ROUTES_AREA itself;
* the voice cap arrives as a prop. ChatView subscribes to its own session
* atoms, so streaming never round-trips through the controller. */
export const ChatRoutesSurface = memo(function ChatRoutesSurface({
actions,
maxVoiceRecordingSeconds
@@ -114,19 +114,11 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
maxVoiceRecordingSeconds?: number
}) {
const activeGatewayProfile = useStore($activeGatewayProfile)
const gateway = useStore($gateway)
const gatewayState = useStore($gatewayState)
useContributions(ROUTES_AREA)
const routeContributions = contributedRoutes()
// Recapture the live gateway instance whenever the connection state flips.
// getGateway reads a controller ref, so gatewayState is the intentional
// re-eval trigger (not a value the computation itself reads).
const gateway = useMemo(
() => actions.getGateway(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[actions, gatewayState]
)
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (