diff --git a/packages/app/src/components/GlobalFilterContext.tsx b/packages/app/src/components/GlobalFilterContext.tsx index 2780a202..b7e12e08 100644 --- a/packages/app/src/components/GlobalFilterContext.tsx +++ b/packages/app/src/components/GlobalFilterContext.tsx @@ -25,6 +25,7 @@ import { Sequence, SEQUENCE_OPTIONS, } from '@/lib/data-mappings'; +import { computeAutoSwitchDecision } from '@/lib/unofficial-run-auto-switch'; import type { AvailabilityRow, WorkflowInfoResponse } from '@/lib/api'; interface RunInfo { @@ -172,6 +173,34 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) { }); }, [availabilityRows, unofficialAvailable]); + // Auto-switch the selected model when an unofficial run is loaded that + // doesn't include the currently selected model. Without this, navigating + // to `?unofficialrun=` while the default `g_model=DeepSeek-R1` sticks + // leaves the user staring at a chart with no overlay points — they'd have + // to know to open the dropdown and pick the run's model themselves. + // + // Precedence on first load: the `if (urlModel)` early-bail in + // `computeAutoSwitchDecision` is the primary guard for explicit `g_model` + // intent. The dedupe ref is a secondary guard for the narrow window after + // an auto-switch fires but before the URL-sync effect (below) writes + // `g_model` back to the URL — once that runs, `urlModel` is set on every + // subsequent render and the ref check is effectively redundant. The ref + // still matters across navigations between unofficial runs because it is + // reset whenever the overlay set goes empty. + const lastAutoSwitchKeyRef = useRef(''); + useEffect(() => { + const decision = computeAutoSwitchDecision( + unofficialAvailable, + getUrlParam('g_model'), + selectedModel, + lastAutoSwitchKeyRef.current, + ); + lastAutoSwitchKeyRef.current = decision.nextKey; + if (decision.modelToSet !== null) { + setSelectedModel(decision.modelToSet); + } + }, [unofficialAvailable, selectedModel]); + // Sequences available for the selected model (DB ∪ unofficial run for this model) const availableSequences = useMemo(() => { const unofficialSeqs = unofficialAvailable diff --git a/packages/app/src/components/unofficial-run-provider.tsx b/packages/app/src/components/unofficial-run-provider.tsx index 9de84519..fa02bcaf 100644 --- a/packages/app/src/components/unofficial-run-provider.tsx +++ b/packages/app/src/components/unofficial-run-provider.tsx @@ -43,7 +43,7 @@ type UnofficialChartData = Record< const UNOFFICIAL_RUN_PARAM_RE = /^unofficialruns?$/i; -interface AvailableModelSequence { +export interface AvailableModelSequence { model: Model; sequence: Sequence; precisions: string[]; diff --git a/packages/app/src/lib/unofficial-run-auto-switch.test.ts b/packages/app/src/lib/unofficial-run-auto-switch.test.ts new file mode 100644 index 00000000..f58776ad --- /dev/null +++ b/packages/app/src/lib/unofficial-run-auto-switch.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import type { AvailableModelSequence } from '@/components/unofficial-run-provider'; +import { Model, Sequence } from '@/lib/data-mappings'; + +import { computeAutoSwitchDecision } from './unofficial-run-auto-switch'; + +function entry(model: Model, sequence: Sequence): AvailableModelSequence { + return { model, sequence, precisions: [] }; +} + +describe('computeAutoSwitchDecision', () => { + it('returns no-op and resets the key when no unofficial run is loaded', () => { + expect(computeAutoSwitchDecision([], undefined, Model.DeepSeek_R1, 'stale-key')).toEqual({ + nextKey: '', + modelToSet: null, + }); + }); + + it('switches to the run model when g_model is not pinned and current model is not covered', () => { + const run = [entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK)]; + const decision = computeAutoSwitchDecision(run, undefined, Model.DeepSeek_R1, ''); + expect(decision.modelToSet).toBe(Model.DeepSeek_V4_Pro); + expect(decision.nextKey).toBe(Model.DeepSeek_V4_Pro); + }); + + it('respects an explicit g_model URL param even when the run lacks that model', () => { + const run = [entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK)]; + const decision = computeAutoSwitchDecision(run, Model.DeepSeek_R1, Model.DeepSeek_R1, ''); + expect(decision.modelToSet).toBeNull(); + // Ref must not be advanced — if the URL is later cleared we still want + // a fresh load of the same run to be able to fire the switch. + expect(decision.nextKey).toBe(''); + }); + + it('does not switch when the current model is already covered by the overlay', () => { + const run = [ + entry(Model.DeepSeek_R1, Sequence.OneK_OneK), + entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK), + ]; + const decision = computeAutoSwitchDecision(run, undefined, Model.DeepSeek_R1, ''); + expect(decision.modelToSet).toBeNull(); + // Key still advances so we don't keep re-evaluating on every render. + expect(decision.nextKey).toBe([Model.DeepSeek_R1, Model.DeepSeek_V4_Pro].toSorted().join(',')); + }); + + it('does not re-fire after a manual model change against the same run set', () => { + // Simulate the post-auto-switch state: ref already holds the run's key, + // user manually switched back to a model the run does not cover. + const run = [entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK)]; + const lastKey = Model.DeepSeek_V4_Pro; + const decision = computeAutoSwitchDecision(run, undefined, Model.DeepSeek_R1, lastKey); + expect(decision.modelToSet).toBeNull(); + expect(decision.nextKey).toBe(lastKey); + }); + + it('re-arms after the overlay set is cleared so a subsequent load can switch again', () => { + // Step 1: a run is loaded, switch fires. + const run = [entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK)]; + const first = computeAutoSwitchDecision(run, undefined, Model.DeepSeek_R1, ''); + expect(first.modelToSet).toBe(Model.DeepSeek_V4_Pro); + + // Step 2: user dismisses the run, overlay set goes empty — ref resets. + const cleared = computeAutoSwitchDecision([], undefined, Model.DeepSeek_V4_Pro, first.nextKey); + expect(cleared).toEqual({ nextKey: '', modelToSet: null }); + + // Step 3: a *different* run is loaded with a different model. The cleared + // ref allows the switch to fire again. + const run2 = [entry(Model.Kimi_K2_5, Sequence.OneK_OneK)]; + const second = computeAutoSwitchDecision( + run2, + undefined, + Model.DeepSeek_V4_Pro, + cleared.nextKey, + ); + expect(second.modelToSet).toBe(Model.Kimi_K2_5); + }); + + it('ignores sequence-only changes in the dedupe key', () => { + // Same model, two sequences appearing across renders. The decision logic + // only branches on model, so the key should not change when a new + // sequence arrives for an already-covered model — otherwise the effect + // would re-evaluate (and bail) on every sequence delta. + const oneK = [entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK)]; + const both = [ + entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK), + entry(Model.DeepSeek_V4_Pro, Sequence.EightK_OneK), + ]; + const first = computeAutoSwitchDecision(oneK, undefined, Model.DeepSeek_R1, ''); + const second = computeAutoSwitchDecision(both, undefined, Model.DeepSeek_V4_Pro, first.nextKey); + expect(first.nextKey).toBe(second.nextKey); + expect(second.modelToSet).toBeNull(); + }); + + it('picks the first model deterministically across insertion orders', () => { + // Same set of models in two different orders should produce the same + // auto-picked target — protecting against `Object.keys`-driven nondeterminism + // in `parseAvailableModelsAndSequences`. + const orderA = [ + entry(Model.MiniMax_M2_5, Sequence.OneK_OneK), + entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK), + entry(Model.Kimi_K2_5, Sequence.OneK_OneK), + ]; + const orderB = [ + entry(Model.Kimi_K2_5, Sequence.OneK_OneK), + entry(Model.DeepSeek_V4_Pro, Sequence.OneK_OneK), + entry(Model.MiniMax_M2_5, Sequence.OneK_OneK), + ]; + const a = computeAutoSwitchDecision(orderA, undefined, Model.DeepSeek_R1, ''); + const b = computeAutoSwitchDecision(orderB, undefined, Model.DeepSeek_R1, ''); + expect(a.modelToSet).toBe(b.modelToSet); + expect(a.nextKey).toBe(b.nextKey); + }); +}); diff --git a/packages/app/src/lib/unofficial-run-auto-switch.ts b/packages/app/src/lib/unofficial-run-auto-switch.ts new file mode 100644 index 00000000..a4af4683 --- /dev/null +++ b/packages/app/src/lib/unofficial-run-auto-switch.ts @@ -0,0 +1,48 @@ +import type { AvailableModelSequence } from '@/components/unofficial-run-provider'; +import type { Model } from '@/lib/data-mappings'; + +export interface AutoSwitchDecision { + /** New value the caller should write into the dedupe ref. */ + nextKey: string; + /** Model to switch to, or null when no switch is needed. */ + modelToSet: Model | null; +} + +/** + * Pure decision helper for the unofficial-run auto-switch effect in + * `GlobalFilterContext`. Given the unofficial run's available models, the URL + * `g_model` param, the currently selected model, and the previous dedupe key, + * returns whether to swap `selectedModel` and what the new dedupe key should be. + * + * - When the overlay set is empty, the dedupe key is reset so the next load + * re-arms the effect. + * - When the URL pinned `g_model` explicitly, no switch fires (respect intent). + * - Otherwise the dedupe key is the sorted unique list of overlay models — the + * sequence dimension is intentionally excluded so a sequence-only delta does + * not invalidate a manual model pick the user made earlier. + * - The first model is taken from a sorted unique list to keep the choice + * deterministic across renders (insertion order from `Object.keys` is not + * guaranteed for multi-model runs). + */ +export function computeAutoSwitchDecision( + unofficialAvailable: AvailableModelSequence[], + urlModel: string | undefined, + selectedModel: Model, + lastKey: string, +): AutoSwitchDecision { + if (unofficialAvailable.length === 0) { + return { nextKey: '', modelToSet: null }; + } + if (urlModel) { + return { nextKey: lastKey, modelToSet: null }; + } + const sortedModels = [...new Set(unofficialAvailable.map((a) => a.model))].toSorted(); + const key = sortedModels.join(','); + if (lastKey === key) { + return { nextKey: lastKey, modelToSet: null }; + } + if (sortedModels.includes(selectedModel)) { + return { nextKey: key, modelToSet: null }; + } + return { nextKey: key, modelToSet: sortedModels[0] }; +}