diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 8d05f20e..9308d798 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -443,6 +443,12 @@ export function ConfigTab({ workspaceId }: Props) { // save propagate-and-restart even when the user didn't touch the model. // Capturing the actual rendered value covers both. const [originalModel, setOriginalModel] = useState(""); + // core#2594: source of the loaded model value. "workspace_secrets" means + // the model is persisted in the DB; "unresolved" means the workspace is + // running with a runtime-env-derived model and the canvas has no stored + // value to display. Used to show a "derived from environment" hint and to + // block Save from appearing to wipe env-derived routing. + const [modelSource, setModelSource] = useState<"workspace_secrets" | "unresolved" | null>(null); const successTimerRef = useRef>(undefined); useEffect(() => { @@ -480,11 +486,12 @@ export function ConfigTab({ workspaceId }: Props) { const [wsRes, modelRes] = await Promise.all([ api.get<{ runtime?: string; tier?: number }>(`/workspaces/${workspaceId}`) .catch(() => ({} as { runtime?: string; tier?: number })), - api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`) - .catch(() => ({} as { model?: string })), + api.get<{ model?: string; source?: "workspace_secrets" | "unresolved" }>(`/workspaces/${workspaceId}/model`) + .catch(() => ({} as { model?: string; source?: "workspace_secrets" | "unresolved" })), ]); const wsMetadataRuntime = (wsRes.runtime || "").trim(); const wsMetadataModel = (modelRes.model || "").trim(); + setModelSource(modelRes.source ?? (wsMetadataModel ? "workspace_secrets" : "unresolved")); const wsMetadataTier: number | null = typeof wsRes.tier === "number" ? wsRes.tier : null; setProvider(""); @@ -915,6 +922,12 @@ export function ConfigTab({ workspaceId }: Props) { const providerDirty = provider !== originalProvider; const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty; + // core#2594: an env-resolved workspace has no stored model. Saving while + // the model dropdown is still empty can't "wipe" routing (handleSave skips + // an empty /model PUT), but it is confusing and can stall the user on other + // fields without surfacing why. Block save until a model is picked. + const modelUnresolved = modelSource === "unresolved" && !currentModelId; + const canSave = isDirty && !modelUnresolved; if (loading) { return
Loading config...
; @@ -1001,6 +1014,16 @@ export function ConfigTab({ workspaceId }: Props) { ))} + {/* core#2594: env-resolved workspaces have no stored model/provider. + Surface that clearly so users don't see empty required dropdowns + on a healthy workspace, and can't hit Save expecting it to stay + empty (which would otherwise look like it "wiped" routing). */} + {modelSource === "unresolved" && !currentModelId && ( +
+ Provider and model are derived from the workspace runtime environment.{" "} + They are not stored in config.yaml. Select a model below to persist them. +
+ )} {/* Shared Provider→Model selector. Same component renders in MissingKeysModal (deploy onboarding) so the dropdown UX is identical across all three surfaces. Provider field maps @@ -1311,7 +1334,7 @@ export function ConfigTab({ workspaceId }: Props) {