From f9f5c14f9aaf2ce273fe4dae81e1082dfd0a72b0 Mon Sep 17 00:00:00 2001 From: KBANTH Date: Fri, 14 Aug 2026 22:36:01 +0700 Subject: [PATCH] fix(desktop): keep live gateway across profile switches --- .../desktop/src/app/contrib/surfaces.test.tsx | 67 +++++++++++++++++++ apps/desktop/src/app/contrib/surfaces.tsx | 18 ++--- 2 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/app/contrib/surfaces.test.tsx diff --git a/apps/desktop/src/app/contrib/surfaces.test.tsx b/apps/desktop/src/app/contrib/surfaces.test.tsx new file mode 100644 index 0000000000..274da80268 --- /dev/null +++ b/apps/desktop/src/app/contrib/surfaces.test.tsx @@ -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(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 }) =>
{gateway?.id}
+})) +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( + + + + ) + + expect(screen.getByTestId('gateway').textContent).toBe('a') + + act(() => { + $gateway.set(gatewayB) + $activeGatewayProfile.set('other') + }) + + expect(screen.getByTestId('gateway').textContent).toBe('b') + }) +}) diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index fb5b32a257..abd3fb04d0 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -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' ? (