Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export function getGlobalModelOptions(

return window.hermesDesktop.api<ModelOptionsResponse>({
...capabilityScoped(profile),
...(profile && typeof profile === 'object' ? { connectionId: profile.connectionId || 'local' } : {}),
// Explicit null preserves the captured legacy route, which may be remote.
...(profile && typeof profile === 'object' && profile.connectionId !== null
? { connectionId: profile.connectionId || 'local' }
: {}),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop/src/app/hooks/use-composer-model-owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useStore } from '@nanostores/react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'

import {
captureDraftComposerOwner,
captureModelRequestOwner,
composerOwnerKey,
type ComposerSelectionOwner
} from '@/app/session/hooks/composer-model-selection-owner'
import { $activeGatewayProfile, $newChatConnectionId, $newChatProfile, $newChatRoute } from '@/store/profile'
import { $connection } from '@/store/session'

// These atoms publish rehomes; canonical capture still owns route precedence.
// Do not infer model authority from the mirrored model/provider atoms here.
function useModelOwnerChanges() {
useStore($connection)
useStore($activeGatewayProfile)
useStore($newChatConnectionId)
useStore($newChatProfile)
useStore($newChatRoute)
}

export function useModelRequestOwner(scopeProfile?: string): ComposerSelectionOwner {
// Canonical capture reads imperative stores; it must run on every rehome.
'use no memo'

useModelOwnerChanges()

return captureModelRequestOwner(scopeProfile)
}

export function useDraftComposerOwner(): ComposerSelectionOwner {
'use no memo'

useModelOwnerChanges()

return captureDraftComposerOwner()
}

/** Form lifecycle only: a batched round trip still needs a fresh form. */
export function useModelFormKey(owner: ComposerSelectionOwner, scopeProfile?: string): string {
const [revision, setRevision] = useState(0)
useLayoutEffect(() => {
let previousKey = composerOwnerKey(captureModelRequestOwner(scopeProfile))

const rehome = () => {
const nextKey = composerOwnerKey(captureModelRequestOwner(scopeProfile))

if (nextKey !== previousKey) {
previousKey = nextKey
setRevision(value => value + 1)
}
}

const unlisten = [$connection, $activeGatewayProfile, $newChatConnectionId, $newChatProfile, $newChatRoute]
.map(store => store.listen(rehome))

return () => { unlisten.forEach(stop => stop()) }
}, [scopeProfile])

return JSON.stringify([composerOwnerKey(owner), revision])
}

/** A remounted form cannot regain permission when the user returns A→B→A. */
export function useModelOwnerIsCurrent(owner: ComposerSelectionOwner, scopeProfile?: string): () => boolean {
const ownerKey = composerOwnerKey(owner)
const mounted = useRef(true)
useLayoutEffect(() => {
mounted.current = true

const invalidate = () => {
if (composerOwnerKey(captureModelRequestOwner(scopeProfile)) !== ownerKey) {
mounted.current = false
}
}

// Observe the transition itself, even when React batches A→B→A into one
// paint. This lease cannot become valid again before a fresh form mount.
const unlisten = [$connection, $activeGatewayProfile, $newChatConnectionId, $newChatProfile, $newChatRoute]
.map(store => store.listen(invalidate))

return () => {
mounted.current = false
unlisten.forEach(stop => stop())
}
}, [ownerKey, scopeProfile])

return useCallback(
() => mounted.current && composerOwnerKey(captureModelRequestOwner(scopeProfile)) === ownerKey,
[ownerKey, scopeProfile]
)
}

export function requireCurrentModelOwner(isCurrent: () => boolean): void {
if (!isCurrent()) {
throw new Error('Model settings target changed. Reopen the selection before saving.')
}
}
132 changes: 132 additions & 0 deletions apps/desktop/src/app/model-picker-overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'

import { setApiRequestConnection, setApiRequestProfile } from '@/api/client'
import type * as Gateway from '@/store/gateway'
import { $newChatConnectionId, $newChatProfile, $newChatRoute } from '@/store/profile'
import {
$activeSessionId, $currentModel, $currentProvider, $gatewayState, $modelPickerOpen, $sessions,
_resetComposerModelSelectionsForTests, _resetSessionOwnerHintsForTests, captureComposerModelSelection,
recordComposerModelSelection, setComposerModelSelectionOwner, setSessionOwnerHint
} from '@/store/session'
import { knownOwnerForSession } from '@/store/session-states'

import { ModelPickerOverlay } from './model-picker-overlay'

const calls = vi.hoisted(() => ({ agent: vi.fn(), profile: vi.fn(), rest: vi.fn() }))
vi.mock('@/store/gateway', async original => ({
...await original<typeof Gateway>(),
requestGatewayForAgent: (...args: unknown[]) => calls.agent(...args),
requestGatewayForProfile: (...args: unknown[]) => calls.profile(...args)
}))
vi.mock('@/hermes', () => ({ getGlobalModelOptions: (...args: unknown[]) => calls.rest(...args), setApiRequestProfile: vi.fn() }))
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
const owner = { connectionId: 'source-b', profile: 'same-name', targetProfile: 'backend-b' }
const options = { model: 'catalog-b', provider: 'custom:b', providers: [{ name: 'B', slug: 'custom:b', models: ['catalog-b', 'pinned-b'] }] }

beforeEach(() => {
vi.clearAllMocks()
calls.agent.mockReset().mockResolvedValue(options)
calls.profile.mockReset().mockResolvedValue(options)
calls.rest.mockReset().mockResolvedValue(options)
_resetComposerModelSelectionsForTests()
_resetSessionOwnerHintsForTests()
$sessions.set([])
$activeSessionId.set(null)
$gatewayState.set('open')
$modelPickerOpen.set(true)
$currentModel.set('ambient-a')
$currentProvider.set('provider-a')
setApiRequestConnection('source-a')
setApiRequestProfile('default')
$newChatRoute.set(owner)
$newChatProfile.set(owner.profile)
$newChatConnectionId.set(owner.connectionId)
setComposerModelSelectionOwner(owner)
})
afterEach(() => { cleanup(); setApiRequestConnection(null); setApiRequestProfile('default'); $newChatRoute.set(null); $newChatProfile.set(null); $newChatConnectionId.set(null); $modelPickerOpen.set(false); $gatewayState.set('idle'); $sessions.set([]); _resetSessionOwnerHintsForTests() })

function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const ambient = vi.fn(async () => options)
const select = vi.fn()
const view = render(<QueryClientProvider client={client}><ModelPickerOverlay gateway={{ request: ambient } as never} onSelect={select} profile="default" /></QueryClientProvider>)

return { client, view, ambient, select }
}

it('uses the captured fresh draft source and backend target in the actual overlay catalog', async () => {
const { ambient } = mount()
await screen.findByText('catalog-b')
expect(calls.agent).toHaveBeenCalledWith('source-b', 'same-name', 'model.options', { profile: 'backend-b', explicit_only: true })
expect(ambient).not.toHaveBeenCalled()
})

it('does not mark a stale catalog default current when a valid scalar receipt owns the draft', async () => {
recordComposerModelSelection(captureComposerModelSelection(owner), { model: 'scalar-b', provider: '', source: 'default' })
$currentModel.set('scalar-b')
$currentProvider.set('')
const { select } = mount()
const row = await screen.findByText('catalog-b')
expect(row.closest('[cmdk-item]')?.className).not.toContain('bg-primary text-primary-foreground')
expect(select).not.toHaveBeenCalled()
})

it('preserves a deliberate draft pin through a late catalog reply', async () => {
let resolve!: (value: typeof options) => void
calls.agent.mockReturnValueOnce(new Promise(r => { resolve = r }))
mount()
await waitFor(() => expect(calls.agent).toHaveBeenCalledOnce())
await act(async () => {
recordComposerModelSelection(captureComposerModelSelection(owner), { model: 'pinned-b', provider: 'custom:b', source: 'manual' })
$currentModel.set('pinned-b')
$currentProvider.set('custom:b')
resolve(options)
})
const row = await screen.findByText('pinned-b')
expect(row.closest('[cmdk-item]')?.className).toContain('bg-primary text-primary-foreground')
})

it.each([
['legacy', 'rejected'], ['legacy', 'empty'], ['local', 'rejected'], ['local', 'empty']
] as const)('preserves a live %s owner through %s RPC recovery in the overlay', async (source, rpcUnavailable) => {
const runtimeId = 'owned-runtime'
const connectionId = source === 'legacy' ? null : 'local'
$sessions.set([{ id: runtimeId, profile: 'default' }] as never)

if (connectionId) {
setSessionOwnerHint(runtimeId, { connectionId, profile: 'default', mode: 'local' })
}

expect(knownOwnerForSession(runtimeId)).toEqual(connectionId ? { connectionId, profile: 'default', mode: 'local' } : 'default')
$activeSessionId.set(runtimeId)
$currentModel.set('')
$currentProvider.set('')
const rpc = source === 'legacy' ? calls.profile : calls.agent

if (rpcUnavailable === 'empty') {
rpc.mockResolvedValue({ providers: [] })
} else {
rpc.mockRejectedValue(new Error('Offline catalog RPC'))
}

const model = source === 'legacy' ? 'legacy-a' : 'local-b'
const expected = { ...options, model, providers: [{ ...options.providers[0], models: [model] }] }
calls.rest.mockImplementation(async (_opts, scope) => scope.connectionId === connectionId ? expected : options)
const { client, ambient } = mount()
await waitFor(() => expect(calls.rest).toHaveBeenCalledWith({ explicitOnly: true }, { connectionId, profile: 'default' }))
await screen.findByText(model)
expect(ambient).not.toHaveBeenCalled()

if (source === 'legacy') {
expect(calls.profile).toHaveBeenCalledWith('default', 'model.options', { profile: 'default', session_id: runtimeId, explicit_only: true }, undefined, undefined)
} else {
expect(calls.agent).toHaveBeenCalledWith('local', 'default', 'model.options', { profile: 'default', session_id: runtimeId, explicit_only: true })
}

expect(client.getQueryData(['model-options', source === 'legacy' ? 'default' : 'local::default', runtimeId])).toEqual(expected)
})
21 changes: 15 additions & 6 deletions apps/desktop/src/app/model-picker-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import { useStoreSelector } from '@/lib/use-session-slice'
import { requestGatewayForAgent } from '@/store/gateway'
import {
$activeSessionId,
$currentModel,
$currentProvider,
$gatewayState,
$modelPickerOpen,
getComposerModelSelection,
setModelPickerOpen
} from '@/store/session'
import { knownOwnerForSession, requestForOwnedSession } from '@/store/session-states'
import { $focusedRuntimeId, $focusedSessionState } from '@/store/session-states'

import { useDraftComposerOwner } from './hooks/use-composer-model-owner'

interface ModelPickerOverlayProps {
gateway?: HermesGateway
onSelect: (selection: ModelSelection) => void
Expand All @@ -36,6 +40,7 @@ export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOv
const focusedProvider = useStoreSelector($focusedSessionState, state => state?.provider ?? null)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)
const draftOwner = useDraftComposerOwner()

// Prefer the focused tile's runtime when the overlay opens from a tile that
// lacked a live menu (gateway closed → fallback path).
Expand All @@ -48,20 +53,24 @@ export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOv
}

const owner = knownOwnerForSession(sessionId)
const draftSelection = !sessionId ? getComposerModelSelection(draftOwner) : null
const ownerProfile = typeof owner === 'string' ? owner : (owner?.targetProfile || owner?.profile)
const ownerConnection = owner && typeof owner === 'object' ? owner.connectionId : owner ? 'local' : undefined
const ownerConnection = owner && typeof owner === 'object' ? owner.connectionId : owner ? null : undefined

return (
<ModelPickerDialog
connectionId={ownerConnection}
currentModel={currentModel}
currentProvider={currentProvider}
connectionId={owner ? ownerConnection : (!sessionId ? draftOwner.connectionId : undefined)}
currentModel={sessionId ? currentModel : draftSelection?.model || ''}
currentProvider={sessionId ? currentProvider : draftSelection?.provider || ''}
gw={gateway}
onOpenChange={setModelPickerOpen}
onSelect={selection => onSelect({ ...selection, sessionId })}
open={open}
profile={ownerProfile || profile}
request={gateway && sessionId ? (method, params) => requestForOwnedSession(sessionId, gateway.request.bind(gateway), method, params) : undefined}
profile={ownerProfile || (!sessionId ? draftOwner.targetProfile || draftOwner.profile : profile)}
request={!sessionId
? (method, params) => requestGatewayForAgent(draftOwner.connectionId, draftOwner.profile, method, params)
: gateway ? (method, params) => requestForOwnedSession(sessionId, gateway.request.bind(gateway), method, params) : undefined}
selectionIsAuthoritative={Boolean(draftSelection)}
sessionId={sessionId}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,14 @@ describe('actual producer/store/admission composition', () => {
})

it('refuses the actual Settings confirmation retry after A changes to B with the same logical profile', async () => {
vi.mocked(getGlobalModelOptions).mockImplementation(async (_opts, scope) => {
const connectionId = scope && typeof scope === 'object' ? scope.connectionId : getApiRequestConnection()
const pair = connectionId === b.connectionId ? defaultB : defaultA

return {
providers: [{ slug: pair.provider, name: 'Offline owner', authenticated: true, models: [pair.model] }]
}
})
const value = await setup(1)
await seed(value)
const assignmentSources: (string | null)[] = []
Expand Down
Loading
Loading