FN-8666: add credential instance pickers

Add credential-instance selection across dashboard model configuration surfaces.

- Expose provider credential instances in custom model dropdowns and task, workflow, and settings forms.
- Persist per-role credential-instance overrides through dashboard APIs and effective model resolution.
- Document credential-instance precedence and cover dropdown/settings behavior, including task state synchronization.

Files changed:
 .changeset/fn-8666-credential-instance-picker.md   |   7 ++
 docs/settings-reference.md                         |   2 +
 docs/workflow-steps.md                             |   2 +-
 packages/dashboard/app/api.ts                      |   1 +
 packages/dashboard/app/api/models-usage.ts         |  27 ++++-
 packages/dashboard/app/api/tasks.ts                |   8 ++
 .../app/components/CustomModelDropdown.css         |  12 ++-
 .../app/components/CustomModelDropdown.tsx         |  71 ++++++++++++-
 .../dashboard/app/components/InlineCreateCard.tsx  |  23 +++-
 packages/dashboard/app/components/ListView.tsx     |  29 +++++-
 .../app/components/ModelSelectionModal.tsx         |  25 ++++-
 .../dashboard/app/components/ModelSelectorTab.tsx  |  29 +++++-
 packages/dashboard/app/components/NewTaskModal.tsx |  23 +++-
 .../dashboard/app/components/QuickEntryBox.tsx     |  28 +++++
 .../dashboard/app/components/SettingsModal.tsx     |   2 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  32 +++++-
 packages/dashboard/app/components/TaskForm.tsx     |  18 ++++
 .../app/components/WorkflowNodeEditor.tsx          |  13 ++-
 .../app/components/WorkflowSettingsPanel.tsx       |  38 ++++++-
 ...ustomModelDropdown.credential-instance.test.tsx | 111 ++++++++++++++++++++
 .../__tests__/WorkflowSettingsPanel.test.tsx       |   9 +-
 .../app/components/effective-model-resolution.ts   |   8 +-
 .../settings/sections/GlobalModelsSection.tsx      |  31 +++++-
 .../settings/sections/ProjectModelsSection.tsx     | 116 ++++++++++++++++-----
 .../ProjectModelsSection.chatDefault.test.tsx      |  33 +++++-
 packages/dashboard/app/hooks/useFavorites.ts       |   6 +-
 packages/dashboard/app/hooks/useModelsCache.ts     |   5 +-
 .../src/routes/register-task-workflow-routes.ts    |  17 ++-
 28 files changed, 655 insertions(+), 71 deletions(-)

Fusion-Task-Id: FN-8666

Fusion-Task-Lineage: 6c3711b3-68b8-47a0-ac36-4b64846adf39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 04:03:26 -07:00
parent 4f09758dc8
commit 0e8f6769ba
28 changed files with 655 additions and 71 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators select configured credential instances from model pickers.
category: feature
dev: The picker displays a control only for providers with multiple configured instances.

View File

@@ -1808,6 +1808,8 @@ All three lanes (planning / executor / reviewer) follow the same precedence:
5. Project `defaultProviderOverride` / `defaultModelIdOverride` 5. Project `defaultProviderOverride` / `defaultModelIdOverride`
6. Global `defaultProvider` / `defaultModelId` → automatic resolution 6. Global `defaultProvider` / `defaultModelId` → automatic resolution
A selected credential instance is part of the winning provider/model selection. The model picker shows a **Credential instance** control only when `/api/models` advertises two or more configured instances for that provider. Selecting **Default** removes the instance override entirely; providers with zero or one instance keep the unchanged picker UI.
## Mock provider (test mode) ## Mock provider (test mode)
Set `defaultProvider: "mock"` at any tier in that hierarchy (or the per-task lane override) to force planning, executor, reviewer/validator, mission validation, merger, and heartbeat sessions onto the deterministic zero-network mock runtime. Set `defaultProvider: "mock"` at any tier in that hierarchy (or the per-task lane override) to force planning, executor, reviewer/validator, mission validation, merger, and heartbeat sessions onto the deterministic zero-network mock runtime.

View File

@@ -562,7 +562,7 @@ A prompt-mode gate node can set its own model with:
- `modelId` - `modelId`
- `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`) - `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`)
If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned. If a node also sets `config.credentialInstanceId`, execution resolves that configured instance for the node's provider/model pair; clearing it uses the provider default credential. If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned.
## Default-On Behavior for New Tasks ## Default-On Behavior for New Tasks

View File

@@ -6,6 +6,7 @@
*/ */
export * from "./api/legacy"; export * from "./api/legacy";
export * from "./api/provider-status"; export * from "./api/provider-status";
export * from "./api/models-usage";
export * from "./api/chat"; export * from "./api/chat";
export * from "./api-node"; export * from "./api-node";
export * from "./api/report"; export * from "./api/report";

View File

@@ -14,22 +14,45 @@ export interface ModelInfo {
name: string; name: string;
reasoning: boolean; reasoning: boolean;
contextWindow: number; contextWindow: number;
/** Provider-wide public instance metadata, attached by fetchModels for picker consumers. */
credentialInstances?: ProviderCredentialInstanceSummary[];
} }
/** Response from the models endpoint */ /** Response from the models endpoint */
/** Public metadata for a configured provider credential; it never contains credential material. */
export interface ProviderCredentialInstanceSummary {
id: string;
isDefault: boolean;
unavailableModelIds?: string[];
}
export interface ModelsResponse { export interface ModelsResponse {
models: ModelInfo[]; models: ModelInfo[];
favoriteProviders: string[]; favoriteProviders: string[];
favoriteModels: string[]; favoriteModels: string[];
defaultProvider?: string; defaultProvider?: string;
/** Configured credential instances keyed by provider, omitted by older servers. */
providerInstances?: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
defaultModelId?: string; defaultModelId?: string;
resolvedPlanningProvider?: string; resolvedPlanningProvider?: string;
resolvedPlanningModelId?: string; resolvedPlanningModelId?: string;
} }
/** Fetch available AI models from the model registry along with favoriteProviders */ /** Fetch available AI models from the model registry along with favoriteProviders */
export function fetchModels(): Promise<ModelsResponse> { export async function fetchModels(): Promise<ModelsResponse> {
return api<ModelsResponse>("/models"); const response = await api<ModelsResponse>("/models");
/*
FNXC:ModelDropdown 2026-08-01-09:49:
Every existing model picker already receives the catalog returned by this client. Attach public provider-instance summaries to those rows so callers that only retain `models` still receive the optional picker availability without a parallel endpoint or a second dropdown implementation.
*/
return {
...response,
models: (response.models ?? []).map((model) => ({
...model,
credentialInstances: response.providerInstances?.[model.provider]?.instances,
})),
};
} }
// --- Usage API --- // --- Usage API ---

View File

@@ -323,12 +323,16 @@ export function updateTask(
status?: null; status?: null;
modelProvider?: string | null; modelProvider?: string | null;
modelId?: string | null; modelId?: string | null;
credentialInstanceId?: string | null;
validatorModelProvider?: string | null; validatorModelProvider?: string | null;
validatorModelId?: string | null; validatorModelId?: string | null;
validatorCredentialInstanceId?: string | null;
planningModelProvider?: string | null; planningModelProvider?: string | null;
planningModelId?: string | null; planningModelId?: string | null;
planningCredentialInstanceId?: string | null;
mergerModelProvider?: string | null; mergerModelProvider?: string | null;
mergerModelId?: string | null; mergerModelId?: string | null;
mergerCredentialInstanceId?: string | null;
thinkingLevel?: string | null; thinkingLevel?: string | null;
validatorThinkingLevel?: string | null; validatorThinkingLevel?: string | null;
planningThinkingLevel?: string | null; planningThinkingLevel?: string | null;
@@ -382,6 +386,8 @@ export function batchUpdateTaskModels(
nodeId?: string | null, nodeId?: string | null,
thinkingLevel?: string | null, thinkingLevel?: string | null,
projectId?: string, projectId?: string,
credentialInstanceId?: string | null,
validatorCredentialInstanceId?: string | null,
): Promise<{ updated: Task[]; count: number }> { ): Promise<{ updated: Task[]; count: number }> {
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), { return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
method: "POST", method: "POST",
@@ -395,6 +401,8 @@ export function batchUpdateTaskModels(
planningModelId, planningModelId,
nodeId, nodeId,
...(thinkingLevel !== undefined ? { thinkingLevel } : {}), ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
...(credentialInstanceId !== undefined ? { credentialInstanceId } : {}),
...(validatorCredentialInstanceId !== undefined ? { validatorCredentialInstanceId } : {}),
}), }),
}); });
} }

View File

@@ -137,7 +137,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
border-bottom: var(--btn-border-width) solid var(--border); border-bottom: var(--btn-border-width) solid var(--border);
} }
.model-combobox-thinking { .model-combobox-thinking,
.model-combobox-instance {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -148,7 +149,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
border-bottom: var(--btn-border-width) solid var(--border); border-bottom: var(--btn-border-width) solid var(--border);
} }
.model-combobox-thinking-label { .model-combobox-thinking-label,
.model-combobox-instance-label {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
color: var(--text-muted); color: var(--text-muted);
@@ -156,7 +158,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
font-weight: 600; font-weight: 600;
} }
.model-combobox-thinking-select { .model-combobox-thinking-select,
.model-combobox-instance-select {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -210,7 +213,8 @@ The Thinking Level <select> renders inside the createPortal dropdown (.model-com
font-weight: 600; font-weight: 600;
} }
.model-combobox-thinking::after { .model-combobox-thinking::after,
.model-combobox-instance::after {
content: ""; content: "";
position: absolute; position: absolute;
right: calc(var(--space-md) + var(--space-sm)); right: calc(var(--space-md) + var(--space-sm));

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, useId } from "react"
import { THINKING_LEVELS } from "@fusion/core"; import { THINKING_LEVELS } from "@fusion/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ModelInfo } from "../api"; import type { ModelInfo, ProviderCredentialInstanceSummary } from "../api";
import { filterModels } from "../utils/modelFilter"; import { filterModels } from "../utils/modelFilter";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
@@ -39,6 +39,12 @@ export interface CustomModelDropdownProps {
defaultThinkingLevel?: string; defaultThinkingLevel?: string;
/** Explicitly render the optional inline thinking-level selector even without a change callback. */ /** Explicitly render the optional inline thinking-level selector even without a change callback. */
showThinkingLevel?: boolean; showThinkingLevel?: boolean;
/** Optional selected credential instance; empty or absent means provider default. */
credentialInstanceId?: string;
/** Called when the inline credential-instance selector changes; empty means clear the persisted override. */
onCredentialInstanceChange?: (instanceId: string) => void;
/** Available credential instances keyed by provider, as advertised by /api/models. */
credentialInstances?: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
} }
interface DropdownPosition { interface DropdownPosition {
@@ -101,6 +107,9 @@ export function CustomModelDropdown({
onThinkingLevelChange, onThinkingLevelChange,
defaultThinkingLevel, defaultThinkingLevel,
showThinkingLevel, showThinkingLevel,
credentialInstanceId,
onCredentialInstanceChange,
credentialInstances,
}: CustomModelDropdownProps) { }: CustomModelDropdownProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…"); const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…");
@@ -113,6 +122,7 @@ export function CustomModelDropdown({
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null); const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(loadCollapsedProviders); const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(loadCollapsedProviders);
const generatedThinkingId = useId(); const generatedThinkingId = useId();
const generatedInstanceId = useId();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
@@ -220,6 +230,38 @@ export function CustomModelDropdown({
return slashIdx === -1 ? null : value.slice(0, slashIdx); return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [hasNoChangeOption, noChangeValue, value]); }, [hasNoChangeOption, noChangeValue, value]);
const instanceOptions = useMemo(() => {
if (!currentProvider) return [];
const seen = new Set<string>();
const available = credentialInstances?.[currentProvider]?.instances
?? models.find((model) => model.provider === currentProvider)?.credentialInstances
?? [];
const deduplicated = available.filter((instance) => {
if (!instance.id || seen.has(instance.id)) return false;
seen.add(instance.id);
return true;
});
/*
FNXC:ModelDropdown 2026-08-01-09:13:
A stale persisted id is retained only after the availability threshold is met. It must not make a single-instance provider sprout this optional control.
*/
if (deduplicated.length >= 2 && credentialInstanceId && !seen.has(credentialInstanceId)) {
deduplicated.push({ id: credentialInstanceId, isDefault: false });
}
return deduplicated;
}, [credentialInstanceId, credentialInstances, currentProvider, models]);
const shouldShowCredentialInstance = instanceOptions.length >= 2;
const normalizedCredentialInstanceId = credentialInstanceId ?? "";
const credentialInstanceSelectId = id ? `${id}-credential-instance` : `${generatedInstanceId}-credential-instance`;
/*
FNXC:ModelDropdown 2026-08-01-09:13:
Credential-instance selection is visible exactly when a selected provider advertises at least two distinct instances. Rendering nothing otherwise keeps existing and older-server picker menus structurally unchanged; ownership callbacks determine persistence, never visibility.
FNXC:ModelDropdown 2026-08-01-09:13:
A stale selected instance remains an explicit option rather than being cleared during render. Only an operator change may remove a persisted override, preventing unrelated saves from silently changing runtime credentials.
*/
const specialOptions = useMemo(() => { const specialOptions = useMemo(() => {
const options: Array<{ type: "default" | "no-change"; value: string; label: string }> = []; const options: Array<{ type: "default" | "no-change"; value: string; label: string }> = [];
if (hasNoChangeOption) { if (hasNoChangeOption) {
@@ -637,6 +679,28 @@ export function CustomModelDropdown({
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })} {t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
</div> </div>
{shouldShowCredentialInstance && (
<div className="model-combobox-instance" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<label className="model-combobox-instance-label" htmlFor={credentialInstanceSelectId}>
{t("models.labels.credentialInstance", "Credential instance")}
</label>
<select
id={credentialInstanceSelectId}
className="thinking-level-select model-combobox-instance-select"
data-testid="custom-model-dropdown-credential-instance"
value={normalizedCredentialInstanceId}
onChange={(e) => onCredentialInstanceChange?.(e.target.value)}
disabled={disabled || !onCredentialInstanceChange}
aria-label={t("models.labels.credentialInstance", "Credential instance")}
>
<option value="">{t("models.credentialInstanceDefault", "Default")}</option>
{instanceOptions.map((instance) => (
<option key={instance.id} value={instance.id}>{instance.id}</option>
))}
</select>
</div>
)}
{shouldShowThinking && ( {shouldShowThinking && (
<div className="model-combobox-thinking" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}> <div className="model-combobox-thinking" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<label className="model-combobox-thinking-label" htmlFor={thinkingSelectId}> <label className="model-combobox-thinking-label" htmlFor={thinkingSelectId}>
@@ -834,6 +898,11 @@ export function CustomModelDropdown({
{thinkingBadgeLabel} {thinkingBadgeLabel}
</span> </span>
)} )}
{shouldShowCredentialInstance && normalizedCredentialInstanceId && (
<span className="model-badge model-badge-custom model-combobox-thinking-badge" data-testid="custom-model-dropdown-credential-instance-badge">
{normalizedCredentialInstanceId}
</span>
)}
<span className="model-combobox-trigger-arrow">▼</span> <span className="model-combobox-trigger-arrow">▼</span>
</button> </button>
</div> </div>

View File

@@ -118,12 +118,16 @@ export function InlineCreateCard({
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined); const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined); const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined); const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined); const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined); const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined); const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined); const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined); const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined); const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: quick-create board card carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */ /* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: quick-create board card carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */
const [thinkingLevel, setThinkingLevel] = useState<string>(""); const [thinkingLevel, setThinkingLevel] = useState<string>("");
@@ -500,12 +504,16 @@ export function InlineCreateCard({
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId, modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined, modelProvider: hasExecutorOverride ? executorProvider : undefined,
credentialInstanceId: hasExecutorOverride ? credentialInstanceId : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined, modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorCredentialInstanceId: hasValidatorOverride ? validatorCredentialInstanceId : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined, planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningCredentialInstanceId: hasPlanningOverride ? planningCredentialInstanceId : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined, planningModelId: hasPlanningOverride ? planningModelId : undefined,
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined, mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerCredentialInstanceId: hasMergerOverride ? mergerCredentialInstanceId : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined, mergerModelId: hasMergerOverride ? mergerModelId : undefined,
validatorThinkingLevel: validatorThinkingLevel !== "" ? validatorThinkingLevel as ThinkingLevel : undefined, validatorThinkingLevel: validatorThinkingLevel !== "" ? validatorThinkingLevel as ThinkingLevel : undefined,
planningThinkingLevel: planningThinkingLevel !== "" ? planningThinkingLevel as ThinkingLevel : undefined, planningThinkingLevel: planningThinkingLevel !== "" ? planningThinkingLevel as ThinkingLevel : undefined,
@@ -532,7 +540,7 @@ export function InlineCreateCard({
} }
await submitTask(input); await submitTask(input);
}, [description, submitting, selectedWorkflowId, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, hasMergerOverride, mergerProvider, mergerModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, mergerThinkingLevel, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]); }, [description, submitting, selectedWorkflowId, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, credentialInstanceId, executorModelId, hasValidatorOverride, validatorProvider, validatorCredentialInstanceId, validatorModelId, hasPlanningOverride, planningProvider, planningCredentialInstanceId, planningModelId, hasMergerOverride, mergerProvider, mergerCredentialInstanceId, mergerModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, mergerThinkingLevel, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]);
const handleDuplicateProceed = useCallback(async () => { const handleDuplicateProceed = useCallback(async () => {
const matches = duplicateMatches; const matches = duplicateMatches;
@@ -667,18 +675,21 @@ export function InlineCreateCard({
const next = parseModelSelection(value); const next = parseModelSelection(value);
setExecutorProvider(next.provider); setExecutorProvider(next.provider);
setExecutorModelId(next.modelId); setExecutorModelId(next.modelId);
setCredentialInstanceId(undefined);
}, []); }, []);
const handleValidatorChange = useCallback((value: string) => { const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setValidatorProvider(next.provider); setValidatorProvider(next.provider);
setValidatorModelId(next.modelId); setValidatorModelId(next.modelId);
setValidatorCredentialInstanceId(undefined);
}, []); }, []);
const handlePlanningModelChange = useCallback((value: string) => { const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setPlanningProvider(next.provider); setPlanningProvider(next.provider);
setPlanningModelId(next.modelId); setPlanningModelId(next.modelId);
setPlanningCredentialInstanceId(undefined);
}, []); }, []);
const handleThinkingLevelChange = useCallback((value: string) => { const handleThinkingLevelChange = useCallback((value: string) => {
@@ -1239,7 +1250,15 @@ export function InlineCreateCard({
onExecutorChange={handleExecutorChange} onExecutorChange={handleExecutorChange}
onValidatorChange={handleValidatorChange} onValidatorChange={handleValidatorChange}
onPlanningChange={handlePlanningModelChange} onPlanningChange={handlePlanningModelChange}
onMergerChange={(value) => { const next = parseModelSelection(value); setMergerProvider(next.provider); setMergerModelId(next.modelId); }} onMergerChange={(value) => { const next = parseModelSelection(value); setMergerProvider(next.provider); setMergerModelId(next.modelId); setMergerCredentialInstanceId(undefined); }}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={(value) => setCredentialInstanceId(value || undefined)}
validatorCredentialInstanceId={validatorCredentialInstanceId}
onValidatorCredentialInstanceChange={(value) => setValidatorCredentialInstanceId(value || undefined)}
planningCredentialInstanceId={planningCredentialInstanceId}
onPlanningCredentialInstanceChange={(value) => setPlanningCredentialInstanceId(value || undefined)}
mergerCredentialInstanceId={mergerCredentialInstanceId}
onMergerCredentialInstanceChange={(value) => setMergerCredentialInstanceId(value || undefined)}
mergerThinkingLevel={mergerThinkingLevel} mergerThinkingLevel={mergerThinkingLevel}
onMergerThinkingLevelChange={setMergerThinkingLevel} onMergerThinkingLevelChange={setMergerThinkingLevel}
validatorThinkingLevel={validatorThinkingLevel} validatorThinkingLevel={validatorThinkingLevel}

View File

@@ -606,7 +606,9 @@ export function ListView({
// Bulk edit state and handlers (declared before clearSelection so every clear path resets pending lane edits) // Bulk edit state and handlers (declared before clearSelection so every clear path resets pending lane edits)
const [executorModel, setExecutorModel] = useState<string>("__no_change__"); const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [credentialInstanceId, setCredentialInstanceId] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__"); const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string>("__no_change__");
const [bulkThinkingLevel, setBulkThinkingLevel] = useState<string>("__no_change__"); const [bulkThinkingLevel, setBulkThinkingLevel] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__"); const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
@@ -615,7 +617,9 @@ export function ListView({
if (prev) { if (prev) {
setSelectedTaskIds(new Set()); setSelectedTaskIds(new Set());
setExecutorModel("__no_change__"); setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__"); setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__"); setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__"); setNodeOverride("__no_change__");
} }
@@ -640,7 +644,9 @@ export function ListView({
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedTaskIds(new Set()); setSelectedTaskIds(new Set());
setExecutorModel("__no_change__"); setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__"); setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__"); setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__"); setNodeOverride("__no_change__");
}, []); }, []);
@@ -1779,6 +1785,8 @@ export function ListView({
validatorModelId?: string | null; validatorModelId?: string | null;
nodeId?: string | null; nodeId?: string | null;
thinkingLevel?: ThinkingLevel | null; thinkingLevel?: ThinkingLevel | null;
credentialInstanceId?: string | null;
validatorCredentialInstanceId?: string | null;
} = { taskIds }; } = { taskIds };
if (executorModel !== "__no_change__") { if (executorModel !== "__no_change__") {
@@ -1786,11 +1794,13 @@ export function ListView({
// "Use default" - clear override // "Use default" - clear override
payload.modelProvider = null; payload.modelProvider = null;
payload.modelId = null; payload.modelId = null;
payload.credentialInstanceId = null;
} else { } else {
const slashIdx = executorModel.indexOf("/"); const slashIdx = executorModel.indexOf("/");
if (slashIdx !== -1) { if (slashIdx !== -1) {
payload.modelProvider = executorModel.slice(0, slashIdx); payload.modelProvider = executorModel.slice(0, slashIdx);
payload.modelId = executorModel.slice(slashIdx + 1); payload.modelId = executorModel.slice(slashIdx + 1);
payload.credentialInstanceId = null;
} }
} }
} }
@@ -1800,15 +1810,20 @@ export function ListView({
// "Use default" - clear override // "Use default" - clear override
payload.validatorModelProvider = null; payload.validatorModelProvider = null;
payload.validatorModelId = null; payload.validatorModelId = null;
payload.validatorCredentialInstanceId = null;
} else { } else {
const slashIdx = validatorModel.indexOf("/"); const slashIdx = validatorModel.indexOf("/");
if (slashIdx !== -1) { if (slashIdx !== -1) {
payload.validatorModelProvider = validatorModel.slice(0, slashIdx); payload.validatorModelProvider = validatorModel.slice(0, slashIdx);
payload.validatorModelId = validatorModel.slice(slashIdx + 1); payload.validatorModelId = validatorModel.slice(slashIdx + 1);
payload.validatorCredentialInstanceId = null;
} }
} }
} }
if (credentialInstanceId !== "__no_change__") payload.credentialInstanceId = credentialInstanceId || null;
if (validatorCredentialInstanceId !== "__no_change__") payload.validatorCredentialInstanceId = validatorCredentialInstanceId || null;
if (nodeOverride !== "__no_change__") { if (nodeOverride !== "__no_change__") {
if (nodeOverride === "") { if (nodeOverride === "") {
payload.nodeId = null; payload.nodeId = null;
@@ -1840,6 +1855,8 @@ export function ListView({
payload.nodeId, payload.nodeId,
payload.thinkingLevel, payload.thinkingLevel,
projectId, projectId,
payload.credentialInstanceId,
payload.validatorCredentialInstanceId,
); );
if (onTasksUpdated) { if (onTasksUpdated) {
@@ -1851,7 +1868,9 @@ export function ListView({
// Reset state // Reset state
clearSelection(); clearSelection();
setExecutorModel("__no_change__"); setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__"); setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__"); setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__"); setNodeOverride("__no_change__");
} catch (err) { } catch (err) {
@@ -1859,7 +1878,7 @@ export function ListView({
} finally { } finally {
setIsApplying(false); setIsApplying(false);
} }
}, [addToast, bulkThinkingLevel, clearSelection, executorModel, isTaskArchivedColumn, nodeOverride, onTasksUpdated, projectId, selectedTaskIds, tasks, validatorModel]); }, [addToast, bulkThinkingLevel, clearSelection, credentialInstanceId, executorModel, isTaskArchivedColumn, nodeOverride, onTasksUpdated, projectId, selectedTaskIds, tasks, validatorCredentialInstanceId, validatorModel]);
const closeContextMenu = useCallback(() => { const closeContextMenu = useCallback(() => {
setContextMenuState(null); setContextMenuState(null);
@@ -2776,7 +2795,9 @@ export function ListView({
<CustomModelDropdown <CustomModelDropdown
models={availableModels} models={availableModels}
value={executorModel} value={executorModel}
onChange={setExecutorModel} onChange={(value) => { setCredentialInstanceId("__no_change__"); setExecutorModel(value); }}
credentialInstanceId={credentialInstanceId === "__no_change__" ? undefined : credentialInstanceId}
onCredentialInstanceChange={setCredentialInstanceId}
label={t("listView.executorModel", "Executor Model")} label={t("listView.executorModel", "Executor Model")}
noChangeValue="__no_change__" noChangeValue="__no_change__"
noChangeLabel={t("listView.noChange", "No change")} noChangeLabel={t("listView.noChange", "No change")}
@@ -2790,7 +2811,9 @@ export function ListView({
<CustomModelDropdown <CustomModelDropdown
models={availableModels} models={availableModels}
value={validatorModel} value={validatorModel}
onChange={setValidatorModel} onChange={(value) => { setValidatorCredentialInstanceId("__no_change__"); setValidatorModel(value); }}
credentialInstanceId={validatorCredentialInstanceId === "__no_change__" ? undefined : validatorCredentialInstanceId}
onCredentialInstanceChange={setValidatorCredentialInstanceId}
label={t("listView.reviewerModel", "Reviewer Model")} label={t("listView.reviewerModel", "Reviewer Model")}
noChangeValue="__no_change__" noChangeValue="__no_change__"
noChangeLabel={t("listView.noChange", "No change")} noChangeLabel={t("listView.noChange", "No change")}

View File

@@ -22,6 +22,15 @@ interface ModelSelectionModalProps {
onValidatorChange: (value: string) => void; onValidatorChange: (value: string) => void;
onPlanningChange?: (value: string) => void; onPlanningChange?: (value: string) => void;
onMergerChange?: (value: string) => void; onMergerChange?: (value: string) => void;
/** Optional persisted instance overrides for task model lanes. */
credentialInstanceId?: string;
validatorCredentialInstanceId?: string;
planningCredentialInstanceId?: string;
mergerCredentialInstanceId?: string;
onCredentialInstanceChange?: (value: string) => void;
onValidatorCredentialInstanceChange?: (value: string) => void;
onPlanningCredentialInstanceChange?: (value: string) => void;
onMergerCredentialInstanceChange?: (value: string) => void;
mergerThinkingLevel?: string; mergerThinkingLevel?: string;
onMergerThinkingLevelChange?: (value: string) => void; onMergerThinkingLevelChange?: (value: string) => void;
validatorThinkingLevel?: string; validatorThinkingLevel?: string;
@@ -77,6 +86,14 @@ export function ModelSelectionModal({
onValidatorChange, onValidatorChange,
onPlanningChange, onPlanningChange,
onMergerChange, onMergerChange,
credentialInstanceId = "",
validatorCredentialInstanceId = "",
planningCredentialInstanceId = "",
mergerCredentialInstanceId = "",
onCredentialInstanceChange,
onValidatorCredentialInstanceChange,
onPlanningCredentialInstanceChange,
onMergerCredentialInstanceChange,
mergerThinkingLevel = "", mergerThinkingLevel = "",
onMergerThinkingLevelChange, onMergerThinkingLevelChange,
validatorThinkingLevel = "", validatorThinkingLevel = "",
@@ -288,6 +305,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite} onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={planningThinkingLevel} thinkingLevel={planningThinkingLevel}
onThinkingLevelChange={onPlanningThinkingLevelChange} onThinkingLevelChange={onPlanningThinkingLevelChange}
credentialInstanceId={planningCredentialInstanceId}
onCredentialInstanceChange={onPlanningCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"} defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/> />
</div> </div>
@@ -318,6 +337,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite} onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={thinkingLevel} thinkingLevel={thinkingLevel}
onThinkingLevelChange={onThinkingLevelChange} onThinkingLevelChange={onThinkingLevelChange}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={onCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"} defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/> />
</div> </div>
@@ -326,7 +347,7 @@ export function ModelSelectionModal({
{onMergerChange ? <div className="task-detail-section"><div className="inline-create-model-row"> {onMergerChange ? <div className="task-detail-section"><div className="inline-create-model-row">
<label htmlFor="model-selection-merger" className="inline-create-model-label">{t("tasks.mergerModel", "Merger Model")}</label> <label htmlFor="model-selection-merger" className="inline-create-model-label">{t("tasks.mergerModel", "Merger Model")}</label>
<span className={`model-badge ${hasMergerOverride ? "model-badge-custom" : "model-badge-default"}`}>{getModelBadgeLabel(models, mergerValue, t)}</span> <span className={`model-badge ${hasMergerOverride ? "model-badge-custom" : "model-badge-default"}`}>{getModelBadgeLabel(models, mergerValue, t)}</span>
<CustomModelDropdown id="model-selection-merger" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={onMergerChange} models={models} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={mergerThinkingLevel} onThinkingLevelChange={onMergerThinkingLevelChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} /> <CustomModelDropdown id="model-selection-merger" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={onMergerChange} models={models} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={mergerThinkingLevel} onThinkingLevelChange={onMergerThinkingLevelChange} credentialInstanceId={mergerCredentialInstanceId} onCredentialInstanceChange={onMergerCredentialInstanceChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} />
</div></div> : null} </div></div> : null}
<div className="task-detail-section"> <div className="task-detail-section">
@@ -353,6 +374,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite} onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={validatorThinkingLevel} thinkingLevel={validatorThinkingLevel}
onThinkingLevelChange={onValidatorThinkingLevelChange} onThinkingLevelChange={onValidatorThinkingLevelChange}
credentialInstanceId={validatorCredentialInstanceId}
onCredentialInstanceChange={onValidatorCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"} defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/> />
</div> </div>

View File

@@ -28,6 +28,7 @@ interface ModelSelectorTabProps {
interface ModelSelection { interface ModelSelection {
provider?: string; provider?: string;
modelId?: string; modelId?: string;
credentialInstanceId?: string;
} }
function normalizeModelField(value: string | null | undefined): string | undefined { function normalizeModelField(value: string | null | undefined): string | undefined {
@@ -38,6 +39,7 @@ function getExecutorSelection(task: Task | TaskDetail): ModelSelection {
return { return {
provider: normalizeModelField(task.modelProvider), provider: normalizeModelField(task.modelProvider),
modelId: normalizeModelField(task.modelId), modelId: normalizeModelField(task.modelId),
credentialInstanceId: normalizeModelField(task.credentialInstanceId),
}; };
} }
@@ -45,6 +47,7 @@ function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
return { return {
provider: normalizeModelField(task.validatorModelProvider), provider: normalizeModelField(task.validatorModelProvider),
modelId: normalizeModelField(task.validatorModelId), modelId: normalizeModelField(task.validatorModelId),
credentialInstanceId: normalizeModelField(task.validatorCredentialInstanceId),
}; };
} }
@@ -52,6 +55,7 @@ function getPlanningSelection(task: Task | TaskDetail): ModelSelection {
return { return {
provider: normalizeModelField(task.planningModelProvider), provider: normalizeModelField(task.planningModelProvider),
modelId: normalizeModelField(task.planningModelId), modelId: normalizeModelField(task.planningModelId),
credentialInstanceId: normalizeModelField(task.planningCredentialInstanceId),
}; };
} }
@@ -59,6 +63,7 @@ function getMergerSelection(task: Task | TaskDetail): ModelSelection {
return { return {
provider: normalizeModelField(task.mergerModelProvider), provider: normalizeModelField(task.mergerModelProvider),
modelId: normalizeModelField(task.mergerModelId), modelId: normalizeModelField(task.mergerModelId),
credentialInstanceId: normalizeModelField(task.mergerCredentialInstanceId),
}; };
} }
@@ -109,7 +114,7 @@ function getDropdownValue(selection: ModelSelection): string {
} }
function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean { function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean {
return a.provider === b.provider && a.modelId === b.modelId; return a.provider === b.provider && a.modelId === b.modelId && a.credentialInstanceId === b.credentialInstanceId;
} }
function getSuccessToastMessage( function getSuccessToastMessage(
@@ -141,6 +146,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { const {
availableModels, availableModels,
providerInstances,
favoriteProviders, favoriteProviders,
favoriteModels, favoriteModels,
toggleFavoriteProvider, toggleFavoriteProvider,
@@ -213,7 +219,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
setSelectedMergerThinking(nextMergerThinking); setSelectedMergerThinking(nextMergerThinking);
setSavedMergerThinking(nextMergerThinking); setSavedMergerThinking(nextMergerThinking);
setSavingTarget(null); setSavingTarget(null);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.mergerModelProvider, task.mergerModelId, task.thinkingLevel, task.validatorThinkingLevel, task.planningThinkingLevel, task.mergerThinkingLevel]); }, [task.id, task.modelProvider, task.modelId, task.credentialInstanceId, task.validatorModelProvider, task.validatorModelId, task.validatorCredentialInstanceId, task.planningModelProvider, task.planningModelId, task.planningCredentialInstanceId, task.mergerModelProvider, task.mergerModelId, task.mergerCredentialInstanceId, task.thinkingLevel, task.validatorThinkingLevel, task.planningThinkingLevel, task.mergerThinkingLevel]);
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]); const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]); const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
@@ -241,30 +247,38 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
? { ? {
modelProvider: nextSelection.provider ?? null, modelProvider: nextSelection.provider ?? null,
modelId: nextSelection.modelId ?? null, modelId: nextSelection.modelId ?? null,
credentialInstanceId: nextSelection.credentialInstanceId ?? null,
} }
: target === "validator" : target === "validator"
? { ? {
validatorModelProvider: nextSelection.provider ?? null, validatorModelProvider: nextSelection.provider ?? null,
validatorModelId: nextSelection.modelId ?? null, validatorModelId: nextSelection.modelId ?? null,
validatorCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
} }
: target === "merger" : target === "merger"
? { ? {
mergerModelProvider: nextSelection.provider ?? null, mergerModelProvider: nextSelection.provider ?? null,
mergerModelId: nextSelection.modelId ?? null, mergerModelId: nextSelection.modelId ?? null,
mergerCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
} }
: { : {
planningModelProvider: nextSelection.provider ?? null, planningModelProvider: nextSelection.provider ?? null,
planningModelId: nextSelection.modelId ?? null, planningModelId: nextSelection.modelId ?? null,
planningCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
}) })
: { : {
modelProvider: (target === "executor" ? nextSelection : savedExecutor).provider ?? null, modelProvider: (target === "executor" ? nextSelection : savedExecutor).provider ?? null,
modelId: (target === "executor" ? nextSelection : savedExecutor).modelId ?? null, modelId: (target === "executor" ? nextSelection : savedExecutor).modelId ?? null,
credentialInstanceId: (target === "executor" ? nextSelection : savedExecutor).credentialInstanceId ?? null,
validatorModelProvider: (target === "validator" ? nextSelection : savedValidator).provider ?? null, validatorModelProvider: (target === "validator" ? nextSelection : savedValidator).provider ?? null,
validatorModelId: (target === "validator" ? nextSelection : savedValidator).modelId ?? null, validatorModelId: (target === "validator" ? nextSelection : savedValidator).modelId ?? null,
validatorCredentialInstanceId: (target === "validator" ? nextSelection : savedValidator).credentialInstanceId ?? null,
planningModelProvider: (target === "planning" ? nextSelection : savedPlanning).provider ?? null, planningModelProvider: (target === "planning" ? nextSelection : savedPlanning).provider ?? null,
planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null, planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null,
planningCredentialInstanceId: (target === "planning" ? nextSelection : savedPlanning).credentialInstanceId ?? null,
mergerModelProvider: (target === "merger" ? nextSelection : savedMerger).provider ?? null, mergerModelProvider: (target === "merger" ? nextSelection : savedMerger).provider ?? null,
mergerModelId: (target === "merger" ? nextSelection : savedMerger).modelId ?? null, mergerModelId: (target === "merger" ? nextSelection : savedMerger).modelId ?? null,
mergerCredentialInstanceId: (target === "merger" ? nextSelection : savedMerger).credentialInstanceId ?? null,
}; };
/* /*
@@ -599,6 +613,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={executorValue} value={executorValue}
onChange={handleExecutorChange} onChange={handleExecutorChange}
models={availableModels} models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedExecutor.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("executor", { ...selectedExecutor, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving} disabled={isSaving}
placeholder={t("models.placeholders.selectExecutor", "Select executor model…")} placeholder={t("models.placeholders.selectExecutor", "Select executor model…")}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
@@ -632,6 +649,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={validatorValue} value={validatorValue}
onChange={handleValidatorChange} onChange={handleValidatorChange}
models={availableModels} models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedValidator.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("validator", { ...selectedValidator, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving} disabled={isSaving}
placeholder={t("models.placeholders.selectReviewer", "Select reviewer model…")} placeholder={t("models.placeholders.selectReviewer", "Select reviewer model…")}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
@@ -665,6 +685,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={planningValue} value={planningValue}
onChange={handlePlanningChange} onChange={handlePlanningChange}
models={availableModels} models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedPlanning.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("planning", { ...selectedPlanning, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving} disabled={isSaving}
placeholder={t("models.placeholders.selectPlanning", "Select planning model…")} placeholder={t("models.placeholders.selectPlanning", "Select planning model…")}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
@@ -684,7 +707,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
<div className="model-selector-current"> <div className="model-selector-current">
{mergerUsingDefault ? <span className="model-badge model-badge-default">{t("models.states.usingDefault", "Using default")}{effectiveMerger.provider && effectiveMerger.modelId ? ` (${effectiveMerger.provider}/${effectiveMerger.modelId})` : ""}</span> : <span className="model-badge model-badge-custom">{savedMerger.provider && <ProviderIcon provider={savedMerger.provider} size="sm" />}{savedMerger.provider}/{savedMerger.modelId}</span>} {mergerUsingDefault ? <span className="model-badge model-badge-default">{t("models.states.usingDefault", "Using default")}{effectiveMerger.provider && effectiveMerger.modelId ? ` (${effectiveMerger.provider}/${effectiveMerger.modelId})` : ""}</span> : <span className="model-badge model-badge-custom">{savedMerger.provider && <ProviderIcon provider={savedMerger.provider} size="sm" />}{savedMerger.provider}/{savedMerger.modelId}</span>}
</div> </div>
<CustomModelDropdown id="mergerModel" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={handleMergerChange} models={availableModels} disabled={isSaving} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} thinkingLevel={selectedMergerThinking ?? ""} onThinkingLevelChange={handleMergerThinkingChange} defaultThinkingLevel={settings?.mergerThinkingLevel ?? "off"} /> <CustomModelDropdown id="mergerModel" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={handleMergerChange} models={availableModels} credentialInstances={providerInstances} credentialInstanceId={selectedMerger.credentialInstanceId} onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("merger", { ...selectedMerger, credentialInstanceId: credentialInstanceId || undefined })} disabled={isSaving} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} thinkingLevel={selectedMergerThinking ?? ""} onThinkingLevelChange={handleMergerThinkingChange} defaultThinkingLevel={settings?.mergerThinkingLevel ?? "off"} />
<small>{t("models.descriptions.merger", "The AI model used to merge this task.")}</small> <small>{t("models.descriptions.merger", "The AI model used to merge this task.")}</small>
</div> </div>

View File

@@ -358,8 +358,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null); const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const [executorModel, setExecutorModel] = useState(""); const [executorModel, setExecutorModel] = useState("");
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorModel, setValidatorModel] = useState(""); const [validatorModel, setValidatorModel] = useState("");
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningModel, setPlanningModel] = useState(""); const [planningModel, setPlanningModel] = useState("");
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [thinkingLevel, setThinkingLevel] = useState<string>(""); const [thinkingLevel, setThinkingLevel] = useState<string>("");
// FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means inherit from workflow. // FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means inherit from workflow.
const [plannerOversightLevel, setPlannerOversightLevel] = useState<string>(""); const [plannerOversightLevel, setPlannerOversightLevel] = useState<string>("");
@@ -575,8 +578,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setDescription(""); setDescription("");
setDependencies([]); setDependencies([]);
setExecutorModel(""); setExecutorModel("");
setCredentialInstanceId(undefined);
setValidatorModel(""); setValidatorModel("");
setValidatorCredentialInstanceId(undefined);
setPlanningModel(""); setPlanningModel("");
setPlanningCredentialInstanceId(undefined);
setThinkingLevel(""); setThinkingLevel("");
setPlannerOversightLevel(""); setPlannerOversightLevel("");
setSelectedPresetId(""); setSelectedPresetId("");
@@ -649,10 +655,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined, modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
...(credentialInstanceId ? { credentialInstanceId } : {}),
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined, validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined, validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
...(validatorCredentialInstanceId ? { validatorCredentialInstanceId } : {}),
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined, planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined, planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
...(planningCredentialInstanceId ? { planningCredentialInstanceId } : {}),
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined, thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined,
// FNXC:PlannerOversight 2026-07-04-00:00: omit when "Inherit from workflow" ("") is selected so the task falls back to the workflow's effective plannerOversightLevel. // FNXC:PlannerOversight 2026-07-04-00:00: omit when "Inherit from workflow" ("") is selected so the task falls back to the workflow's effective plannerOversightLevel.
...(plannerOversightLevel !== "" ? { plannerOversightLevel: plannerOversightLevel as "off" | "observe" | "steer" | "autonomous" } : {}), ...(plannerOversightLevel !== "" ? { plannerOversightLevel: plannerOversightLevel as "off" | "observe" | "steer" | "autonomous" } : {}),
@@ -700,7 +709,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
resetForm(); resetForm();
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success"); addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
onClose(); onClose();
}, [executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]); }, [executorModel, credentialInstanceId, validatorModel, validatorCredentialInstanceId, planningModel, planningCredentialInstanceId, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim(); const trimmedDesc = description.trim();
@@ -1033,9 +1042,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies={dependencies} dependencies={dependencies}
onDependenciesChange={setDependencies} onDependenciesChange={setDependencies}
executorModel={executorModel} executorModel={executorModel}
onExecutorModelChange={handleExecutorModelChange} onExecutorModelChange={(value, meta) => { setCredentialInstanceId(undefined); handleExecutorModelChange(value, meta); }}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceIdChange={(instanceId) => setCredentialInstanceId(instanceId || undefined)}
validatorModel={validatorModel} validatorModel={validatorModel}
onValidatorModelChange={handleValidatorModelChange} onValidatorModelChange={(value, meta) => { setValidatorCredentialInstanceId(undefined); handleValidatorModelChange(value, meta); }}
validatorCredentialInstanceId={validatorCredentialInstanceId}
onValidatorCredentialInstanceIdChange={(instanceId) => setValidatorCredentialInstanceId(instanceId || undefined)}
presetMode={presetMode} presetMode={presetMode}
onPresetModeChange={setPresetMode} onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId} selectedPresetId={selectedPresetId}
@@ -1055,7 +1068,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPlanningMode={onPlanningMode} onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown} onSubtaskBreakdown={onSubtaskBreakdown}
planningModel={planningModel} planningModel={planningModel}
onPlanningModelChange={setPlanningModel} onPlanningModelChange={(value) => { setPlanningCredentialInstanceId(undefined); setPlanningModel(value); }}
planningCredentialInstanceId={planningCredentialInstanceId}
onPlanningCredentialInstanceIdChange={(instanceId) => setPlanningCredentialInstanceId(instanceId || undefined)}
thinkingLevel={thinkingLevel} thinkingLevel={thinkingLevel}
plannerOversightLevel={plannerOversightLevel} plannerOversightLevel={plannerOversightLevel}
onPlannerOversightLevelChange={setPlannerOversightLevel} onPlannerOversightLevelChange={setPlannerOversightLevel}

View File

@@ -199,12 +199,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | "merger" | null>(null); const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | "merger" | null>(null);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined); const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined); const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined); const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined); const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined); const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined); const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined); const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined); const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */ /* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */
const [thinkingLevel, setThinkingLevel] = useState<string>(""); const [thinkingLevel, setThinkingLevel] = useState<string>("");
const [validatorThinkingLevel, setValidatorThinkingLevel] = useState<string>(""); const [validatorThinkingLevel, setValidatorThinkingLevel] = useState<string>("");
@@ -681,12 +685,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
setPriorityPickerPosition(null); setPriorityPickerPosition(null);
setExecutorProvider(undefined); setExecutorProvider(undefined);
setExecutorModelId(undefined); setExecutorModelId(undefined);
setCredentialInstanceId(undefined);
setValidatorProvider(undefined); setValidatorProvider(undefined);
setValidatorModelId(undefined); setValidatorModelId(undefined);
setValidatorCredentialInstanceId(undefined);
setPlanningProvider(undefined); setPlanningProvider(undefined);
setPlanningModelId(undefined); setPlanningModelId(undefined);
setPlanningCredentialInstanceId(undefined);
setMergerProvider(undefined); setMergerProvider(undefined);
setMergerModelId(undefined); setMergerModelId(undefined);
setMergerCredentialInstanceId(undefined);
setThinkingLevel(""); setThinkingLevel("");
setValidatorThinkingLevel(""); setValidatorThinkingLevel("");
setPlanningThinkingLevel(""); setPlanningThinkingLevel("");
@@ -817,12 +825,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
modelPresetId: selectedPresetId, modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined, modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined, modelId: hasExecutorOverride ? executorModelId : undefined,
...(hasExecutorOverride && credentialInstanceId ? { credentialInstanceId } : {}),
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
...(hasValidatorOverride && validatorCredentialInstanceId ? { validatorCredentialInstanceId } : {}),
planningModelProvider: hasPlanningOverride ? planningProvider : undefined, planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined, planningModelId: hasPlanningOverride ? planningModelId : undefined,
...(hasPlanningOverride && planningCredentialInstanceId ? { planningCredentialInstanceId } : {}),
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined, mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined, mergerModelId: hasMergerOverride ? mergerModelId : undefined,
...(hasMergerOverride && mergerCredentialInstanceId ? { mergerCredentialInstanceId } : {}),
validatorThinkingLevel: validatorThinkingLevel !== "" ? (validatorThinkingLevel as ThinkingLevel) : undefined, validatorThinkingLevel: validatorThinkingLevel !== "" ? (validatorThinkingLevel as ThinkingLevel) : undefined,
planningThinkingLevel: planningThinkingLevel !== "" ? (planningThinkingLevel as ThinkingLevel) : undefined, planningThinkingLevel: planningThinkingLevel !== "" ? (planningThinkingLevel as ThinkingLevel) : undefined,
mergerThinkingLevel: mergerThinkingLevel !== "" ? (mergerThinkingLevel as ThinkingLevel) : undefined, mergerThinkingLevel: mergerThinkingLevel !== "" ? (mergerThinkingLevel as ThinkingLevel) : undefined,
@@ -901,12 +913,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
hasExecutorOverride, hasExecutorOverride,
executorProvider, executorProvider,
executorModelId, executorModelId,
credentialInstanceId,
hasValidatorOverride, hasValidatorOverride,
validatorProvider, validatorProvider,
validatorModelId, validatorModelId,
validatorCredentialInstanceId,
hasPlanningOverride, hasPlanningOverride,
planningProvider, planningProvider,
planningModelId, planningModelId,
planningCredentialInstanceId,
mergerCredentialInstanceId,
thinkingLevel, thinkingLevel,
enabledOptionalStepIds, enabledOptionalStepIds,
isFastMode, isFastMode,
@@ -1462,18 +1478,21 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const handlePlanningModelChange = useCallback((value: string) => { const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setPlanningCredentialInstanceId(undefined);
setPlanningProvider(next.provider); setPlanningProvider(next.provider);
setPlanningModelId(next.modelId); setPlanningModelId(next.modelId);
}, []); }, []);
const handleExecutorChange = useCallback((value: string) => { const handleExecutorChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setCredentialInstanceId(undefined);
setExecutorProvider(next.provider); setExecutorProvider(next.provider);
setExecutorModelId(next.modelId); setExecutorModelId(next.modelId);
}, []); }, []);
const handleValidatorChange = useCallback((value: string) => { const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setValidatorCredentialInstanceId(undefined);
setValidatorProvider(next.provider); setValidatorProvider(next.provider);
setValidatorModelId(next.modelId); setValidatorModelId(next.modelId);
}, []); }, []);
@@ -1481,6 +1500,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const handleThinkingLevelChange = useCallback((value: string) => setThinkingLevel(value), []); const handleThinkingLevelChange = useCallback((value: string) => setThinkingLevel(value), []);
const handleMergerModelChange = useCallback((value: string) => { const handleMergerModelChange = useCallback((value: string) => {
const next = parseModelSelection(value); const next = parseModelSelection(value);
setMergerCredentialInstanceId(undefined);
setMergerProvider(next.provider); setMergerProvider(next.provider);
setMergerModelId(next.modelId); setMergerModelId(next.modelId);
}, []); }, []);
@@ -2498,6 +2518,14 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
thinkingLevel={activeModelSubmenu === "executor" ? thinkingLevel : activeModelSubmenu === "plan" ? planningThinkingLevel : activeModelSubmenu === "validator" ? validatorThinkingLevel : mergerThinkingLevel} thinkingLevel={activeModelSubmenu === "executor" ? thinkingLevel : activeModelSubmenu === "plan" ? planningThinkingLevel : activeModelSubmenu === "validator" ? validatorThinkingLevel : mergerThinkingLevel}
onThinkingLevelChange={activeModelSubmenu === "executor" ? handleThinkingLevelChange : activeModelSubmenu === "plan" ? setPlanningThinkingLevel : activeModelSubmenu === "validator" ? setValidatorThinkingLevel : setMergerThinkingLevel} onThinkingLevelChange={activeModelSubmenu === "executor" ? handleThinkingLevelChange : activeModelSubmenu === "plan" ? setPlanningThinkingLevel : activeModelSubmenu === "validator" ? setValidatorThinkingLevel : setMergerThinkingLevel}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"} defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
credentialInstanceId={activeModelSubmenu === "plan" ? planningCredentialInstanceId : activeModelSubmenu === "executor" ? credentialInstanceId : activeModelSubmenu === "validator" ? validatorCredentialInstanceId : mergerCredentialInstanceId}
onCredentialInstanceChange={(instanceId) => {
const next = instanceId || undefined;
if (activeModelSubmenu === "plan") setPlanningCredentialInstanceId(next);
else if (activeModelSubmenu === "executor") setCredentialInstanceId(next);
else if (activeModelSubmenu === "validator") setValidatorCredentialInstanceId(next);
else setMergerCredentialInstanceId(next);
}}
/> />
{modelsError && ( {modelsError && (
<div className="model-submenu-error"> <div className="model-submenu-error">

View File

@@ -3877,8 +3877,10 @@ export function SettingsModal({
name: nextName, name: nextName,
executorProvider: presetDraft.executorProvider, executorProvider: presetDraft.executorProvider,
executorModelId: presetDraft.executorModelId, executorModelId: presetDraft.executorModelId,
...(presetDraft.executorCredentialInstanceId ? { executorCredentialInstanceId: presetDraft.executorCredentialInstanceId } : {}),
validatorProvider: presetDraft.validatorProvider, validatorProvider: presetDraft.validatorProvider,
validatorModelId: presetDraft.validatorModelId, validatorModelId: presetDraft.validatorModelId,
...(presetDraft.validatorCredentialInstanceId ? { validatorCredentialInstanceId: presetDraft.validatorCredentialInstanceId } : {}),
}; };
setForm((current) => { setForm((current) => {

View File

@@ -1390,8 +1390,11 @@ export function TaskDetailContent({
const [editBranch, setEditBranch] = useState(task.branch ?? ""); const [editBranch, setEditBranch] = useState(task.branch ?? "");
const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? ""); const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? "");
const [editExecutorModel, setEditExecutorModel] = useState(""); const [editExecutorModel, setEditExecutorModel] = useState("");
const [editCredentialInstanceId, setEditCredentialInstanceId] = useState<string | undefined>(undefined);
const [editValidatorModel, setEditValidatorModel] = useState(""); const [editValidatorModel, setEditValidatorModel] = useState("");
const [editValidatorCredentialInstanceId, setEditValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [editPlanningModel, setEditPlanningModel] = useState(""); const [editPlanningModel, setEditPlanningModel] = useState("");
const [editPlanningCredentialInstanceId, setEditPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [editThinkingLevel, setEditThinkingLevel] = useState(""); const [editThinkingLevel, setEditThinkingLevel] = useState("");
// FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means "inherit from workflow" (clear-to-default). // FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means "inherit from workflow" (clear-to-default).
const [editPlannerOversightLevel, setEditPlannerOversightLevel] = useState(""); const [editPlannerOversightLevel, setEditPlannerOversightLevel] = useState("");
@@ -2144,8 +2147,11 @@ export function TaskDetailContent({
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : ""; const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : ""; const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
setEditExecutorModel(execModel); setEditExecutorModel(execModel);
setEditCredentialInstanceId(task.credentialInstanceId);
setEditValidatorModel(valModel); setEditValidatorModel(valModel);
setEditValidatorCredentialInstanceId(task.validatorCredentialInstanceId);
setEditPlanningModel(planModel); setEditPlanningModel(planModel);
setEditPlanningCredentialInstanceId(task.planningCredentialInstanceId);
setEditThinkingLevel(task.thinkingLevel ?? ""); setEditThinkingLevel(task.thinkingLevel ?? "");
setEditPlannerOversightLevel(task.plannerOversightLevel ?? ""); setEditPlannerOversightLevel(task.plannerOversightLevel ?? "");
setEditNodeId(task.nodeId); setEditNodeId(task.nodeId);
@@ -2208,6 +2214,9 @@ export function TaskDetailContent({
if (editExecutorModel !== currentExecutorModel) { if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null; updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null; updates.modelId = executorSelection?.modelId ?? null;
updates.credentialInstanceId = null;
} else if ((editCredentialInstanceId ?? "") !== (task.credentialInstanceId ?? "")) {
updates.credentialInstanceId = editCredentialInstanceId ?? null;
} }
const validatorSelection = splitModelSelection(editValidatorModel); const validatorSelection = splitModelSelection(editValidatorModel);
@@ -2215,6 +2224,9 @@ export function TaskDetailContent({
if (editValidatorModel !== currentValidatorModel) { if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null; updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null; updates.validatorModelId = validatorSelection?.modelId ?? null;
updates.validatorCredentialInstanceId = null;
} else if ((editValidatorCredentialInstanceId ?? "") !== (task.validatorCredentialInstanceId ?? "")) {
updates.validatorCredentialInstanceId = editValidatorCredentialInstanceId ?? null;
} }
const planningSelection = splitModelSelection(editPlanningModel); const planningSelection = splitModelSelection(editPlanningModel);
@@ -2222,6 +2234,9 @@ export function TaskDetailContent({
if (editPlanningModel !== currentPlanningModel) { if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null; updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null; updates.planningModelId = planningSelection?.modelId ?? null;
updates.planningCredentialInstanceId = null;
} else if ((editPlanningCredentialInstanceId ?? "") !== (task.planningCredentialInstanceId ?? "")) {
updates.planningCredentialInstanceId = editPlanningCredentialInstanceId ?? null;
} }
const currentThinkingLevel = task.thinkingLevel ?? ""; const currentThinkingLevel = task.thinkingLevel ?? "";
@@ -2269,7 +2284,7 @@ export function TaskDetailContent({
} }
return { updates, error: null as string | null }; return { updates, error: null as string | null };
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorModel, task]); }, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editCredentialInstanceId, editExecutorModel, editNodeId, editPlanningCredentialInstanceId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorCredentialInstanceId, editValidatorModel, task]);
const persistEditChanges = useCallback(async (includeDescription: boolean) => { const persistEditChanges = useCallback(async (includeDescription: boolean) => {
const { updates, error } = buildEditUpdates(includeDescription); const { updates, error } = buildEditUpdates(includeDescription);
@@ -2370,8 +2385,11 @@ export function TaskDetailContent({
editBranch, editBranch,
editBaseBranch, editBaseBranch,
editExecutorModel, editExecutorModel,
editCredentialInstanceId,
editValidatorModel, editValidatorModel,
editValidatorCredentialInstanceId,
editPlanningModel, editPlanningModel,
editPlanningCredentialInstanceId,
editThinkingLevel, editThinkingLevel,
editPlannerOversightLevel, editPlannerOversightLevel,
editNodeId, editNodeId,
@@ -4532,11 +4550,17 @@ export function TaskDetailContent({
baseBranch={editBaseBranch} baseBranch={editBaseBranch}
onBaseBranchChange={setEditBaseBranch} onBaseBranchChange={setEditBaseBranch}
executorModel={editExecutorModel} executorModel={editExecutorModel}
onExecutorModelChange={setEditExecutorModel} onExecutorModelChange={(value) => { setEditCredentialInstanceId(undefined); setEditExecutorModel(value); }}
credentialInstanceId={editCredentialInstanceId}
onCredentialInstanceIdChange={(instanceId) => setEditCredentialInstanceId(instanceId || undefined)}
validatorModel={editValidatorModel} validatorModel={editValidatorModel}
onValidatorModelChange={setEditValidatorModel} onValidatorModelChange={(value) => { setEditValidatorCredentialInstanceId(undefined); setEditValidatorModel(value); }}
validatorCredentialInstanceId={editValidatorCredentialInstanceId}
onValidatorCredentialInstanceIdChange={(instanceId) => setEditValidatorCredentialInstanceId(instanceId || undefined)}
planningModel={editPlanningModel} planningModel={editPlanningModel}
onPlanningModelChange={setEditPlanningModel} onPlanningModelChange={(value) => { setEditPlanningCredentialInstanceId(undefined); setEditPlanningModel(value); }}
planningCredentialInstanceId={editPlanningCredentialInstanceId}
onPlanningCredentialInstanceIdChange={(instanceId) => setEditPlanningCredentialInstanceId(instanceId || undefined)}
thinkingLevel={editThinkingLevel} thinkingLevel={editThinkingLevel}
onThinkingLevelChange={setEditThinkingLevel} onThinkingLevelChange={setEditThinkingLevel}
plannerOversightLevel={editPlannerOversightLevel} plannerOversightLevel={editPlannerOversightLevel}

View File

@@ -97,10 +97,16 @@ export interface TaskFormProps {
onPriorityChange?: (value: TaskPriority) => void; onPriorityChange?: (value: TaskPriority) => void;
executorModel: string; executorModel: string;
onExecutorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void; onExecutorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
credentialInstanceId?: string;
onCredentialInstanceIdChange?: (value: string) => void;
validatorModel: string; validatorModel: string;
onValidatorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void; onValidatorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
validatorCredentialInstanceId?: string;
onValidatorCredentialInstanceIdChange?: (value: string) => void;
planningModel?: string; planningModel?: string;
onPlanningModelChange?: (value: string) => void; onPlanningModelChange?: (value: string) => void;
planningCredentialInstanceId?: string;
onPlanningCredentialInstanceIdChange?: (value: string) => void;
thinkingLevel?: string; thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void; onThinkingLevelChange?: (value: string) => void;
/* /*
@@ -216,10 +222,16 @@ export function TaskForm({
onPriorityChange, onPriorityChange,
executorModel, executorModel,
onExecutorModelChange, onExecutorModelChange,
credentialInstanceId,
onCredentialInstanceIdChange,
validatorModel, validatorModel,
onValidatorModelChange, onValidatorModelChange,
validatorCredentialInstanceId,
onValidatorCredentialInstanceIdChange,
planningModel, planningModel,
onPlanningModelChange, onPlanningModelChange,
planningCredentialInstanceId,
onPlanningCredentialInstanceIdChange,
thinkingLevel, thinkingLevel,
onThinkingLevelChange, onThinkingLevelChange,
plannerOversightLevel, plannerOversightLevel,
@@ -1571,6 +1583,8 @@ export function TaskForm({
thinkingLevel={thinkingLevel || ""} thinkingLevel={thinkingLevel || ""}
onThinkingLevelChange={onThinkingLevelChange ? (value) => onThinkingLevelChange(value) : undefined} onThinkingLevelChange={onThinkingLevelChange ? (value) => onThinkingLevelChange(value) : undefined}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"} defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={onCredentialInstanceIdChange}
/> />
</div> </div>
<div className="model-select-row"> <div className="model-select-row">
@@ -1591,6 +1605,8 @@ export function TaskForm({
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels} favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite} onToggleModelFavorite={handleToggleModelFavorite}
credentialInstanceId={validatorCredentialInstanceId}
onCredentialInstanceChange={onValidatorCredentialInstanceIdChange}
/> />
</div> </div>
{onPlanningModelChange && ( {onPlanningModelChange && (
@@ -1611,6 +1627,8 @@ export function TaskForm({
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels} favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite} onToggleModelFavorite={handleToggleModelFavorite}
credentialInstanceId={planningCredentialInstanceId}
onCredentialInstanceChange={onPlanningCredentialInstanceIdChange}
/> />
</div> </div>
)} )}

View File

@@ -4364,8 +4364,15 @@ function InnerEditor({
)} )}
onChange={(value) => { onChange={(value) => {
const { provider, modelId } = parseModelDropdownValue(value); const { provider, modelId } = parseModelDropdownValue(value);
updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } }); updateSelectedData({ config: {
modelProvider: provider || undefined,
modelId: modelId || undefined,
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit node-model changes cannot retain an instance selected for the previous provider.
credentialInstanceId: undefined,
} });
}} }}
credentialInstanceId={String(selectedNode.data.config?.credentialInstanceId ?? "")}
onCredentialInstanceChange={(credentialInstanceId) => updateSelectedData({ config: { credentialInstanceId: credentialInstanceId || undefined } })}
/* /*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00: * FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Prompt model nodes expose the shared inline thinking selector only for the model executor, persisting `config.thinkingLevel` with Default clearing the key. * Prompt model nodes expose the shared inline thinking selector only for the model executor, persisting `config.thinkingLevel` with Default clearing the key.
@@ -5102,9 +5109,13 @@ function InnerEditor({
modelProvider: provider || undefined, modelProvider: provider || undefined,
modelId: modelId || undefined, modelId: modelId || undefined,
model: value || undefined, model: value || undefined,
// FNXC:ModelDropdown 2026-08-01-10:45: Review-node model changes clear the prior credential-instance override.
credentialInstanceId: undefined,
}, },
}); });
}} }}
credentialInstanceId={String(selectedNode.data.config?.credentialInstanceId ?? "")}
onCredentialInstanceChange={(credentialInstanceId) => updateSelectedData({ config: { credentialInstanceId: credentialInstanceId || undefined } })}
/* /*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00: * FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-review nodes share the model dropdown thinking selector so review sessions can pin reasoning effort with the same node > task > settings precedence as executor steps. * Step-review nodes share the model dropdown thinking selector so review sessions can pin reasoning effort with the same node > task > settings precedence as executor steps.

View File

@@ -505,6 +505,7 @@ export interface WorkflowModelLanePair {
providerId: string; providerId: string;
modelId: string; modelId: string;
thinkingId?: string; thinkingId?: string;
credentialInstanceId?: string;
label: string; label: string;
help: string; help: string;
} }
@@ -525,6 +526,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "planningProvider", providerId: "planningProvider",
modelId: "planningModelId", modelId: "planningModelId",
thinkingId: "planningThinkingLevel", thinkingId: "planningThinkingLevel",
credentialInstanceId: "planningCredentialInstanceId",
label: "Plan/Triage Model", label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.",
}, },
@@ -533,6 +535,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "planningFallbackProvider", providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId", modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel", thinkingId: "planningFallbackThinkingLevel",
credentialInstanceId: "planningFallbackCredentialInstanceId",
label: "Planning Fallback Model", label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
}, },
@@ -541,6 +544,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "executionProvider", providerId: "executionProvider",
modelId: "executionModelId", modelId: "executionModelId",
thinkingId: "executionThinkingLevel", thinkingId: "executionThinkingLevel",
credentialInstanceId: "executionCredentialInstanceId",
label: "Executor Model", label: "Executor Model",
help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.", help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.",
}, },
@@ -549,6 +553,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "executionFallbackProvider", providerId: "executionFallbackProvider",
modelId: "executionFallbackModelId", modelId: "executionFallbackModelId",
thinkingId: "executionFallbackThinkingLevel", thinkingId: "executionFallbackThinkingLevel",
credentialInstanceId: "executionFallbackCredentialInstanceId",
label: "Executor Fallback Model", label: "Executor Fallback Model",
help: "Fallback provider and model used when the primary Executor model cannot be used.", help: "Fallback provider and model used when the primary Executor model cannot be used.",
}, },
@@ -557,6 +562,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "validatorProvider", providerId: "validatorProvider",
modelId: "validatorModelId", modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel", thinkingId: "validatorThinkingLevel",
credentialInstanceId: "validatorCredentialInstanceId",
label: "Reviewer Model", label: "Reviewer Model",
help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.", help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.",
}, },
@@ -565,6 +571,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "validatorFallbackProvider", providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId", modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel", thinkingId: "validatorFallbackThinkingLevel",
credentialInstanceId: "validatorFallbackCredentialInstanceId",
label: "Reviewer Fallback Model", label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.", help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
}, },
@@ -619,7 +626,12 @@ function ValuesTab({
[settingsById], [settingsById],
); );
const modelPairSettingIds = useMemo( const modelPairSettingIds = useMemo(
() => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId, ...(pair.thinkingId ? [pair.thinkingId] : [])])), () => new Set(modelLanePairs.flatMap((pair) => [
pair.providerId,
pair.modelId,
...(pair.thinkingId ? [pair.thinkingId] : []),
...(pair.credentialInstanceId ? [pair.credentialInstanceId] : []),
])),
[modelLanePairs], [modelLanePairs],
); );
@@ -727,6 +739,8 @@ function ValuesTab({
...prev, ...prev,
[pair.providerId]: split?.provider ?? null, [pair.providerId]: split?.provider ?? null,
[pair.modelId]: split?.modelId ?? null, [pair.modelId]: split?.modelId ?? null,
// FNXC:ModelDropdown 2026-08-01-10:45: Persisted workflow lane selections clear any instance from the prior provider/model pair.
...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}),
})); }));
setRejections((prev) => { setRejections((prev) => {
if (!prev[pair.providerId] && !prev[pair.modelId]) return prev; if (!prev[pair.providerId] && !prev[pair.modelId]) return prev;
@@ -737,6 +751,12 @@ function ValuesTab({
}); });
}; };
const setModelPairCredentialInstanceValue = (pair: WorkflowModelLanePair, value: string) => {
if (!pair.credentialInstanceId) return;
// Default clears the stored override; workflow setting writes use null-as-delete.
setValue(pair.credentialInstanceId, value || null);
};
const setModelPairThinkingValue = (pair: WorkflowModelLanePair, value: string) => { const setModelPairThinkingValue = (pair: WorkflowModelLanePair, value: string) => {
if (!pair.thinkingId) return; if (!pair.thinkingId) return;
setValue(pair.thinkingId, value || null); setValue(pair.thinkingId, value || null);
@@ -745,6 +765,7 @@ function ValuesTab({
const clearModelPairValue = (pair: WorkflowModelLanePair) => { const clearModelPairValue = (pair: WorkflowModelLanePair) => {
setModelPairValue(pair, ""); setModelPairValue(pair, "");
setModelPairThinkingValue(pair, ""); setModelPairThinkingValue(pair, "");
setModelPairCredentialInstanceValue(pair, "");
}; };
const dirty = Object.keys(pending).length > 0; const dirty = Object.keys(pending).length > 0;
@@ -807,8 +828,17 @@ function ValuesTab({
const thinkingValue = pair.thinkingId && typeof valueOfSettingId(pair.thinkingId) === "string" const thinkingValue = pair.thinkingId && typeof valueOfSettingId(pair.thinkingId) === "string"
? (valueOfSettingId(pair.thinkingId) as string) ? (valueOfSettingId(pair.thinkingId) as string)
: ""; : "";
const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message ?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined); const credentialInstanceId = pair.credentialInstanceId && typeof valueOfSettingId(pair.credentialInstanceId) === "string"
const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId) || Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId)); ? (valueOfSettingId(pair.credentialInstanceId) as string)
: "";
const error = rejections[pair.providerId]?.message
?? rejections[pair.modelId]?.message
?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined)
?? (pair.credentialInstanceId ? rejections[pair.credentialInstanceId]?.message : undefined);
const customized = isCustomizedId(pair.providerId)
|| isCustomizedId(pair.modelId)
|| Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId))
|| Boolean(pair.credentialInstanceId && isCustomizedId(pair.credentialInstanceId));
const dropdownDisabled = modelsLoading || availableModels.length === 0; const dropdownDisabled = modelsLoading || availableModels.length === 0;
const emptyHelp = const emptyHelp =
!modelsLoading && availableModels.length === 0 !modelsLoading && availableModels.length === 0
@@ -833,6 +863,8 @@ function ValuesTab({
models={availableModels} models={availableModels}
value={value} value={value}
onChange={(next) => setModelPairValue(pair, next)} onChange={(next) => setModelPairValue(pair, next)}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={(value) => setModelPairCredentialInstanceValue(pair, value)}
placeholder={t("workflowSettings.selectModel", "Select a model…")} placeholder={t("workflowSettings.selectModel", "Select a model…")}
defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")} defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")}
disabled={dropdownDisabled} disabled={dropdownDisabled}

View File

@@ -0,0 +1,111 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomModelDropdown } from "../CustomModelDropdown";
vi.mock("../ProviderIcon", () => ({
ProviderIcon: () => <span />,
}));
const models = [{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }];
async function openDropdown(props: Partial<ComponentProps<typeof CustomModelDropdown>>) {
const user = userEvent.setup();
render(<CustomModelDropdown label="Model" value="openai/gpt-4o" onChange={vi.fn()} models={models} {...props} />);
await user.click(screen.getByRole("button", { name: "Model" }));
return user;
}
describe("CustomModelDropdown credential instance", () => {
it.each([undefined, {}, { openai: { instances: [] } }, { openai: { instances: [{ id: "only", isDefault: true }] } }])(
"renders no instance control unless the selected provider has two instances",
async (credentialInstances) => {
await openDropdown({ credentialInstances });
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-credential-instance-badge")).toBeNull();
},
);
it("keeps hidden menus structurally free of an instance shell at desktop and mobile breakpoints", async () => {
for (const width of [1024, 768, 480]) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
const user = await openDropdown({ credentialInstances: { openai: { instances: [{ id: "only", isDefault: true }] } } });
const menu = screen.getAllByTestId("model-combobox-portal").at(-1)!;
expect([...menu.children].map((element) => element.className)).not.toContain("model-combobox-instance");
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-credential-instance-badge")).toBeNull();
await user.keyboard("{Escape}");
cleanup();
}
});
it("shows availability-driven control even before an owner supplies a persistence callback", async () => {
await openDropdown({
credentialInstances: { openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] } },
});
expect(screen.getByTestId("custom-model-dropdown-credential-instance")).toBeDisabled();
});
it("deduplicates instances, preserves a stale selection, and emits empty only for Default", async () => {
const onCredentialInstanceChange = vi.fn();
const user = await openDropdown({
credentialInstanceId: "stale",
onCredentialInstanceChange,
credentialInstances: {
openai: { instances: [{ id: "primary", isDefault: true }, { id: "primary", isDefault: false }, { id: "backup", isDefault: false }] },
},
});
const select = screen.getByTestId("custom-model-dropdown-credential-instance") as HTMLSelectElement;
expect([...select.options].map((option) => option.value)).toEqual(["", "primary", "backup", "stale"]);
expect(select.value).toBe("stale");
await user.selectOptions(select, "backup");
expect(onCredentialInstanceChange).toHaveBeenLastCalledWith("backup");
await user.selectOptions(select, "");
expect(onCredentialInstanceChange).toHaveBeenLastCalledWith("");
});
it("recomputes availability after the selected provider changes without affecting keyboard dismissal", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onCredentialInstanceChange = vi.fn();
const { rerender } = render(
<CustomModelDropdown
label="Model"
value="openai/gpt-4o"
onChange={onChange}
models={[
...models,
{ provider: "anthropic", id: "claude", name: "Claude", reasoning: false, contextWindow: 128000 },
]}
credentialInstances={{
openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] },
anthropic: { instances: [{ id: "only", isDefault: true }] },
}}
onCredentialInstanceChange={onCredentialInstanceChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Model" }));
expect(screen.getByTestId("custom-model-dropdown-credential-instance")).toBeTruthy();
rerender(
<CustomModelDropdown
label="Model"
value="anthropic/claude"
onChange={onChange}
models={[
...models,
{ provider: "anthropic", id: "claude", name: "Claude", reasoning: false, contextWindow: 128000 },
]}
credentialInstances={{
openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] },
anthropic: { instances: [{ id: "only", isDefault: true }] },
}}
onCredentialInstanceChange={onCredentialInstanceChange}
/>,
);
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
await user.keyboard("{Escape}");
expect(screen.queryByTestId("model-combobox-portal")).toBeNull();
});
});

View File

@@ -705,7 +705,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith( expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1", "wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" }, { planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1", "proj-1",
); );
}); });
@@ -759,6 +759,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
validatorFallbackProvider: null, validatorFallbackProvider: null,
validatorFallbackModelId: null, validatorFallbackModelId: null,
validatorFallbackThinkingLevel: null, validatorFallbackThinkingLevel: null,
validatorFallbackCredentialInstanceId: null,
}, "proj-1"); }, "proj-1");
}); });
@@ -788,7 +789,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith( expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1", "wf-1",
{ planningProvider: null, planningModelId: null }, { planningProvider: null, planningModelId: null, planningCredentialInstanceId: null },
"proj-1", "proj-1",
); );
}); });
@@ -830,7 +831,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(mockUpdateValues).toHaveBeenNthCalledWith( expect(mockUpdateValues).toHaveBeenNthCalledWith(
2, 2,
"wf-1", "wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" }, { planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1", "proj-1",
); );
}); });
@@ -871,7 +872,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet"); expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
expect(mockUpdateValues).toHaveBeenCalledWith( expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1", "wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" }, { planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1", "proj-1",
); );
}); });

View File

@@ -83,9 +83,11 @@ export function extractAssignedRuntimeModel(agent: Agent | null | undefined): Mo
const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : ""; const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : "";
const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : ""; const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : "";
const credentialInstanceId = isStringValue(runtimeConfig?.credentialInstanceId) ? runtimeConfig.credentialInstanceId.trim() : "";
return { return {
provider: provider || undefined, provider: provider || undefined,
modelId: modelId || undefined, modelId: modelId || undefined,
...(credentialInstanceId ? { credentialInstanceId } : {}),
}; };
} }
@@ -191,7 +193,11 @@ export function resolveEffectivePlanning(
settings?: Settings, settings?: Settings,
): ModelSelection { ): ModelSelection {
if (task.planningModelProvider && task.planningModelId) { if (task.planningModelProvider && task.planningModelId) {
return { provider: task.planningModelProvider, modelId: task.planningModelId }; return {
provider: task.planningModelProvider,
modelId: task.planningModelId,
...(task.planningCredentialInstanceId ? { credentialInstanceId: task.planningCredentialInstanceId } : {}),
};
} }
const fromLog = extractPlanningModelFromLog(logEntries); const fromLog = extractPlanningModelFromLog(logEntries);
if (fromLog) { if (fromLog) {

View File

@@ -17,6 +17,18 @@ function toCommaSeparatedInput(values?: string[]): string {
function fromCommaSeparatedInput(value: string): string[] { function fromCommaSeparatedInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
} }
/*
FNXC:SettingsCredentialInstance 2026-08-01-10:19:
Each global model lane persists its credential instance alongside its provider/model pair. Clearing either the model or the instance must remove the override so runtime resolution returns to the provider default rather than retaining an orphaned credential choice.
*/
const GLOBAL_LANE_CREDENTIAL_INSTANCE_KEYS: Partial<Record<string, keyof Settings>> = {
execution: "executionGlobalCredentialInstanceId",
planning: "planningGlobalCredentialInstanceId",
validator: "validatorGlobalCredentialInstanceId",
merger: "mergerGlobalCredentialInstanceId",
summarization: "titleSummarizerGlobalCredentialInstanceId",
"import-translate": "importTranslateGlobalCredentialInstanceId",
};
export interface GlobalModelsSectionProps extends SectionBaseProps { export interface GlobalModelsSectionProps extends SectionBaseProps {
availableModels: ModelInfo[]; availableModels: ModelInfo[];
modelsLoading: boolean; modelsLoading: boolean;
@@ -55,7 +67,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
</div> </div>
<CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => { <CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => {
if (!val) { if (!val) {
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined, defaultCredentialInstanceId: undefined }));
} }
else { else {
const slashIdx = val.indexOf("/"); const slashIdx = val.indexOf("/");
@@ -63,9 +75,11 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f, ...f,
defaultProvider: val.slice(0, slashIdx), defaultProvider: val.slice(0, slashIdx),
defaultModelId: val.slice(slashIdx + 1), defaultModelId: val.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A provider/model change invalidates its credential-instance companion; preserve stale ids only during passive rendering, never across an explicit selection.
defaultCredentialInstanceId: undefined,
})); }));
} }
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> }} credentialInstanceId={form.defaultCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, defaultCredentialInstanceId: instanceId || undefined }))} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -76,7 +90,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
{/* FNXC:Settings-ThinkingLevel 2026-07-10-12:00: Global fallback model selection owns its own thinking-level companion (`fallbackThinkingLevel`). Clearing the fallback picker must clear the companion value so null-as-delete reset parity matches the per-lane model pickers. */} {/* FNXC:Settings-ThinkingLevel 2026-07-10-12:00: Global fallback model selection owns its own thinking-level companion (`fallbackThinkingLevel`). Clearing the fallback picker must clear the companion value so null-as-delete reset parity matches the per-lane model pickers. */}
<CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => { <CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => {
if (!val) { if (!val) {
setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined })); setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined, fallbackCredentialInstanceId: undefined }));
} }
else { else {
const slashIdx = val.indexOf("/"); const slashIdx = val.indexOf("/");
@@ -84,9 +98,11 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f, ...f,
fallbackProvider: val.slice(0, slashIdx), fallbackProvider: val.slice(0, slashIdx),
fallbackModelId: val.slice(slashIdx + 1), fallbackModelId: val.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit model selection clears the prior provider's credential-instance override.
fallbackCredentialInstanceId: undefined,
})); }));
} }
}} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={(() => { }} credentialInstanceId={form.fallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, fallbackCredentialInstanceId: instanceId || undefined }))} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={(() => {
const selectedModel = availableModels.find((m) => m.provider === form.fallbackProvider && m.id === form.fallbackModelId); const selectedModel = availableModels.find((m) => m.provider === form.fallbackProvider && m.id === form.fallbackModelId);
return selectedModel ? Boolean(selectedModel.reasoning) : true; return selectedModel ? Boolean(selectedModel.reasoning) : true;
})()} thinkingLevel={form.fallbackThinkingLevel || ""} onThinkingLevelChange={(level) => setForm((f) => ({ ...f, fallbackThinkingLevel: (level as ThinkingLevel) || undefined }))} defaultThinkingLevel={form.defaultThinkingLevel}/> })()} thinkingLevel={form.fallbackThinkingLevel || ""} onThinkingLevelChange={(level) => setForm((f) => ({ ...f, fallbackThinkingLevel: (level as ThinkingLevel) || undefined }))} defaultThinkingLevel={form.defaultThinkingLevel}/>
@@ -129,6 +145,8 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
const model = form[lane.globalModelKey as keyof Settings] as string | undefined; const model = form[lane.globalModelKey as keyof Settings] as string | undefined;
const value = provider && model ? `${provider}/${model}` : ""; const value = provider && model ? `${provider}/${model}` : "";
const thinkingValue = getLaneThinkingValue(lane); const thinkingValue = getLaneThinkingValue(lane);
const credentialInstanceKey = GLOBAL_LANE_CREDENTIAL_INSTANCE_KEYS[lane.laneId];
const credentialInstanceId = credentialInstanceKey ? form[credentialInstanceKey] as string | undefined : undefined;
return (<div className="form-group" key={`global-${lane.laneId}`}> return (<div className="form-group" key={`global-${lane.laneId}`}>
{/* FNXC:SettingsHelp 2026-07-15-21:40: A global lane row is plain label + picker + one help string (unlike the project lanes, which add an inherited/override badge and a resolved fallback chain), so its helper text hangs off the shared "?" like every other row in this section. */} {/* FNXC:SettingsHelp 2026-07-15-21:40: A global lane row is plain label + picker + one help string (unlike the project lanes, which add an inherited/override badge and a resolved fallback chain), so its helper text hangs off the shared "?" like every other row in this section. */}
<div className="settings-field-label-row"> <div className="settings-field-label-row">
@@ -141,6 +159,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f, ...f,
[lane.globalProviderKey]: undefined, [lane.globalProviderKey]: undefined,
[lane.globalModelKey]: undefined, [lane.globalModelKey]: undefined,
...(credentialInstanceKey ? { [credentialInstanceKey]: undefined } : {}),
})); }));
resetLaneThinkingValue(lane); resetLaneThinkingValue(lane);
return; return;
@@ -150,8 +169,10 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f, ...f,
[lane.globalProviderKey]: selected.slice(0, slashIdx), [lane.globalProviderKey]: selected.slice(0, slashIdx),
[lane.globalModelKey]: selected.slice(slashIdx + 1), [lane.globalModelKey]: selected.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Lane selections cannot carry an instance override from the previously chosen provider.
...(credentialInstanceKey ? { [credentialInstanceKey]: undefined } : {}),
})); }));
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={Boolean(lane.globalThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/> }} credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={credentialInstanceKey ? (instanceId) => setForm((f) => ({ ...f, [credentialInstanceKey]: instanceId || undefined })) : undefined} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={Boolean(lane.globalThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>); </div>);
})} })}
</>)} </>)}

View File

@@ -19,10 +19,21 @@ type WorkflowModelPair = {
providerId: string; providerId: string;
modelId: string; modelId: string;
thinkingId?: string; thinkingId?: string;
credentialInstanceId?: string;
label: string; label: string;
help: string; help: string;
}; };
const DEFAULT_WORKFLOW_ID = "builtin:coding"; const DEFAULT_WORKFLOW_ID = "builtin:coding";
/*
FNXC:SettingsCredentialInstance 2026-08-01-10:19:
Project lanes that own persisted model overrides must carry their credential instance through the same form state. The empty Default choice deletes only that instance key, preserving provider/model selection while returning resolution to the provider default.
*/
const PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS = {
default: "defaultCredentialInstanceIdOverride",
merger: "mergerCredentialInstanceId",
"import-translate": "importTranslateCredentialInstanceId",
} as const satisfies Partial<Record<string, keyof Settings>>;
/* /*
FNXC:SettingsModels 2026-06-16-19:58: FNXC:SettingsModels 2026-06-16-19:58:
Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes workflow fallback pairs declared by the active default workflow plus project-scoped title-summarizer fallback keys so saves never PATCH undeclared keys. Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes workflow fallback pairs declared by the active default workflow plus project-scoped title-summarizer fallback keys so saves never PATCH undeclared keys.
@@ -39,6 +50,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "planningProvider", providerId: "planningProvider",
modelId: "planningModelId", modelId: "planningModelId",
thinkingId: "planningThinkingLevel", thinkingId: "planningThinkingLevel",
credentialInstanceId: "planningCredentialInstanceId",
label: "Plan/Triage Model", label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to fall through to the global lane, then the selected workflow.", help: "Provider and model used when planning or triaging tasks. Leave unset to fall through to the global lane, then the selected workflow.",
}, },
@@ -47,6 +59,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "planningFallbackProvider", providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId", modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel", thinkingId: "planningFallbackThinkingLevel",
credentialInstanceId: "planningFallbackCredentialInstanceId",
label: "Planning Fallback Model", label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
}, },
@@ -55,6 +68,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "executionProvider", providerId: "executionProvider",
modelId: "executionModelId", modelId: "executionModelId",
thinkingId: "executionThinkingLevel", thinkingId: "executionThinkingLevel",
credentialInstanceId: "executionCredentialInstanceId",
label: "Executor Model", label: "Executor Model",
help: "Provider and model used while executing workflow steps. Leave unset to fall through to the global lane, then the selected workflow.", help: "Provider and model used while executing workflow steps. Leave unset to fall through to the global lane, then the selected workflow.",
}, },
@@ -63,6 +77,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "executionFallbackProvider", providerId: "executionFallbackProvider",
modelId: "executionFallbackModelId", modelId: "executionFallbackModelId",
thinkingId: "executionFallbackThinkingLevel", thinkingId: "executionFallbackThinkingLevel",
credentialInstanceId: "executionFallbackCredentialInstanceId",
label: "Executor Fallback Model", label: "Executor Fallback Model",
help: "Fallback provider and model used when the primary Executor model cannot be used.", help: "Fallback provider and model used when the primary Executor model cannot be used.",
}, },
@@ -71,6 +86,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "validatorProvider", providerId: "validatorProvider",
modelId: "validatorModelId", modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel", thinkingId: "validatorThinkingLevel",
credentialInstanceId: "validatorCredentialInstanceId",
label: "Reviewer Model", label: "Reviewer Model",
help: "Provider and model used for workflow review or validation lanes. Leave unset to fall through to the global lane, then the selected workflow.", help: "Provider and model used for workflow review or validation lanes. Leave unset to fall through to the global lane, then the selected workflow.",
}, },
@@ -79,6 +95,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "validatorFallbackProvider", providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId", modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel", thinkingId: "validatorFallbackThinkingLevel",
credentialInstanceId: "validatorFallbackCredentialInstanceId",
label: "Reviewer Fallback Model", label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.", help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
}, },
@@ -90,7 +107,12 @@ function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): Wor
const model = settingsById.get(pair.modelId); const model = settingsById.get(pair.modelId);
const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined; const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined;
return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string"); return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string");
}); }).map((pair) => ({
...pair,
credentialInstanceId: settingsById.get(pair.credentialInstanceId ?? "")?.type === "string"
? pair.credentialInstanceId
: undefined,
}));
} }
function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair): string { function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair): string {
const provider = values[pair.providerId]; const provider = values[pair.providerId];
@@ -224,11 +246,12 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const next = { ...current }; const next = { ...current };
delete next[pair.providerId]; delete next[pair.providerId];
delete next[pair.modelId]; delete next[pair.modelId];
if (pair.credentialInstanceId) delete next[pair.credentialInstanceId];
return next; return next;
}); });
setWorkflowPending((current) => { setWorkflowPending((current) => {
if (!value) { if (!value) {
return { ...current, [pair.providerId]: null, [pair.modelId]: null }; return { ...current, [pair.providerId]: null, [pair.modelId]: null, ...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}) };
} }
const slashIdx = value.indexOf("/"); const slashIdx = value.indexOf("/");
if (slashIdx <= 0) if (slashIdx <= 0)
@@ -237,9 +260,22 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current, ...current,
[pair.providerId]: value.slice(0, slashIdx), [pair.providerId]: value.slice(0, slashIdx),
[pair.modelId]: value.slice(slashIdx + 1), [pair.modelId]: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A user-selected provider/model pair must clear its prior credential-instance companion.
...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}),
}; };
}); });
}, [onWorkflowLanesChange]); }, [onWorkflowLanesChange]);
const setWorkflowCredentialInstanceValue = useCallback((pair: WorkflowModelPair, value: string) => {
if (!pair.credentialInstanceId) return;
onWorkflowLanesChange?.();
setWorkflowRejections((current) => {
if (!current[pair.credentialInstanceId as string]) return current;
const next = { ...current };
delete next[pair.credentialInstanceId as string];
return next;
});
setWorkflowPending((current) => ({ ...current, [pair.credentialInstanceId as string]: value || null }));
}, [onWorkflowLanesChange]);
const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => { const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => {
if (!pair.thinkingId) if (!pair.thinkingId)
return; return;
@@ -256,7 +292,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => { const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => {
setWorkflowPairValue(pair, ""); setWorkflowPairValue(pair, "");
setWorkflowThinkingValue(pair, ""); setWorkflowThinkingValue(pair, "");
}, [setWorkflowPairValue, setWorkflowThinkingValue]); setWorkflowCredentialInstanceValue(pair, "");
}, [setWorkflowCredentialInstanceValue, setWorkflowPairValue, setWorkflowThinkingValue]);
const saveWorkflowLanes = useCallback(async () => { const saveWorkflowLanes = useCallback(async () => {
if (!projectId || !workflowDirty) if (!projectId || !workflowDirty)
return; return;
@@ -301,6 +338,16 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
// FNXC:GitHubImportTranslate 2026-07-15-09:30: The import-translate lane is project-scoped (like merger/summarization), so its project override must be editable here — otherwise the lane's projectProviderKey/projectModelKey would be unreachable and only the global lane could ever be set. // FNXC:GitHubImportTranslate 2026-07-15-09:30: The import-translate lane is project-scoped (like merger/summarization), so its project override must be editable here — otherwise the lane's projectProviderKey/projectModelKey would be unreachable and only the global lane could ever be set.
// FNXC:SettingsModels 2026-07-15-12:00: Summarization is still project-scoped but is rendered with the AI summarization section below rather than the general Model Lanes list. // FNXC:SettingsModels 2026-07-15-12:00: Summarization is still project-scoped but is rendered with the AI summarization section below rather than the general Model Lanes list.
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "import-translate"].includes(lane.laneId)); const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "import-translate"].includes(lane.laneId));
const credentialInstanceKeyForLane = (lane: ModelLane): keyof Settings | undefined => PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS[lane.laneId as keyof typeof PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS];
const credentialInstanceValueForLane = (lane: ModelLane): string => {
const key = credentialInstanceKeyForLane(lane);
return key && typeof form[key] === "string" ? form[key] as string : "";
};
const setCredentialInstanceValueForLane = (lane: ModelLane, instanceId: string) => {
const key = credentialInstanceKeyForLane(lane);
if (!key) return;
setForm((current) => ({ ...current, [key]: instanceId || undefined } as SettingsFormState));
};
const summarizationLane = modelLanes.find((lane) => lane.laneId === "summarization"); const summarizationLane = modelLanes.find((lane) => lane.laneId === "summarization");
const getProjectLaneLabel = (lane: ModelLane) => { const getProjectLaneLabel = (lane: ModelLane) => {
if (lane.laneId === "default") { if (lane.laneId === "default") {
@@ -343,10 +390,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string" const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string"
? form.mergerFallbackThinkingLevel ? form.mergerFallbackThinkingLevel
: ""; : "";
const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue); const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue || form.mergerFallbackCredentialInstanceId);
const setMergerFallbackValue = (value: string) => { const setMergerFallbackValue = (value: string) => {
if (!value) { if (!value) {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState));
return; return;
} }
const slashIdx = value.indexOf("/"); const slashIdx = value.indexOf("/");
@@ -354,13 +401,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...f, ...f,
mergerFallbackProvider: value.slice(0, slashIdx), mergerFallbackProvider: value.slice(0, slashIdx),
mergerFallbackModelId: value.slice(slashIdx + 1), mergerFallbackModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit model selection clears the prior provider's credential-instance override.
mergerFallbackCredentialInstanceId: undefined,
} as SettingsFormState)); } as SettingsFormState));
}; };
const setMergerFallbackThinkingValue = (value: string) => { const setMergerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState)); setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState));
}; };
const resetMergerFallbackValue = () => { const resetMergerFallbackValue = () => {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState));
}; };
const renderMergerFallbackLane = () => ( const renderMergerFallbackLane = () => (
<div className="form-group" data-testid="project-model-lane-merger-fallback"> <div className="form-group" data-testid="project-model-lane-merger-fallback">
@@ -375,7 +424,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<CustomModelDropdown id="mergerFallbackModel" label="Merger Fallback Model" models={availableModels} value={mergerFallbackValue} onChange={setMergerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={mergerFallbackThinkingValue} onThinkingLevelChange={setMergerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/> <CustomModelDropdown id="mergerFallbackModel" label="Merger Fallback Model" models={availableModels} value={mergerFallbackValue} onChange={setMergerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={mergerFallbackThinkingValue} onThinkingLevelChange={setMergerFallbackThinkingValue} credentialInstanceId={form.mergerFallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, mergerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div> </div>
{mergerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetMergerFallbackValue}>{t("settings.projectModels.reset", " Reset ")}</button>)} {mergerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetMergerFallbackValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div> </div>
@@ -387,10 +436,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const titleSummarizerFallbackThinkingValue = typeof form.titleSummarizerFallbackThinkingLevel === "string" const titleSummarizerFallbackThinkingValue = typeof form.titleSummarizerFallbackThinkingLevel === "string"
? form.titleSummarizerFallbackThinkingLevel ? form.titleSummarizerFallbackThinkingLevel
: ""; : "";
const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue); const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue || form.titleSummarizerFallbackCredentialInstanceId);
const setTitleSummarizerFallbackValue = (value: string) => { const setTitleSummarizerFallbackValue = (value: string) => {
if (!value) { if (!value) {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState));
return; return;
} }
const slashIdx = value.indexOf("/"); const slashIdx = value.indexOf("/");
@@ -398,13 +447,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...f, ...f,
titleSummarizerFallbackProvider: value.slice(0, slashIdx), titleSummarizerFallbackProvider: value.slice(0, slashIdx),
titleSummarizerFallbackModelId: value.slice(slashIdx + 1), titleSummarizerFallbackModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A newly chosen fallback model cannot inherit a credential instance from its predecessor.
titleSummarizerFallbackCredentialInstanceId: undefined,
} as SettingsFormState)); } as SettingsFormState));
}; };
const setTitleSummarizerFallbackThinkingValue = (value: string) => { const setTitleSummarizerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, titleSummarizerFallbackThinkingLevel: value || undefined } as SettingsFormState)); setForm((f) => ({ ...f, titleSummarizerFallbackThinkingLevel: value || undefined } as SettingsFormState));
}; };
const resetTitleSummarizerFallbackValue = () => { const resetTitleSummarizerFallbackValue = () => {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState));
}; };
/* /*
* FNXC:SettingsModels 2026-07-15-12:00: * FNXC:SettingsModels 2026-07-15-12:00:
@@ -416,7 +467,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const status = getLaneStatus(lane); const status = getLaneStatus(lane);
const value = getLaneValue(lane); const value = getLaneValue(lane);
const thinkingValue = getLaneThinkingValue(lane); const thinkingValue = getLaneThinkingValue(lane);
const isOverridden = status === "overridden" || Boolean(thinkingValue); const isOverridden = status === "overridden" || Boolean(thinkingValue) || Boolean(credentialInstanceValueForLane(lane));
const laneLabel = getProjectLaneLabel(lane); const laneLabel = getProjectLaneLabel(lane);
return (<div className="form-group" key={lane.laneId}> return (<div className="form-group" key={lane.laneId}>
{/* {/*
@@ -435,9 +486,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/> <CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => { updateLaneValue(lane, val); setCredentialInstanceValueForLane(lane, ""); }} credentialInstanceId={credentialInstanceValueForLane(lane)} onCredentialInstanceChange={(instanceId) => setCredentialInstanceValueForLane(lane, instanceId)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div> </div>
{isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => { resetLaneValue(lane); resetLaneThinkingValue(lane); }} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)} {isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => { resetLaneValue(lane); resetLaneThinkingValue(lane); setCredentialInstanceValueForLane(lane, ""); }} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div> </div>
</div>); </div>);
}; };
@@ -448,10 +499,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const chatDefaultThinkingValue = typeof form.chatDefaultThinkingLevel === "string" const chatDefaultThinkingValue = typeof form.chatDefaultThinkingLevel === "string"
? form.chatDefaultThinkingLevel ? form.chatDefaultThinkingLevel
: ""; : "";
const chatDefaultCustomized = Boolean(form.chatNewSessionMode || form.chatDefaultKind || form.chatDefaultAgentId || chatDefaultModelValue || chatDefaultThinkingValue); const chatDefaultCustomized = Boolean(form.chatNewSessionMode || form.chatDefaultKind || form.chatDefaultAgentId || chatDefaultModelValue || chatDefaultThinkingValue || form.chatDefaultCredentialInstanceId);
const setChatDefaultModelValue = (value: string) => { const setChatDefaultModelValue = (value: string) => {
if (!value) { if (!value) {
setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState));
return; return;
} }
const slashIdx = value.indexOf("/"); const slashIdx = value.indexOf("/");
@@ -463,13 +514,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
chatDefaultAgentId: undefined, chatDefaultAgentId: undefined,
chatDefaultModelProvider: value.slice(0, slashIdx), chatDefaultModelProvider: value.slice(0, slashIdx),
chatDefaultModelId: value.slice(slashIdx + 1), chatDefaultModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A fresh chat model selection must not reuse a previous provider's credential instance.
chatDefaultCredentialInstanceId: undefined,
} as SettingsFormState)); } as SettingsFormState));
}; };
const setChatDefaultThinkingValue = (value: string) => { const setChatDefaultThinkingValue = (value: string) => {
setForm((f) => ({ ...f, chatDefaultThinkingLevel: value || undefined } as SettingsFormState)); setForm((f) => ({ ...f, chatDefaultThinkingLevel: value || undefined } as SettingsFormState));
}; };
const resetChatDefaultValue = () => { const resetChatDefaultValue = () => {
setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState)); setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState));
}; };
return (<> return (<>
@@ -536,7 +589,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
<button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "model" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined } as SettingsFormState))}> <button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "model" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined } as SettingsFormState))}>
{t("settings.projectModels.chatDefaultKindModel", "Model")} {t("settings.projectModels.chatDefaultKindModel", "Model")}
</button> </button>
<button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "agent" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState))}> <button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "agent" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState))}>
{t("settings.projectModels.chatDefaultKindAgent", "Agent")} {t("settings.projectModels.chatDefaultKindAgent", "Agent")}
</button> </button>
</div> </div>
@@ -553,7 +606,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<CustomModelDropdown id="chatDefaultModel" label={t("settings.projectModels.chatDefaultModel", "Chat Default Model")} models={availableModels} value={chatDefaultModelValue} onChange={setChatDefaultModelValue} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/> <CustomModelDropdown id="chatDefaultModel" label={t("settings.projectModels.chatDefaultModel", "Chat Default Model")} models={availableModels} value={chatDefaultModelValue} onChange={setChatDefaultModelValue} credentialInstanceId={typeof form.chatDefaultCredentialInstanceId === "string" ? form.chatDefaultCredentialInstanceId : undefined} onCredentialInstanceChange={(instanceId) => setForm((current) => ({ ...current, chatDefaultCredentialInstanceId: instanceId || undefined } as SettingsFormState))} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div> </div>
{chatDefaultCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.chatDefaultReset", "Reset Chat default")} onClick={resetChatDefaultValue}>{t("settings.projectModels.reset", " Reset ")}</button>)} {chatDefaultCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.chatDefaultReset", "Reset Chat default")} onClick={resetChatDefaultValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div> </div>
@@ -564,7 +617,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<select id="chatDefaultAgentId" value={form.chatDefaultAgentId ?? ""} disabled={agentsLoading || agents.length === 0} onChange={(event) => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultAgentId: event.target.value || undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState))}> <select id="chatDefaultAgentId" value={form.chatDefaultAgentId ?? ""} disabled={agentsLoading || agents.length === 0} onChange={(event) => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultAgentId: event.target.value || undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState))}>
<option value="">{agentsLoading ? t("settings.projectModels.loadingAgents", "Loading agents…") : t("settings.projectModels.selectChatDefaultAgent", "Select a chat default agent")}</option> <option value="">{agentsLoading ? t("settings.projectModels.loadingAgents", "Loading agents…") : t("settings.projectModels.selectChatDefaultAgent", "Select a chat default agent")}</option>
{agents.map((agent) => (<option key={agent.id} value={agent.id}>{agent.name} ({agent.role})</option>))} {agents.map((agent) => (<option key={agent.id} value={agent.id}>{agent.name} ({agent.role})</option>))}
</select> </select>
@@ -587,6 +640,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const value = modelPairValue(effectiveWorkflowValues, pair); const value = modelPairValue(effectiveWorkflowValues, pair);
const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined; const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined;
const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : ""; const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : "";
const rawCredentialInstanceId = pair.credentialInstanceId ? effectiveWorkflowValues[pair.credentialInstanceId] : undefined;
const credentialInstanceId = typeof rawCredentialInstanceId === "string" ? rawCredentialInstanceId : "";
const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId) const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
? workflowPending[pair.providerId] !== null ? workflowPending[pair.providerId] !== null
: Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId) : Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId)
@@ -596,8 +651,13 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
? workflowPending[pair.thinkingId] !== null ? workflowPending[pair.thinkingId] !== null
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId))) : Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId)))
: false; : false;
const customized = modelCustomized || thinkingCustomized; const credentialInstanceCustomized = pair.credentialInstanceId
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined); ? (Object.prototype.hasOwnProperty.call(workflowPending, pair.credentialInstanceId)
? workflowPending[pair.credentialInstanceId] !== null
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.credentialInstanceId)))
: false;
const customized = modelCustomized || thinkingCustomized || credentialInstanceCustomized;
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined) ?? (pair.credentialInstanceId ? workflowRejections[pair.credentialInstanceId]?.message : undefined);
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}> return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
<div className="settings-model-lane-label-row"> <div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label> <label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
@@ -609,7 +669,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/> <CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={pair.credentialInstanceId ? (instanceId) => setWorkflowCredentialInstanceValue(pair, instanceId) : undefined} showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
</div> </div>
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)} {customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div> </div>
@@ -687,7 +747,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
<label htmlFor="preset-executor-model">{t("settings.projectModels.executorModel", "Executor model")}</label> <label htmlFor="preset-executor-model">{t("settings.projectModels.executorModel", "Executor model")}</label>
<CustomModelDropdown id="preset-executor-model" label="Preset executor model" models={availableModels} value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""} onChange={(val) => { <CustomModelDropdown id="preset-executor-model" label="Preset executor model" models={availableModels} value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""} onChange={(val) => {
if (!val) { if (!val) {
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current); setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined, executorCredentialInstanceId: undefined } : current);
return; return;
} }
const slashIdx = val.indexOf("/"); const slashIdx = val.indexOf("/");
@@ -695,14 +755,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current, ...current,
executorProvider: val.slice(0, slashIdx), executorProvider: val.slice(0, slashIdx),
executorModelId: val.slice(slashIdx + 1), executorModelId: val.slice(slashIdx + 1),
executorCredentialInstanceId: undefined,
} : current); } : current);
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/> }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.executorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { executorCredentialInstanceId: instanceId } : { executorCredentialInstanceId: undefined }) } : current)}/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="preset-validator-model">{t("settings.projectModels.reviewerModel", "Reviewer model")}</label> <label htmlFor="preset-validator-model">{t("settings.projectModels.reviewerModel", "Reviewer model")}</label>
<CustomModelDropdown id="preset-validator-model" label="Preset reviewer model" models={availableModels} value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""} onChange={(val) => { <CustomModelDropdown id="preset-validator-model" label="Preset reviewer model" models={availableModels} value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""} onChange={(val) => {
if (!val) { if (!val) {
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current); setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined, validatorCredentialInstanceId: undefined } : current);
return; return;
} }
const slashIdx = val.indexOf("/"); const slashIdx = val.indexOf("/");
@@ -710,8 +771,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current, ...current,
validatorProvider: val.slice(0, slashIdx), validatorProvider: val.slice(0, slashIdx),
validatorModelId: val.slice(slashIdx + 1), validatorModelId: val.slice(slashIdx + 1),
validatorCredentialInstanceId: undefined,
} : current); } : current);
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/> }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.validatorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { validatorCredentialInstanceId: instanceId } : { validatorCredentialInstanceId: undefined }) } : current)}/>
</div> </div>
</>)} </>)}
</div> </div>
@@ -802,7 +864,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div> </div>
<div className="settings-model-lane-control-row"> <div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main"> <div className="settings-model-lane-control-main">
<CustomModelDropdown id="titleSummarizerFallbackModel" label="Title Summarizer Fallback Model" models={availableModels} value={titleSummarizerFallbackValue} onChange={setTitleSummarizerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={titleSummarizerFallbackThinkingValue} onThinkingLevelChange={setTitleSummarizerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/> <CustomModelDropdown id="titleSummarizerFallbackModel" label="Title Summarizer Fallback Model" models={availableModels} value={titleSummarizerFallbackValue} onChange={setTitleSummarizerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={titleSummarizerFallbackThinkingValue} onThinkingLevelChange={setTitleSummarizerFallbackThinkingValue} credentialInstanceId={form.titleSummarizerFallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, titleSummarizerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div> </div>
{titleSummarizerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetTitleSummarizerFallbackValue} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)} {titleSummarizerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetTitleSummarizerFallbackValue} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div> </div>

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { useState } from "react"; import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectModelsSection } from "../ProjectModelsSection"; import { ProjectModelsSection } from "../ProjectModelsSection";
@@ -25,7 +26,7 @@ vi.mock("../../../../api", async (importOriginal) => {
}); });
vi.mock("../../../CustomModelDropdown", () => ({ vi.mock("../../../CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, label, value, onChange, showThinkingLevel, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel }: { CustomModelDropdown: ({ id, label, value, onChange, showThinkingLevel, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel, credentialInstanceId, onCredentialInstanceChange }: {
id?: string; id?: string;
label: string; label: string;
value?: string; value?: string;
@@ -34,10 +35,13 @@ vi.mock("../../../CustomModelDropdown", () => ({
thinkingLevel?: string; thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void; onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string; defaultThinkingLevel?: string;
credentialInstanceId?: string;
onCredentialInstanceChange?: (value: string) => void;
}) => ( }) => (
<div data-testid={`mock-model-host-${id ?? label}`} data-value={value ?? ""} data-default-thinking={defaultThinkingLevel ?? ""}> <div data-testid={`mock-model-host-${id ?? label}`} data-value={value ?? ""} data-default-thinking={defaultThinkingLevel ?? ""}>
<button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}>{label}</button> <button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}>{label}</button>
{showThinkingLevel ? <button type="button" data-testid={`mock-thinking-${id ?? label}`} onClick={() => onThinkingLevelChange?.(thinkingLevel ? "" : "high")}>thinking:{thinkingLevel || "inherit"}</button> : null} {showThinkingLevel ? <button type="button" data-testid={`mock-thinking-${id ?? label}`} onClick={() => onThinkingLevelChange?.(thinkingLevel ? "" : "high")}>thinking:{thinkingLevel || "inherit"}</button> : null}
{onCredentialInstanceChange ? <><button type="button" data-testid={`mock-instance-${id ?? label}`} onClick={() => onCredentialInstanceChange("backup")}>instance:{credentialInstanceId || "default"}</button><button type="button" data-testid={`mock-instance-default-${id ?? label}`} onClick={() => onCredentialInstanceChange("")}>default instance</button></> : null}
</div> </div>
), ),
})); }));
@@ -189,4 +193,31 @@ describe("ProjectModelsSection Chat default settings", () => {
chatDefaultThinkingLevel: undefined, chatDefaultThinkingLevel: undefined,
}); });
}); });
it("round-trips a project lane credential instance and clears it with Default", async () => {
const user = userEvent.setup();
const lane = {
laneId: "merger",
label: "Merger Model",
globalProviderKey: "mergerGlobalProvider",
globalModelKey: "mergerGlobalModelId",
projectProviderKey: "mergerProvider",
projectModelKey: "mergerModelId",
helperText: "",
fallbackOrder: "",
} as ProjectModelsSectionModelProps["modelLanes"][number];
const laneModels = { ...models, modelLanes: [lane] };
let latestForm: SettingsFormState = { mergerProvider: "anthropic", mergerModelId: "claude-sonnet-4-5" } as SettingsFormState;
function Host() {
const [form, setForm] = useState(latestForm);
latestForm = form;
return <ProjectModelsSection form={form} setForm={setForm} models={laneModels} projectId="project-1" addToast={vi.fn()} />;
}
render(<Host />);
await user.click(screen.getByTestId("mock-instance-mergerModel"));
expect(latestForm.mergerCredentialInstanceId).toBe("backup");
await user.click(screen.getByTestId("mock-instance-default-mergerModel"));
expect(latestForm.mergerCredentialInstanceId).toBeUndefined();
});
}); });

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { updateGlobalSettings, type ModelInfo } from "../api"; import { updateGlobalSettings, type ModelInfo, type ProviderCredentialInstanceSummary } from "../api";
import { useModelsCache } from "./useModelsCache"; import { useModelsCache } from "./useModelsCache";
/** /**
@@ -9,6 +9,7 @@ export interface UseFavoritesResult {
availableModels: ModelInfo[]; availableModels: ModelInfo[];
favoriteProviders: string[]; favoriteProviders: string[];
favoriteModels: string[]; favoriteModels: string[];
providerInstances: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
toggleFavoriteProvider: (provider: string) => Promise<void>; toggleFavoriteProvider: (provider: string) => Promise<void>;
toggleFavoriteModel: (modelId: string) => Promise<void>; toggleFavoriteModel: (modelId: string) => Promise<void>;
} }
@@ -17,7 +18,7 @@ export interface UseFavoritesResult {
* Loads model catalog + favorites and exposes optimistic favorite toggles. * Loads model catalog + favorites and exposes optimistic favorite toggles.
*/ */
export function useFavorites(): UseFavoritesResult { export function useFavorites(): UseFavoritesResult {
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, refresh } = useModelsCache(); const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, providerInstances, refresh } = useModelsCache();
const [availableModels, setAvailableModels] = useState<ModelInfo[]>(models); const [availableModels, setAvailableModels] = useState<ModelInfo[]>(models);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders); const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels); const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
@@ -96,6 +97,7 @@ export function useFavorites(): UseFavoritesResult {
availableModels, availableModels,
favoriteProviders, favoriteProviders,
favoriteModels, favoriteModels,
providerInstances,
toggleFavoriteProvider, toggleFavoriteProvider,
toggleFavoriteModel, toggleFavoriteModel,
}; };

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchModels, type ModelInfo, type ModelsResponse } from "../api"; import { fetchModels, type ModelInfo, type ModelsResponse, type ProviderCredentialInstanceSummary } from "../api";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache"; import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
interface ModelsCacheState { interface ModelsCacheState {
@@ -8,6 +8,7 @@ interface ModelsCacheState {
favoriteModels: string[]; favoriteModels: string[];
defaultProvider: string | null; defaultProvider: string | null;
defaultModelId: string | null; defaultModelId: string | null;
providerInstances: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
} }
export interface UseModelsCacheResult extends ModelsCacheState { export interface UseModelsCacheResult extends ModelsCacheState {
@@ -21,6 +22,7 @@ const EMPTY_MODELS_STATE: ModelsCacheState = {
favoriteModels: [], favoriteModels: [],
defaultProvider: null, defaultProvider: null,
defaultModelId: null, defaultModelId: null,
providerInstances: {},
}; };
let inflight: Promise<ModelsResponse> | null = null; let inflight: Promise<ModelsResponse> | null = null;
@@ -41,6 +43,7 @@ function toModelsCacheState(response: ModelsResponse | null | undefined): Models
favoriteModels: response.favoriteModels ?? [], favoriteModels: response.favoriteModels ?? [],
defaultProvider: response.defaultProvider ?? null, defaultProvider: response.defaultProvider ?? null,
defaultModelId: response.defaultModelId ?? null, defaultModelId: response.defaultModelId ?? null,
providerInstances: response.providerInstances ?? {},
}; };
} }

View File

@@ -3341,6 +3341,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
planningModelId, planningModelId,
nodeId, nodeId,
thinkingLevel, thinkingLevel,
credentialInstanceId,
validatorCredentialInstanceId,
} = req.body; } = req.body;
// Validate taskIds // Validate taskIds
@@ -3360,13 +3362,18 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined; const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
const hasNodeId = nodeId !== undefined; const hasNodeId = nodeId !== undefined;
const hasThinkingLevel = thinkingLevel !== undefined; const hasThinkingLevel = thinkingLevel !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId && !hasThinkingLevel) { const hasCredentialInstance = credentialInstanceId !== undefined || validatorCredentialInstanceId !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId && !hasThinkingLevel && !hasCredentialInstance) {
throw badRequest("At least one model field, thinkingLevel, or nodeId must be provided"); throw badRequest("At least one model field, thinkingLevel, or nodeId must be provided");
} }
if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") { if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") {
throw badRequest("nodeId must be a string, null, or undefined"); throw badRequest("nodeId must be a string, null, or undefined");
} }
if ((credentialInstanceId !== undefined && credentialInstanceId !== null && typeof credentialInstanceId !== "string")
|| (validatorCredentialInstanceId !== undefined && validatorCredentialInstanceId !== null && typeof validatorCredentialInstanceId !== "string")) {
throw badRequest("credential instance IDs must be strings, null, or undefined");
}
if (thinkingLevel !== undefined && thinkingLevel !== null && (typeof thinkingLevel !== "string" || !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel))) { if (thinkingLevel !== undefined && thinkingLevel !== null && (typeof thinkingLevel !== "string" || !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel))) {
throw badRequest(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}, null, or undefined`); throw badRequest(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}, null, or undefined`);
} }
@@ -3430,6 +3437,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
planningModelId?: string | null; planningModelId?: string | null;
nodeId?: string | null; nodeId?: string | null;
thinkingLevel?: ThinkingLevel | null; thinkingLevel?: ThinkingLevel | null;
credentialInstanceId?: string | null;
validatorCredentialInstanceId?: string | null;
} = {}; } = {};
if (validatedExecutor.provider !== undefined) { if (validatedExecutor.provider !== undefined) {
updates.modelProvider = validatedExecutor.provider; updates.modelProvider = validatedExecutor.provider;
@@ -3459,6 +3468,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (thinkingLevel !== undefined) { if (thinkingLevel !== undefined) {
updates.thinkingLevel = thinkingLevel as ThinkingLevel | null; updates.thinkingLevel = thinkingLevel as ThinkingLevel | null;
} }
/*
FNXC:TaskBulkModels 2026-08-01-10:34:
Bulk model editing must persist explicit credential-instance choices and clear them on Default. Omitting unchanged lanes preserves the no-change sentinel rather than rewriting task overrides.
*/
if (credentialInstanceId !== undefined) updates.credentialInstanceId = credentialInstanceId;
if (validatorCredentialInstanceId !== undefined) updates.validatorCredentialInstanceId = validatorCredentialInstanceId;
// Update all tasks in parallel // Update all tasks in parallel
const updatePromises = taskIds.map(async (taskId) => { const updatePromises = taskIds.map(async (taskId) => {