diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 93c43972d3..ce5f7546f3 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -781,6 +781,65 @@ describe('ModelSettings MoA preset editor', () => { }) }) +describe('ModelSettings stale-aux banner dismissal', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + afterEach(() => { + window.localStorage.clear() + }) + + const staleAux = { + main: { provider: 'nous', model: 'hermes-4' }, + tasks: [{ task: 'vision', provider: 'alibaba', model: 'qwen3.6-flash', base_url: '' }] + } + + it('hides the persistent stale-aux banner after acknowledging it, and re-arms when the main provider changes', async () => { + getAuxiliaryModels.mockResolvedValue(staleAux) + + // First visit: the deliberate cross-provider pin surfaces the banner. + renderModelSettings() + expect(await screen.findByText(/still run on/)).toBeTruthy() + fireEvent.click(await screen.findByRole('button', { name: TRANSLATIONS.en.settings.model.staleAuxDismiss })) + + await waitFor(() => expect(screen.queryByText(/still run on/)).toBeNull()) + + // Second visit (fresh mount): the acknowledgement persists. + cleanup() + renderModelSettings() + await waitFor(() => expect(getAuxiliaryModels).toHaveBeenCalled()) + await waitFor(() => expect(screen.queryByText(/still run on/)).toBeNull()) + + // A main-provider switch re-arms the banner: the acknowledged + // configuration no longer matches what is running. + cleanup() + getGlobalModelInfo.mockResolvedValue({ provider: 'openrouter', model: 'hermes-4' }) + renderModelSettings() + expect(await screen.findByText(/still run on/)).toBeTruthy() + // The dismiss affordance is offered again for the new configuration. + expect(screen.getByRole('button', { name: TRANSLATIONS.en.settings.model.staleAuxDismiss })).toBeTruthy() + }) + + it('keeps the post-switch notice undismissable — it announces a change that just happened', async () => { + setModelAssignment.mockResolvedValueOnce({ + ok: true, + provider: 'openrouter', + model: 'anthropic/claude-opus-4.7', + gateway_tools: [], + stale_aux: [{ task: 'compression', provider: 'nous', model: 'hermes-4' }] + }) + + renderModelSettings() + await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled()) + + fireEvent.click(await screen.findByRole('button', { name: 'Apply' })) + + expect(await screen.findByText(/still run on/)).toBeTruthy() + expect(screen.queryByRole('button', { name: TRANSLATIONS.en.settings.model.staleAuxDismiss })).toBeNull() + }) +}) + describe('ModelSettings code-skew 503', () => { const skewError = new Error( 'Error invoking remote method \'hermes:api\': Error: 503: {"detail":"Restart required: This process is running code from 08b4875f4a but the checkout on disk is now 48d2528066. The model picker would risk a stale-module crash — restart the Desktop-owned backend to load the new code (use Restart backend in Hermes Desktop, or quit and reopen the app)"}' diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 5b396afcdb..1c6d7ebe45 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -45,6 +45,7 @@ import { CONTROL_TEXT } from './constants' import { getNested, setNested } from './helpers' import { ModelSelect, withActive } from './model-select' import { ListRow, ListRowSkeleton, Pill, SectionHeading, SectionHeadingSkeleton } from './primitives' +import { dismissStaleAux, readStaleAuxDismissal, staleAuxFingerprint } from './stale-aux-dismissal' import { useDeepLinkHighlight } from './use-deep-link-highlight' // Skeleton mirror of the Model settings DOM so the page keeps its shape while @@ -166,11 +167,17 @@ export function staleAuxAssignments( // (auxiliary_client._normalize_aux_provider), so it can never be a stale pin. return p && p !== 'auto' && p !== 'main' && p !== main && !entry.local_endpoint }) - .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) + // base_url rides along for the dismissal fingerprint (see + // stale-aux-dismissal.ts): repointing a pin at a different endpoint changes + // the billing surface and must re-arm an acknowledged banner. + .map(entry => ({ base_url: entry.base_url, task: entry.task, provider: entry.provider, model: entry.model })) } interface StaleAuxWarningProps { applying: boolean + /** Offered only on the persistent variant — the post-switch notice announces + * a change that just happened and must not be silenced. */ + onDismiss?: () => void onReset: () => void slots: readonly StaleAuxAssignment[] taskLabel: (key: string) => string @@ -180,7 +187,9 @@ interface StaleAuxWarningProps { // current main. Surfaces the silent credit-burn path (e.g. aux pinned to a // $0-balance provider after switching main away from it) and offers the // existing one-click reset rather than auto-clearing legitimate pins. -function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) { +// Sized to be read at a glance (#66740) with the theme-aware amber text the +// app's warn badges use, so light mode keeps its contrast. +function StaleAuxWarning({ applying, onDismiss, onReset, slots, taskLabel }: StaleAuxWarningProps) { const { t } = useI18n() const m = t.settings.model @@ -193,9 +202,9 @@ function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarnin const names = slots.map(slot => taskLabel(slot.task)).join(', ') return ( -
{m.auxiliaryDesc}
- {(switchStaleAux.length === 0 || !showMain) && persistentStaleAux.length > 0 && ( -