Overhaul Models tab UX: action feedback, post-training flow, guided navigation
CI / build-and-push (push) Successful in 29s

- Lift modelsTab state into Zustand store so actions can navigate tabs
- Add toast notifications + auto-tab-switch to all 10 model actions
  (train, configure SFT/alignment, distill, fine-tune, quantize, eval, deploy, open-source)
- Add actionable toast buttons with navigation (e.g., "Go to Families" on training complete)
- Fix post-training config: remove 50% deadline, show until pretraining completes,
  always-visible warning prompt outside card expand, engine reminder at 75%
- PostTrainingConfig now hides already-configured sections independently
- Add tab badges: pulsing dot for active jobs, count for undeployed models, warning for no deployment
- Replace empty states with actionable buttons guiding next steps
- Stage bars show "(skip)" in warning color for unconfigured SFT/Alignment stages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 10:20:00 -04:00
parent fdedd6f4d0
commit 775c6a4fa5
6 changed files with 436 additions and 270 deletions
+260 -190
View File
@@ -58,10 +58,13 @@ export interface InfraNav {
datacenterId?: string;
}
type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products';
interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
infraNav: InfraNav;
modelsTab: ModelsTab;
}
export interface GameNotification {
@@ -71,6 +74,7 @@ export interface GameNotification {
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
}
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
@@ -86,6 +90,7 @@ function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks'
interface Actions {
setActivePage: (page: ActivePage) => void;
setInfraNav: (nav: InfraNav) => void;
setModelsTab: (tab: ModelsTab) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void;
removeNotification: (id: string) => void;
@@ -289,11 +294,14 @@ export const useGameStore = create<Store>()(
activePage: 'dashboard' as ActivePage,
notifications: [],
infraNav: { level: 'clusters' } as InfraNav,
modelsTab: 'overview' as ModelsTab,
setActivePage: (page) => set({ activePage: page }),
setInfraNav: (nav) => set({ infraNav: nav }),
setModelsTab: (tab) => set({ modelsTab: tab }),
addNotification: (n) => set((s) => ({
notifications: [
{ ...n, id: uuid(), read: false },
@@ -907,197 +915,252 @@ export const useGameStore = create<Store>()(
// --- Non-infrastructure actions (unchanged) ---
startTrainingPipeline: (config) => set((s) => {
const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length;
const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return s;
startTrainingPipeline: (config) => {
let created = false;
set((s) => {
const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length;
const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1;
if (activeCount >= maxSlots) return s;
const familyId = uuid();
const pipelineId = uuid();
const generation = s.models.families.length + 1;
created = true;
const familyId = uuid();
const pipelineId = uuid();
const generation = s.models.families.length + 1;
const family: ModelFamily = {
id: familyId,
name: config.modelName,
generation,
baseModelId: null,
variants: [],
createdAtTick: s.meta.tickCount,
};
const family: ModelFamily = {
id: familyId,
name: config.modelName,
generation,
baseModelId: null,
variants: [],
createdAtTick: s.meta.tickCount,
};
const pipeline: TrainingPipeline = {
id: pipelineId,
familyId,
modelName: config.modelName,
architecture: config.architecture,
dataMix: config.dataMix,
currentStage: 'pretraining',
stages: {
pretraining: {
targetTokens: config.targetTokens,
processedTokens: 0,
computeAllocated: 0,
progressTicks: 0,
totalTicks: config.totalTicks,
lossValue: 10,
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
isComplete: false,
const pipeline: TrainingPipeline = {
id: pipelineId,
familyId,
modelName: config.modelName,
architecture: config.architecture,
dataMix: config.dataMix,
currentStage: 'pretraining',
stages: {
pretraining: {
targetTokens: config.targetTokens,
processedTokens: 0,
computeAllocated: 0,
progressTicks: 0,
totalTicks: config.totalTicks,
lossValue: 10,
chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9),
isComplete: false,
},
sft: null,
alignment: null,
},
sft: null,
alignment: null,
},
status: 'active',
allocatedComputeFraction: config.allocatedComputeFraction,
events: [],
startedAtTick: s.meta.tickCount,
};
status: 'active',
allocatedComputeFraction: config.allocatedComputeFraction,
events: [],
startedAtTick: s.meta.tickCount,
};
return {
return {
models: {
...s.models,
families: [...s.models.families, family],
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline],
},
};
});
if (created) {
get().addNotification({ title: 'Training Started', message: `${config.modelName} pre-training has begun.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
configureSFT: (pipelineId, specializations) => {
set((s) => ({
models: {
...s.models,
families: [...s.models.families, family],
activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline],
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
p.id === pipelineId ? {
...p,
stages: {
...p.stages,
sft: {
specializations,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10),
isComplete: false,
},
},
} : p,
),
},
};
}),
}));
get().addNotification({ title: 'SFT Configured', message: `${specializations.join(', ')} specializations enabled.`, type: 'success', tick: get().meta.tickCount });
},
configureSFT: (pipelineId, specializations) => set((s) => ({
models: {
...s.models,
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
p.id === pipelineId ? {
...p,
stages: {
...p.stages,
sft: {
specializations,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10),
isComplete: false,
configureAlignment: (pipelineId, method, safetyWeight) => {
set((s) => ({
models: {
...s.models,
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
p.id === pipelineId ? {
...p,
stages: {
...p.stages,
alignment: {
method,
safetyWeight,
helpfulnessWeight: 1 - safetyWeight,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08),
isComplete: false,
},
},
},
} : p,
),
},
})),
} : p,
),
},
}));
get().addNotification({ title: 'Alignment Configured', message: `${method.toUpperCase()} alignment enabled.`, type: 'success', tick: get().meta.tickCount });
},
configureAlignment: (pipelineId, method, safetyWeight) => set((s) => ({
models: {
...s.models,
activeTrainingPipelines: s.models.activeTrainingPipelines.map(p =>
p.id === pipelineId ? {
...p,
stages: {
...p.stages,
alignment: {
method,
safetyWeight,
helpfulnessWeight: 1 - safetyWeight,
progressTicks: 0,
totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08),
isComplete: false,
},
},
} : p,
),
},
})),
createDistillation: (baseModelId, targetParameters, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
created = true;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'distillation',
config: { targetParameters, targetArchitecture: base.architecture.type, variantName },
progressTicks: 0,
totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30),
allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Distillation Started', message: `${variantName} distillation in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createDistillation: (baseModelId, targetParameters, variantName) => set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'distillation',
config: { targetParameters, targetArchitecture: base.architecture.type, variantName },
progressTicks: 0,
totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30),
allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
}),
createFineTune: (baseModelId, specialization, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
created = true;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'fine-tuning',
config: { specialization, datasetIds: [], variantName },
progressTicks: 0,
totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120),
allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Fine-Tuning Started', message: `${variantName} fine-tuning in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createFineTune: (baseModelId, specialization, variantName) => set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'fine-tuning',
config: { specialization, datasetIds: [], variantName },
progressTicks: 0,
totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120),
allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
}),
createQuantization: (baseModelId, level, variantName) => {
let created = false;
set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
created = true;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'quantization',
config: { level, variantName },
progressTicks: 0,
totalTicks: QUANTIZATION_TICKS,
allocatedComputeFraction: 0,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Quantization Started', message: `${variantName} quantization in progress.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
createQuantization: (baseModelId, level, variantName) => set((s) => {
const base = s.models.baseModels.find(m => m.id === baseModelId);
if (!base) return s;
const job: VariantCreationJob = {
id: uuid(),
familyId: base.familyId,
baseModelId,
jobType: 'quantization',
config: { level, variantName },
progressTicks: 0,
totalTicks: QUANTIZATION_TICKS,
allocatedComputeFraction: 0,
status: 'active',
};
return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } };
}),
startEvaluation: (modelId, benchmarkIds) => {
let created = false;
set((s) => {
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
if (benchmarks.length === 0) return s;
created = true;
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
const job: EvalJob = {
id: uuid(),
modelId,
benchmarkIds,
progressTicks: 0,
totalTicks,
computeAllocated: computeCost,
status: 'active',
results: [],
};
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Evaluation Started', message: `${benchmarkIds.length} benchmark${benchmarkIds.length > 1 ? 's' : ''} queued.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
startEvaluation: (modelId, benchmarkIds) => set((s) => {
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
if (benchmarks.length === 0) return s;
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
const job: EvalJob = {
id: uuid(),
modelId,
benchmarkIds,
progressTicks: 0,
totalTicks,
computeAllocated: computeCost,
status: 'active',
results: [],
};
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
}),
deployModel: (modelId) => {
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
set((s) => ({
models: {
...s.models,
baseModels: s.models.baseModels.map(m =>
m.id === modelId ? { ...m, isDeployed: true } : m,
),
productLines: s.models.productLines.map(pl => ({
...pl, modelId, isActive: true,
})),
},
market: {
...s.market,
obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount),
},
}));
get().addNotification({ title: 'Model Deployed', message: `${modelName} is now serving all product lines.`, type: 'success', tick: get().meta.tickCount });
set({ modelsTab: 'products' as ModelsTab });
},
deployModel: (modelId) => set((s) => ({
models: {
...s.models,
baseModels: s.models.baseModels.map(m =>
m.id === modelId ? { ...m, isDeployed: true } : m,
),
productLines: s.models.productLines.map(pl => ({
...pl, modelId, isActive: true,
})),
},
market: {
...s.market,
obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount),
},
})),
deployVariant: (familyId, variantId) => set((s) => ({
models: {
...s.models,
families: s.models.families.map(f =>
f.id === familyId
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
: f,
),
},
})),
deployVariant: (familyId, variantId) => {
set((s) => ({
models: {
...s.models,
families: s.models.families.map(f =>
f.id === familyId
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
: f,
),
},
}));
get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
set({ modelsTab: 'products' as ModelsTab });
},
setProductPricing: (productLineId, field, value) => set((s) => ({
models: {
@@ -1188,20 +1251,27 @@ export const useGameStore = create<Store>()(
};
}),
openSourceModel: (modelId) => set((s) => {
if (s.market.openSourcedModels.includes(modelId)) return s;
return {
market: {
...s.market,
openSourcedModels: [...s.market.openSourcedModels, modelId],
},
reputation: {
...s.reputation,
score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST),
publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST),
},
};
}),
openSourceModel: (modelId) => {
let opened = false;
set((s) => {
if (s.market.openSourcedModels.includes(modelId)) return s;
opened = true;
return {
market: {
...s.market,
openSourcedModels: [...s.market.openSourcedModels, modelId],
},
reputation: {
...s.reputation,
score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST),
publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST),
},
};
});
if (opened) {
get().addNotification({ title: 'Model Open Sourced', message: 'Reputation boosted! Competitors may benefit.', type: 'success', tick: get().meta.tickCount });
}
},
setOverloadPolicy: (policy) => set((s) => ({
market: {
@@ -1382,7 +1452,7 @@ export const useGameStore = create<Store>()(
name: 'ai-tycoon-save',
version: SAVE_VERSION,
partialize: (state) => {
const { activePage, notifications, infraNav, ...rest } = state;
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
return rest;
},
migrate: (_persisted, version) => {