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:
committed by
brooklyn!
parent
03bb1137f8
commit
ca3d6209d4
@@ -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)"}'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user