fix(desktop): readable stale-aux warning with a Don't-show-again escape

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>
This commit is contained in:
Brooklyn Nicholson
2026-09-28 09:10:27 -05:00
committed by brooklyn!
parent 03bb1137f8
commit ca3d6209d4
2 changed files with 114 additions and 15 deletions

View File

@@ -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)"}'

View File

@@ -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 (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="grow">
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-400/60 bg-amber-500/15 px-3 py-2.5 text-sm text-amber-600 dark:text-amber-300">
<AlertTriangle className="size-4 shrink-0" />
<span className="grow font-medium">
{m.staleAuxBefore(slots.length, names)}
<span className="font-mono">{allSameProvider ? provider : m.staleAuxOtherProviders}</span>
{m.staleAuxAfter}
@@ -203,6 +212,11 @@ function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarnin
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
{m.resetAllToMain}
</Button>
{onDismiss && (
<Button disabled={applying} onClick={onDismiss} size="sm" variant="textStrong">
{m.staleAuxDismiss}
</Button>
)}
</div>
)
}
@@ -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<null | string>(() =>
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
</Button>
</div>
<p className="mb-2 text-xs text-muted-foreground">{m.auxiliaryDesc}</p>
{(switchStaleAux.length === 0 || !showMain) && persistentStaleAux.length > 0 && (
<div className="mb-2.5">
<StaleAuxWarning
applying={applying}
onReset={() => void resetAuxiliaryModels()}
slots={persistentStaleAux}
taskLabel={auxiliaryTaskLabel}
/>
</div>
)}
{(switchStaleAux.length === 0 || !showMain) &&
persistentStaleAux.length > 0 &&
!staleAuxDismissed && (
<div className="mb-2.5">
<StaleAuxWarning
applying={applying}
onDismiss={() => {
const mainProvider = mainModel?.provider ?? ''
dismissStaleAux(scopeProfile, mainProvider, persistentStaleAux)
setDismissedStaleAux(staleAuxFingerprint(mainProvider, persistentStaleAux))
}}
onReset={() => void resetAuxiliaryModels()}
slots={persistentStaleAux}
taskLabel={auxiliaryTaskLabel}
/>
</div>
)}
<div className="grid gap-1">
{AUX_TASKS.map(meta => {
const copy = m.tasks[meta.key] ?? { label: meta.key, hint: meta.key }