From ca3d6209d49ada09aefd2e1f60f09d6bac1723ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 28 Sep 2026 09:10:27 -0500 Subject: [PATCH] fix(desktop): readable stale-aux warning with a Don't-show-again escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner was text-xs amber-200 — easy to miss in dark mode and near illegible in light mode. It now uses the app's warn-badge contrast pair (text-amber-600 dark:text-amber-300) at text-sm with a size-4 icon, stronger border/background, and medium weight, so it reads at a glance in both themes. The persistent variant gains a dismiss button that records the fingerprint acknowledgement; the post-switch notice stays undismissable because it announces a change that just happened. Co-authored-by: kyssta-exe <25470058+kyssta-exe@users.noreply.github.com> Co-authored-by: Konstantin Khlopkov <47825603+kokhlo@users.noreply.github.com> --- .../src/app/settings/model-settings.test.tsx | 59 ++++++++++++++++ .../src/app/settings/model-settings.tsx | 70 +++++++++++++++---- 2 files changed, 114 insertions(+), 15 deletions(-) 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.staleAuxBefore(slots.length, names)} {allSameProvider ? provider : m.staleAuxOtherProviders} {m.staleAuxAfter} @@ -203,6 +212,11 @@ function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarnin + {onDismiss && ( + + )}
) } @@ -582,6 +596,24 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod [auxiliary, mainModel] ) + // Acknowledgement of the persistent stale-aux banner (#66740): a dismissal + // is bound to the exact pin configuration it acknowledged, so any slot edit, + // main switch, or endpoint repoint produces a different fingerprint and + // re-arms the warning. Seeded lazily at first render (before the data can + // paint, so an acknowledged banner never flashes); the panel stays mounted + // across profile switches, so re-read when the scope changes. + const [dismissedStaleAux, setDismissedStaleAux] = useState(() => + readStaleAuxDismissal(scopeProfile) + ) + + useEffect(() => { + setDismissedStaleAux(readStaleAuxDismissal(scopeProfile)) + }, [scopeProfile]) + + const staleAuxDismissed = + persistentStaleAux.length > 0 && + dismissedStaleAux === staleAuxFingerprint(mainModel?.provider ?? '', persistentStaleAux) + // Capabilities of the APPLIED main model — gates the profile-default // reasoning/speed controls the same way the composer picker gates per-model // edits (reasoning defaults on, fast defaults off when unreported). @@ -1052,16 +1084,24 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod

{m.auxiliaryDesc}

- {(switchStaleAux.length === 0 || !showMain) && persistentStaleAux.length > 0 && ( -
- void resetAuxiliaryModels()} - slots={persistentStaleAux} - taskLabel={auxiliaryTaskLabel} - /> -
- )} + {(switchStaleAux.length === 0 || !showMain) && + persistentStaleAux.length > 0 && + !staleAuxDismissed && ( +
+ { + const mainProvider = mainModel?.provider ?? '' + + dismissStaleAux(scopeProfile, mainProvider, persistentStaleAux) + setDismissedStaleAux(staleAuxFingerprint(mainProvider, persistentStaleAux)) + }} + onReset={() => void resetAuxiliaryModels()} + slots={persistentStaleAux} + taskLabel={auxiliaryTaskLabel} + /> +
+ )}
{AUX_TASKS.map(meta => { const copy = m.tasks[meta.key] ?? { label: meta.key, hint: meta.key }