fix(desktop): give ⌘T session tabs a live gateway so slash completions load

`useGatewayRequest` only exposed the gateway through a ref that its own
subscription effect fills in, so a component reading it during render saw
null on mount. Session tiles — what ⌘T and the tab-strip "+" open — did
exactly that and passed `gateway={null}` down to ChatBar. The slash
adapter is disabled without a gateway, so a new tab had no completions at
all while ⌘N, which resolves its gateway through a state-driven memo,
worked fine. Nothing re-rendered the tile afterwards unless the connection
state happened to flip, so it never recovered.

Return the `$gateway` atom as a reactive value alongside the ref and use
it wherever the gateway is needed as a render-time value. The ref stays
for `requestGateway`, which wants a stable identity. The model picker,
model visibility, and settings overlays read the same ref during render,
so they're moved over too.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 20:09:58 -05:00
parent 58d805302a
commit bfc8e3b00d
4 changed files with 56 additions and 9 deletions

View file

@ -113,7 +113,7 @@ function TileChat({
storedSessionId: string
view: SessionView
}) {
const { gatewayRef, requestGateway } = useGatewayRequest()
const { gateway, requestGateway } = useGatewayRequest()
const queryClient = useQueryClient()
const { selectModel } = useModelControls({ queryClient, requestGateway })
const activeGatewayProfile = useStore($activeGatewayProfile)
@ -151,20 +151,20 @@ function TileChat({
() =>
gatewayOpen ? (
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
gateway={gateway || undefined}
onSelectModel={selectModel}
profile={activeGatewayProfile}
requestGateway={requestGateway}
/>
) : null,
[activeGatewayProfile, gatewayOpen, gatewayRef, requestGateway, selectModel]
[activeGatewayProfile, gateway, gatewayOpen, requestGateway, selectModel]
)
return (
<SessionViewProvider value={view}>
<ComposerScopeProvider value={scope}>
<ChatView
gateway={gatewayRef.current}
gateway={gateway}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}

View file

@ -222,7 +222,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
setMessages
})
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const { connectionRef, gateway, gatewayRef, requestGateway } = useGatewayRequest()
const {
loadMoreMessagingForPlatform,
@ -943,13 +943,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
/>
)}
<ModelPickerOverlay
gateway={gatewayRef.current || undefined}
gateway={gateway || undefined}
onSelect={selectModel}
profile={activeGatewayProfile}
/>
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay
gateway={gatewayRef.current || undefined}
gateway={gateway || undefined}
onOpenProviders={openProviderSettings}
profile={activeGatewayProfile}
/>
@ -965,7 +965,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
gateway={gatewayRef.current}
gateway={gateway}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()

View file

@ -0,0 +1,39 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { useGatewayRequest } from './use-gateway-request'
const fakeGateway = { connectionState: 'open' } as unknown as HermesGateway
afterEach(() => {
$gateway.set(null)
})
describe('useGatewayRequest', () => {
// The composer's `/` completions only exist when ChatBar receives a non-null
// gateway PROP. `gatewayRef` is populated by a subscription effect, so it is
// still null on the first render — a surface that read the ref while
// rendering (session tiles / ⌘T tabs) shipped `gateway={null}` and silently
// lost slash completions. The returned `gateway` value must be live
// immediately so that never happens again.
it('exposes the live gateway on the first render, before effects run', () => {
$gateway.set(fakeGateway)
const { result } = renderHook(() => useGatewayRequest())
expect(result.current.gateway).toBe(fakeGateway)
})
it('tracks the gateway when the active socket changes', () => {
const { result } = renderHook(() => useGatewayRequest())
expect(result.current.gateway).toBeNull()
act(() => $gateway.set(fakeGateway))
expect(result.current.gateway).toBe(fakeGateway)
})
})

View file

@ -9,6 +9,14 @@ import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
const gatewayState = useStore($gatewayState)
// Reactive companion to `gatewayRef`. The ref exists so `requestGateway`
// keeps a stable identity and always reaches the live socket, but it is only
// populated by the subscription effect below — i.e. AFTER the first render.
// A component that reads `gatewayRef.current` while rendering therefore sees
// null on mount, and if the connection state doesn't happen to flip
// afterwards it never re-renders to pick the instance up. Anything that needs
// the gateway as a render-time VALUE (props, memo deps) must use this.
const gateway = useStore($gateway) as HermesGateway | null
const gatewayRef = useRef<HermesGateway | null>(null)
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
@ -136,5 +144,5 @@ export function useGatewayRequest() {
[ensureGatewayOpen]
)
return { connectionRef, gatewayRef, requestGateway }
return { connectionRef, gateway, gatewayRef, requestGateway }
}