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:
7
.changeset/fn-8666-credential-instance-picker.md
Normal file
7
.changeset/fn-8666-credential-instance-picker.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>);
|
||||||
})}
|
})}
|
||||||
</>)}
|
</>)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user