597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback, useMemo, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { t } from "@/lib/i18n";
|
|
import { AgentLogo } from "@/components/agent-logo";
|
|
import { resolveModelSelectorAgentName } from "./model-selector-provider";
|
|
|
|
import {
|
|
configOptionToModelOptions,
|
|
isModelConfigOption,
|
|
ModelConfigSelector,
|
|
type ModelSelectorOption,
|
|
type SelectConfigOption,
|
|
usableConfigOptions,
|
|
} from "@/components/model-config-selector";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
|
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
|
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
|
|
import { setSessionConfigOption, setSessionModel } from "@/lib/api/domains/session-api";
|
|
import type { Agent, AgentProfile, AvailableAgent, TaskSession } from "@/lib/types/http";
|
|
import type {
|
|
ConfigOptionEntry,
|
|
SessionModelEntry,
|
|
} from "@/lib/state/slices/session-runtime/types";
|
|
type SessionModelsEntry = {
|
|
currentModelId: string;
|
|
models: SessionModelEntry[];
|
|
configOptions: ConfigOptionEntry[];
|
|
configOptionsSettled?: boolean;
|
|
configBaseline?: Record<string, string>;
|
|
/** Set when the session started on the profile's fallback model. */
|
|
fallbackModel?: string;
|
|
};
|
|
|
|
type ModelSelectorProps = {
|
|
sessionId: string | null;
|
|
triggerClassName?: string;
|
|
showAgentIcon?: boolean;
|
|
};
|
|
|
|
const debug = createDebugLogger("model-selector:gate");
|
|
|
|
function configValueKeys(value: unknown): string[] {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
return Object.entries(value)
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && !!entry[1])
|
|
.map(([key]) => key);
|
|
}
|
|
|
|
function configValue(value: unknown, key: string): string | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const candidate = (value as Record<string, unknown>)[key];
|
|
return typeof candidate === "string" && candidate ? candidate : undefined;
|
|
}
|
|
|
|
const MODEL_CONFIG_KEY = "model";
|
|
// "agent" identifies which agent runs the session. Legacy snapshots can record
|
|
// it as an identity or the stale value "default", but provider-defined agent
|
|
// options remain required when the provider advertises them.
|
|
const AGENT_CONFIG_KEY = "agent";
|
|
const LEGACY_AGENT_CONFIG_VALUE = "default";
|
|
|
|
function isLegacyAgentConfig(session: TaskSession | null, agents: Agent[]): boolean {
|
|
if (!session) return false;
|
|
const snapshot = session.agent_profile_snapshot;
|
|
const profile = agents
|
|
.flatMap((agent) => agent.profiles)
|
|
.find((item) => item.id === session.agent_profile_id);
|
|
const identityValues = new Set(
|
|
[
|
|
configValue(snapshot, "agent_id"),
|
|
configValue(snapshot, "agent_name"),
|
|
profile?.agentId,
|
|
profile?.agentDisplayName,
|
|
].filter((value): value is string => !!value),
|
|
);
|
|
const optionSources = [
|
|
snapshot?.config_options,
|
|
profile?.configOptions,
|
|
(session.metadata?.runtime_config as Record<string, unknown> | undefined)?.config_options,
|
|
(session.metadata?.runtime_config_overrides as Record<string, unknown> | undefined)
|
|
?.config_options,
|
|
];
|
|
return optionSources.some((source) => {
|
|
const value = configValue(source, AGENT_CONFIG_KEY);
|
|
return value === LEGACY_AGENT_CONFIG_VALUE || (!!value && identityValues.has(value));
|
|
});
|
|
}
|
|
|
|
// profileRequiredConfigKeys returns keys the current agent profile itself
|
|
// declares (snapshot + matched profile). These stay required even when the
|
|
// running agent has not advertised them, because the profile chose them.
|
|
function profileRequiredConfigKeys(session: TaskSession, agents: Agent[]): Set<string> {
|
|
const keys = new Set(configValueKeys(session.agent_profile_snapshot?.config_options));
|
|
for (const agent of agents) {
|
|
const profile = agent.profiles.find((item) => item.id === session.agent_profile_id);
|
|
for (const key of Object.keys(profile?.configOptions ?? {})) keys.add(key);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
// persistedRuntimeConfigKeys returns keys recorded only in the session's
|
|
// persisted runtime metadata. A prior agent type may have written keys the
|
|
// current agent never advertises; these must not block the selector.
|
|
function persistedRuntimeConfigKeys(session: TaskSession): Set<string> {
|
|
const keys = new Set<string>();
|
|
for (const value of [
|
|
session.metadata?.runtime_config,
|
|
session.metadata?.runtime_config_overrides,
|
|
]) {
|
|
if (!value || typeof value !== "object") continue;
|
|
for (const key of configValueKeys((value as Record<string, unknown>).config_options)) {
|
|
keys.add(key);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
export function requiredConfigKeys(session: TaskSession | null, agents: Agent[]): string[] {
|
|
if (!session) return [];
|
|
const keys = profileRequiredConfigKeys(session, agents);
|
|
for (const key of persistedRuntimeConfigKeys(session)) keys.add(key);
|
|
return [...keys];
|
|
}
|
|
|
|
export function hasCompleteDynamicConfig(
|
|
session: TaskSession | null,
|
|
sessionModelsData: SessionModelsEntry | undefined,
|
|
agents: Agent[],
|
|
): boolean {
|
|
const required = requiredConfigKeys(session, agents);
|
|
if (required.length === 0) return true;
|
|
if (!sessionModelsData) return false;
|
|
const available = new Set(sessionModelsData.configOptions.map((option) => option.id));
|
|
// Flat-model-list agents (e.g. claude-opus) expose their models via the ACP
|
|
// top-level `models` list and switch with session/set_model rather than a
|
|
// SessionConfigOption(category="model"). For those the persisted runtime
|
|
// config still records a "model" key, but it will never appear in
|
|
// configOptions. Treat that key as satisfied when the session has a flat model
|
|
// list — the selector renders fine from it.
|
|
const hasFlatModelList = !!sessionModelsData.models.length;
|
|
const catalogSettled = sessionModelsData.configOptionsSettled === true;
|
|
const hasLegacyAgentConfig = catalogSettled && isLegacyAgentConfig(session, agents);
|
|
// A settled catalog is authoritative for which non-agent config keys apply
|
|
// to the selected model.
|
|
const isUnadvertisedSettledKey = (key: string): boolean =>
|
|
key !== AGENT_CONFIG_KEY && catalogSettled && !available.has(key);
|
|
return required.every(
|
|
(key) =>
|
|
available.has(key) ||
|
|
(key === AGENT_CONFIG_KEY && hasLegacyAgentConfig) ||
|
|
(key === MODEL_CONFIG_KEY && hasFlatModelList) ||
|
|
isUnadvertisedSettledKey(key),
|
|
);
|
|
}
|
|
|
|
function resolveSessionState(
|
|
sessionId: string | null,
|
|
taskSessions: Record<string, TaskSession>,
|
|
activeModels: Record<string, string>,
|
|
sessionModelsData: SessionModelsEntry | undefined,
|
|
) {
|
|
if (!sessionId) {
|
|
return { session: null, activeModel: null, sessionModelsData: undefined };
|
|
}
|
|
return {
|
|
session: taskSessions[sessionId] ?? null,
|
|
activeModel: activeModels[sessionId] || null,
|
|
sessionModelsData,
|
|
};
|
|
}
|
|
|
|
function resolveSnapshotModel(snapshot: unknown): string | null {
|
|
if (!snapshot || typeof snapshot !== "object") return null;
|
|
const model = (snapshot as Record<string, unknown>).model;
|
|
return typeof model === "string" && model ? model : null;
|
|
}
|
|
|
|
function resolveStaticModels(
|
|
agents: Agent[],
|
|
profileId: string | null | undefined,
|
|
availableAgents: AvailableAgent[],
|
|
): ModelSelectorOption[] {
|
|
if (!profileId) return [];
|
|
for (const agent of agents) {
|
|
const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId);
|
|
if (!profile) continue;
|
|
const available = availableAgents.find((a: AvailableAgent) => a.name === agent.name);
|
|
const models = available?.model_config?.available_models ?? [];
|
|
return models.map((m) => ({
|
|
...m,
|
|
description: m.id !== m.name ? m.id : undefined,
|
|
}));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function sessionModelsToOptions(models: SessionModelEntry[]): ModelSelectorOption[] {
|
|
return models.map((m) => ({
|
|
id: m.modelId,
|
|
name: m.name,
|
|
description: m.description,
|
|
usageMultiplier: m.usageMultiplier,
|
|
}));
|
|
}
|
|
|
|
// annotateFallbackOptions marks the fallback model so the user sees the
|
|
// session is not on the configured start model. It returns NEW option objects
|
|
// (never mutating the input or shared store state) and reuses the same
|
|
// translated suffix as the trigger label so the option and trigger cannot
|
|
// disagree. Returns the same array when no fallback note is active.
|
|
export function annotateFallbackOptions(
|
|
options: ModelSelectorOption[],
|
|
fallbackModel: string | undefined,
|
|
): ModelSelectorOption[] {
|
|
if (!fallbackModel) return options;
|
|
const suffix = t("settings:modelFallbackSuffix");
|
|
return options.map((option) =>
|
|
option.id === fallbackModel && !option.name.endsWith(suffix)
|
|
? { ...option, name: `${option.name} ${suffix}` }
|
|
: option,
|
|
);
|
|
}
|
|
|
|
export function buildModelOptions(
|
|
availableModels: ModelSelectorOption[],
|
|
currentModel: string | null,
|
|
): ModelSelectorOption[] {
|
|
const options = [...availableModels];
|
|
if (currentModel && !options.some((m) => m.id === currentModel)) {
|
|
// The configured/active model is no longer advertised ("gone"). Keep it
|
|
// visible but greyed out so the user is asked to pick a replacement —
|
|
// never silently drop it.
|
|
options.unshift({
|
|
id: currentModel,
|
|
name: currentModel,
|
|
disabled: true,
|
|
disabledReason: t("settings:startModelUnavailable"),
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function resolveProfileModel(profileId: string | null | undefined, agents: Agent[]): string | null {
|
|
if (!profileId) return null;
|
|
for (const agent of agents) {
|
|
const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId);
|
|
if (profile?.model) return profile.model;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCurrentModel(
|
|
activeModel: string | null,
|
|
acpCurrentModel: string | null,
|
|
snapshotModel: string | null,
|
|
profileModel: string | null,
|
|
): string | null {
|
|
return activeModel || acpCurrentModel || snapshotModel || profileModel;
|
|
}
|
|
|
|
function updateConfigOptionValue(
|
|
options: ConfigOptionEntry[],
|
|
configId: string,
|
|
value: string,
|
|
): ConfigOptionEntry[] {
|
|
return options.map((option) =>
|
|
option.id === configId ? { ...option, currentValue: value } : option,
|
|
);
|
|
}
|
|
|
|
function nextCurrentModelId(
|
|
data: { currentModelId: string; configOptions: ConfigOptionEntry[] },
|
|
configId: string,
|
|
value: string,
|
|
): string {
|
|
const option = data.configOptions.find((item) => item.id === configId);
|
|
if (option && isModelConfigOption(option)) return value;
|
|
return data.currentModelId;
|
|
}
|
|
|
|
function resolveAvailableModels({
|
|
modelConfig,
|
|
usingAcpModels,
|
|
sessionModels,
|
|
settingsAgents,
|
|
profileId,
|
|
availableAgents,
|
|
}: {
|
|
modelConfig: SelectConfigOption | undefined;
|
|
usingAcpModels: boolean;
|
|
sessionModels: SessionModelEntry[];
|
|
settingsAgents: Agent[];
|
|
profileId: string | null | undefined;
|
|
availableAgents: AvailableAgent[];
|
|
}): ModelSelectorOption[] {
|
|
if (modelConfig) return configOptionToModelOptions(modelConfig);
|
|
if (usingAcpModels) return sessionModelsToOptions(sessionModels);
|
|
return resolveStaticModels(settingsAgents, profileId, availableAgents);
|
|
}
|
|
|
|
function describeError(err: unknown): string {
|
|
return err instanceof Error ? err.message : t("task:unknownError2");
|
|
}
|
|
|
|
/** Builds model/config change handlers with optimistic update + error toast + revert. */
|
|
function useModelChangeHandlers(
|
|
configOptions: SelectConfigOption[],
|
|
sessionModelsData: SessionModelsEntry | undefined,
|
|
) {
|
|
const { t } = useTranslation();
|
|
const activeModels = useAppStore((state) => state.activeModel.bySessionId);
|
|
const setActiveModel = useAppStore((state) => state.setActiveModel);
|
|
const setSessionModels = useAppStore((state) => state.setSessionModels);
|
|
const { toast } = useToast();
|
|
// Per-session monotonic request id so a stale failure doesn't clobber a
|
|
// newer successful selection (rapid A -> B -> C where B fails).
|
|
const latestReqId = useRef<Record<string, number>>({});
|
|
|
|
const updateLocalConfig = useCallback(
|
|
(sid: string, configId: string, value: string) => {
|
|
if (!sessionModelsData) return;
|
|
// A manual model change ends the fallback story — drop the note so the
|
|
// picker stops labelling the model as a fallback.
|
|
const fallbackModel =
|
|
configId === sessionModelsData.configOptions.find(isModelConfigOption)?.id
|
|
? undefined
|
|
: sessionModelsData.fallbackModel;
|
|
setSessionModels(sid, {
|
|
...sessionModelsData,
|
|
fallbackModel,
|
|
currentModelId: nextCurrentModelId(sessionModelsData, configId, value),
|
|
configOptions: updateConfigOptionValue(sessionModelsData.configOptions, configId, value),
|
|
});
|
|
},
|
|
[sessionModelsData, setSessionModels],
|
|
);
|
|
|
|
const onFail = useCallback(
|
|
(
|
|
sid: string,
|
|
reqId: number,
|
|
previousActive: string,
|
|
previousModels: SessionModelsEntry | undefined,
|
|
) =>
|
|
(err: unknown) => {
|
|
if (latestReqId.current[sid] !== reqId) return;
|
|
console.error("[ModelSelector] model change failed:", err);
|
|
setActiveModel(sid, previousActive);
|
|
if (previousModels) setSessionModels(sid, previousModels);
|
|
toast({
|
|
title: t("task:failedToChangeModel"),
|
|
description: describeError(err),
|
|
variant: "error",
|
|
});
|
|
},
|
|
[setActiveModel, setSessionModels, toast],
|
|
);
|
|
|
|
const nextReqId = useCallback((sid: string) => {
|
|
const id = (latestReqId.current[sid] ?? 0) + 1;
|
|
latestReqId.current[sid] = id;
|
|
return id;
|
|
}, []);
|
|
|
|
const handleModelChange = useCallback(
|
|
(sid: string, modelId: string) => {
|
|
const reqId = nextReqId(sid);
|
|
const fail = onFail(sid, reqId, activeModels[sid] ?? "", sessionModelsData);
|
|
setActiveModel(sid, modelId);
|
|
const modelConfig = configOptions.find(isModelConfigOption);
|
|
if (modelConfig) {
|
|
updateLocalConfig(sid, modelConfig.id, modelId);
|
|
setSessionConfigOption(sid, modelConfig.id, modelId).catch(fail);
|
|
return;
|
|
}
|
|
// Non-config path: the next models_updated event would preserve the
|
|
// fallback note (see session-models handler) — clear it locally now
|
|
// that the user picked a model explicitly.
|
|
if (sessionModelsData) {
|
|
setSessionModels(sid, { ...sessionModelsData, fallbackModel: undefined });
|
|
}
|
|
setSessionModel(sid, modelId).catch(fail);
|
|
},
|
|
[
|
|
activeModels,
|
|
configOptions,
|
|
nextReqId,
|
|
onFail,
|
|
sessionModelsData,
|
|
setActiveModel,
|
|
updateLocalConfig,
|
|
],
|
|
);
|
|
|
|
const handleConfigChange = useCallback(
|
|
(sid: string, configId: string, value: string) => {
|
|
const reqId = nextReqId(sid);
|
|
const fail = onFail(sid, reqId, activeModels[sid] ?? "", sessionModelsData);
|
|
updateLocalConfig(sid, configId, value);
|
|
setSessionConfigOption(sid, configId, value).catch(fail);
|
|
},
|
|
[activeModels, nextReqId, onFail, sessionModelsData, updateLocalConfig],
|
|
);
|
|
|
|
return { handleModelChange, handleConfigChange };
|
|
}
|
|
|
|
// resolveModelSelectorInputs derives the model list, config options and
|
|
// current model from store state, independent of the hook lifecycle.
|
|
function resolveModelSelectorInputs({
|
|
session,
|
|
sessionModelsData,
|
|
activeModel,
|
|
settingsAgents,
|
|
availableAgents,
|
|
profileModel,
|
|
}: {
|
|
session: TaskSession | null;
|
|
sessionModelsData: SessionModelsEntry | undefined;
|
|
activeModel: string | null;
|
|
settingsAgents: Agent[];
|
|
availableAgents: AvailableAgent[];
|
|
profileModel: string | null;
|
|
}) {
|
|
const usingAcpModels = !!sessionModelsData?.models?.length;
|
|
const configOptions = usableConfigOptions(sessionModelsData?.configOptions);
|
|
const modelConfig = configOptions.find(isModelConfigOption);
|
|
const availableModels = resolveAvailableModels({
|
|
modelConfig,
|
|
usingAcpModels,
|
|
sessionModels: sessionModelsData?.models ?? [],
|
|
settingsAgents,
|
|
profileId: session?.agent_profile_id,
|
|
availableAgents,
|
|
});
|
|
const currentModel = resolveCurrentModel(
|
|
activeModel,
|
|
sessionModelsData?.currentModelId || null,
|
|
resolveSnapshotModel(session?.agent_profile_snapshot),
|
|
profileModel,
|
|
);
|
|
return { configOptions, currentModel, availableModels };
|
|
}
|
|
|
|
/** Resolves available models, config options and current model from store state. */
|
|
function useModelSelectorState(sessionId: string | null) {
|
|
useSettingsData(true);
|
|
|
|
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
|
const taskSessions = useAppStore((state) => state.taskSessions.items);
|
|
const activeModels = useAppStore((state) => state.activeModel.bySessionId);
|
|
const { items: availableAgents } = useAvailableAgents();
|
|
const selectedSessionModels = useAppStore((state) =>
|
|
sessionId ? state.sessionModels.bySessionId[sessionId] : undefined,
|
|
);
|
|
const { session, activeModel, sessionModelsData } = resolveSessionState(
|
|
sessionId,
|
|
taskSessions,
|
|
activeModels,
|
|
selectedSessionModels,
|
|
);
|
|
const profileModel = useMemo(
|
|
() => resolveProfileModel(session?.agent_profile_id, settingsAgents as Agent[]),
|
|
[session?.agent_profile_id, settingsAgents],
|
|
);
|
|
|
|
const { configOptions, currentModel, availableModels } = resolveModelSelectorInputs({
|
|
session,
|
|
sessionModelsData,
|
|
activeModel,
|
|
settingsAgents: settingsAgents as Agent[],
|
|
availableAgents,
|
|
profileModel,
|
|
});
|
|
const modelOptions = useMemo(
|
|
() =>
|
|
annotateFallbackOptions(
|
|
buildModelOptions(availableModels, currentModel),
|
|
sessionModelsData?.fallbackModel,
|
|
),
|
|
[availableModels, currentModel, sessionModelsData?.fallbackModel],
|
|
);
|
|
|
|
const { handleModelChange, handleConfigChange } = useModelChangeHandlers(
|
|
configOptions,
|
|
sessionModelsData,
|
|
);
|
|
|
|
return {
|
|
currentModel,
|
|
modelOptions,
|
|
configOptions,
|
|
configBaseline: sessionModelsData?.configBaseline,
|
|
fallbackModel: sessionModelsData?.fallbackModel ?? null,
|
|
configHydrated: hasCompleteDynamicConfig(session, sessionModelsData, settingsAgents as Agent[]),
|
|
requiredKeys: requiredConfigKeys(session, settingsAgents as Agent[]),
|
|
rawConfigOptionIds: (sessionModelsData?.configOptions ?? []).map((o) => o.id),
|
|
handleModelChange,
|
|
handleConfigChange,
|
|
};
|
|
}
|
|
|
|
export const ModelSelector = memo(function ModelSelector({
|
|
sessionId,
|
|
triggerClassName,
|
|
showAgentIcon = false,
|
|
}: ModelSelectorProps) {
|
|
const { t } = useTranslation();
|
|
const {
|
|
currentModel,
|
|
modelOptions,
|
|
configOptions,
|
|
configBaseline,
|
|
fallbackModel,
|
|
configHydrated,
|
|
requiredKeys,
|
|
rawConfigOptionIds,
|
|
handleModelChange,
|
|
handleConfigChange,
|
|
} = useModelSelectorState(sessionId);
|
|
const agentName = useAppStore((state) =>
|
|
showAgentIcon
|
|
? resolveModelSelectorAgentName(
|
|
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
|
|
state.agentProfiles.items,
|
|
)
|
|
: null,
|
|
);
|
|
const providerIcon = useMemo(
|
|
() =>
|
|
agentName ? (
|
|
<span aria-hidden="true" className="flex shrink-0" data-testid="model-provider-icon">
|
|
<AgentLogo agentName={agentName} size={14} className="size-3.5 shrink-0" />
|
|
</span>
|
|
) : undefined,
|
|
[agentName],
|
|
);
|
|
const modelConfig = configOptions.find(isModelConfigOption);
|
|
// Explicit "using fallback" signal: annotate the trigger so the user sees
|
|
// the session is not on the configured start model.
|
|
const currentModelSuffix = fallbackModel ? ` ${t("settings:modelFallbackSuffix")}` : undefined;
|
|
|
|
const onModelChange = useCallback(
|
|
(value: string) => {
|
|
if (!sessionId) return;
|
|
handleModelChange(sessionId, value);
|
|
},
|
|
[sessionId, handleModelChange],
|
|
);
|
|
|
|
const onConfigChange = useCallback(
|
|
(configId: string, value: string) => {
|
|
if (!sessionId) return;
|
|
handleConfigChange(sessionId, configId, value);
|
|
},
|
|
[sessionId, handleConfigChange],
|
|
);
|
|
|
|
if (isDebug()) {
|
|
debug("render", {
|
|
sessionId: sessionId ?? "",
|
|
configHydrated,
|
|
currentModel: currentModel ?? "",
|
|
hasModelConfig: !!modelConfig,
|
|
modelOptionsLen: modelOptions.length,
|
|
configOptionIds: configOptions.map((o) => o.id),
|
|
rawConfigOptionIds,
|
|
requiredKeys,
|
|
willHide: !sessionId || !configHydrated || (!currentModel && !modelConfig),
|
|
});
|
|
}
|
|
if (!sessionId || !configHydrated || (!currentModel && !modelConfig)) return null;
|
|
|
|
return (
|
|
<ModelConfigSelector
|
|
providerIcon={providerIcon}
|
|
modelOptions={modelOptions}
|
|
currentModel={currentModel}
|
|
configOptions={configOptions}
|
|
onModelChange={onModelChange}
|
|
onConfigChange={onConfigChange}
|
|
placeholder={t("common:model")}
|
|
ariaLabel={t("task:sessionModelSettings")}
|
|
variant="compact"
|
|
popoverSide="top"
|
|
triggerClassName={triggerClassName}
|
|
triggerSummary="changed"
|
|
configBaseline={configBaseline}
|
|
currentModelSuffix={currentModelSuffix}
|
|
/>
|
|
);
|
|
});
|