Use the shared model picker for workflow lane settings instead of raw provider/model text entry. - replace built-in workflow model provider/model string fields with combined dropdown controls in the Values tab - fetch available models and save or clear paired provider/model workflow values together, including fallback lanes - add regression coverage for dropdown rendering, selection, clearing, empty registries, and paired-key validation errors - update dashboard and settings documentation to describe the shared workflow model picker behavior Files changed: docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 22 +- packages/dashboard/app/components/WorkflowSettingsPanel.tsx | 241 +++++++++++++++++++-- packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 133 +++++++++++- 4 files changed, 369 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-6026 Fusion-Task-Lineage: 7f41120f-f764-4011-9aa5-a323e0aa64d6
1086 lines
41 KiB
TypeScript
1086 lines
41 KiB
TypeScript
/**
|
|
* WorkflowSettingsPanel — the workflow editor's typed-settings surface (U6, R5).
|
|
* Sibling to {@link WorkflowFieldsPanel} and {@link WorkflowColumnPanel}: lives
|
|
* alongside the canvas in {@link WorkflowNodeEditor}. It is ONE panel with an
|
|
* internal TAB PAIR (KTD-1/KTD-2):
|
|
*
|
|
* - "Definitions" — declare/edit the workflow's typed settings (id, name, type,
|
|
* default, options for enum kinds, description, widget). Edits mutate
|
|
* `ir.settings` through the editor's shared `settings`/`onChange` state and
|
|
* ride the editor's existing IR Save flow; validation runs server-side at save
|
|
* (parseWorkflowIr) and surfaces through the editor's error band. Built-in
|
|
* workflows render this tab read-only (declarations are not editable; values
|
|
* are — KTD-2).
|
|
*
|
|
* - "Values" — per-PROJECT setting values for the project active when the panel
|
|
* opened. Values batch in panel state and commit through a DEDICATED "Save
|
|
* values" button that sends ONE PATCH to the value authority route — never
|
|
* per-field writes, never fused with the IR Save (the two write authorities
|
|
* stay separate, KTD-2). Per-field typed rejections render on the matching
|
|
* rows. Below the live list, a collapsible "Orphaned values" disclosure (KTD-6
|
|
* drop-on-orphan) shows stored values that no longer validate against the
|
|
* current declarations, each with a delete affordance (null patch).
|
|
*
|
|
* The Values tab BINDS the projectId at open. If the dashboard's active project
|
|
* changes while the editor is open, it shows a stale-context notice instead of
|
|
* silently rebinding writes. With no active project it shows a requires-project
|
|
* state and no write path.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Plus, Trash2, AlertTriangle, ChevronRight, ChevronDown, Save, RotateCcw } from "lucide-react";
|
|
import type {
|
|
WorkflowSettingDefinition,
|
|
WorkflowSettingType,
|
|
WorkflowSettingOption,
|
|
WorkflowSettingRejection,
|
|
} from "../api";
|
|
import {
|
|
fetchModels,
|
|
fetchWorkflowSettingValues,
|
|
updateWorkflowSettingValues,
|
|
ApiRequestError,
|
|
type ModelInfo,
|
|
type WorkflowSettingValuesPayload,
|
|
} from "../api";
|
|
import {
|
|
getWorkflowSettingDisplay,
|
|
groupWorkflowSettings,
|
|
WORKFLOW_SETTING_GROUP_LABELS,
|
|
} from "./workflow-setting-display";
|
|
import {
|
|
SettingsFieldRow,
|
|
SettingsToggleRow,
|
|
SettingsNumberRow,
|
|
SettingsSelectRow,
|
|
SettingsTextRow,
|
|
SettingsTextareaRow,
|
|
} from "./settings";
|
|
import type { ToastType } from "../hooks/useToast";
|
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
|
import "./WorkflowSettingsPanel.css";
|
|
|
|
interface WorkflowSettingsPanelProps {
|
|
/** The workflow whose settings are being authored. */
|
|
workflowId: string;
|
|
/** Setting declarations (mirrors WorkflowFieldsPanel's `fields`). */
|
|
settings: WorkflowSettingDefinition[];
|
|
/** Mutate the declarations (rides the editor's IR save flow). */
|
|
onChange: (next: WorkflowSettingDefinition[]) => void;
|
|
/** Built-in workflows: declarations read-only; values editable (KTD-2). */
|
|
readOnly: boolean;
|
|
/** The active project id, bound for the Values tab at panel open. Undefined =
|
|
* no active project (Values tab shows a requires-project state). */
|
|
projectId?: string;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
initialTab?: "definitions" | "values";
|
|
}
|
|
|
|
const SETTING_TYPES: WorkflowSettingType[] = [
|
|
"string",
|
|
"text",
|
|
"number",
|
|
"boolean",
|
|
"enum",
|
|
"multi-enum",
|
|
];
|
|
|
|
/** Widgets valid per setting type (mirrors the SETTING_RENDER_WIDGETS whitelist
|
|
* client-side so the editor only offers legal combinations). */
|
|
const WIDGETS_BY_TYPE: Record<WorkflowSettingType, NonNullable<WorkflowSettingDefinition["render"]>["widget"][]> = {
|
|
string: ["input"],
|
|
text: ["textarea", "input"],
|
|
number: ["input"],
|
|
boolean: ["toggle"],
|
|
enum: ["select", "radio", "chips"],
|
|
"multi-enum": ["chips"],
|
|
};
|
|
|
|
/** Preset palette for enum option colors (matches WorkflowFieldsPanel). */
|
|
const PRESET_COLORS = [
|
|
"#4f7cff",
|
|
"#22c55e",
|
|
"#f59e0b",
|
|
"#ef4444",
|
|
"#a855f7",
|
|
"#06b6d4",
|
|
"#ec4899",
|
|
"#64748b",
|
|
];
|
|
|
|
function isEnumKind(type: WorkflowSettingType): boolean {
|
|
return type === "enum" || type === "multi-enum";
|
|
}
|
|
|
|
function kebab(raw: string): string {
|
|
return raw
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
let settingSeq = 0;
|
|
function newSettingId(): string {
|
|
settingSeq += 1;
|
|
return `setting-${Date.now().toString(36)}-${settingSeq}`;
|
|
}
|
|
|
|
// ─── Definitions tab ─────────────────────────────────────────────────────────
|
|
|
|
function DefinitionsTab({
|
|
settings,
|
|
onChange,
|
|
readOnly,
|
|
addToast,
|
|
}: Pick<WorkflowSettingsPanelProps, "settings" | "onChange" | "readOnly" | "addToast">) {
|
|
const { t } = useTranslation("app");
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
const patchSetting = useCallback(
|
|
(id: string, patch: Partial<WorkflowSettingDefinition>) => {
|
|
onChange(settings.map((s) => (s.id === id ? { ...s, ...patch } : s)));
|
|
},
|
|
[settings, onChange],
|
|
);
|
|
|
|
const addSetting = useCallback(() => {
|
|
onChange([
|
|
...settings,
|
|
{ id: newSettingId(), name: t("workflowSettings.newSettingName", "New setting"), type: "string" },
|
|
]);
|
|
}, [settings, onChange, t]);
|
|
|
|
const removeSetting = useCallback(
|
|
(id: string) => onChange(settings.filter((s) => s.id !== id)),
|
|
[settings, onChange],
|
|
);
|
|
|
|
const changeId = useCallback(
|
|
(oldId: string, raw: string) => {
|
|
const next = kebab(raw);
|
|
if (!next) return;
|
|
if (next !== oldId && settings.some((s) => s.id === next)) {
|
|
addToast(t("workflowSettings.duplicateId", "A setting with that id already exists"), "error");
|
|
return;
|
|
}
|
|
patchSetting(oldId, { id: next });
|
|
},
|
|
[settings, patchSetting, addToast, t],
|
|
);
|
|
|
|
const changeType = useCallback(
|
|
(id: string, type: WorkflowSettingType) => {
|
|
const setting = settings.find((s) => s.id === id);
|
|
if (!setting) return;
|
|
const patch: Partial<WorkflowSettingDefinition> = { type };
|
|
if (isEnumKind(type)) {
|
|
if (!setting.options || setting.options.length === 0) {
|
|
patch.options = [{ value: "option-1", label: t("workflowSettings.newOptionLabel", "Option 1") }];
|
|
}
|
|
} else {
|
|
patch.options = undefined;
|
|
}
|
|
if (setting.render?.widget && !WIDGETS_BY_TYPE[type].includes(setting.render.widget)) {
|
|
patch.render = undefined;
|
|
}
|
|
// Default value type changed — clear it to avoid a type-mismatch at save.
|
|
patch.default = undefined;
|
|
patchSetting(id, patch);
|
|
},
|
|
[settings, patchSetting, t],
|
|
);
|
|
|
|
const setOptions = useCallback(
|
|
(id: string, options: WorkflowSettingOption[]) => patchSetting(id, { options }),
|
|
[patchSetting],
|
|
);
|
|
|
|
const renderDefaultInput = (setting: WorkflowSettingDefinition) => {
|
|
const commit = (value: unknown) => patchSetting(setting.id, { default: value });
|
|
if (setting.type === "boolean") {
|
|
return (
|
|
<label className="wf-setting--checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={setting.default === true}
|
|
disabled={readOnly}
|
|
onChange={(e) => commit(e.target.checked)}
|
|
/>
|
|
<span>{t("workflowSettings.defaultTrue", "Default on")}</span>
|
|
</label>
|
|
);
|
|
}
|
|
if (isEnumKind(setting.type)) {
|
|
const current =
|
|
setting.type === "multi-enum"
|
|
? Array.isArray(setting.default)
|
|
? (setting.default as string[])[0] ?? ""
|
|
: ""
|
|
: typeof setting.default === "string"
|
|
? setting.default
|
|
: "";
|
|
return (
|
|
<select
|
|
aria-label={t("workflowSettings.defaultLabel", "Default value")}
|
|
value={current}
|
|
disabled={readOnly}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
if (v === "") return commit(undefined);
|
|
commit(setting.type === "multi-enum" ? [v] : v);
|
|
}}
|
|
>
|
|
<option value="">{t("workflowSettings.noDefault", "— none —")}</option>
|
|
{(setting.options ?? []).map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
const typeAttr = setting.type === "number" ? "number" : "text";
|
|
const currentText =
|
|
setting.type === "number"
|
|
? typeof setting.default === "number"
|
|
? String(setting.default)
|
|
: ""
|
|
: typeof setting.default === "string"
|
|
? setting.default
|
|
: "";
|
|
return (
|
|
<input
|
|
type={typeAttr}
|
|
aria-label={t("workflowSettings.defaultLabel", "Default value")}
|
|
defaultValue={currentText}
|
|
disabled={readOnly}
|
|
onBlur={(e) => {
|
|
const raw = e.target.value;
|
|
if (raw === "") return commit(undefined);
|
|
commit(setting.type === "number" ? Number(raw) : raw);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="wf-settings-tabpanel" data-testid="wf-settings-definitions">
|
|
<div className="wf-settings-tabpanel-head">
|
|
<button
|
|
className="wf-settings-add"
|
|
onClick={addSetting}
|
|
disabled={readOnly}
|
|
title={readOnly ? t("workflowSettings.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
|
|
>
|
|
<Plus size={13} /> {t("workflowSettings.add", "Add setting")}
|
|
</button>
|
|
</div>
|
|
|
|
{readOnly && (
|
|
<p className="wf-settings-note wf-settings-note--info" role="note">
|
|
{t("workflowSettings.builtinDefinitionsReadOnly", "Built-in workflow — declarations are read-only. Values are editable below.")}
|
|
</p>
|
|
)}
|
|
|
|
{settings.length === 0 ? (
|
|
<p className="wf-settings-empty">
|
|
{t("workflowSettings.empty", "No settings declared yet. Add a setting to expose a typed, per-project knob.")}
|
|
</p>
|
|
) : (
|
|
<ul className="wf-settings-list">
|
|
{settings.map((setting) => {
|
|
const widgets = WIDGETS_BY_TYPE[setting.type];
|
|
const idEditing = editingId === setting.id;
|
|
return (
|
|
<li key={setting.id} className="wf-setting-item" data-testid={`wf-setting-${setting.id}`}>
|
|
<div className="wf-setting-item-head">
|
|
<input
|
|
className="wf-setting-name"
|
|
aria-label={t("workflowSettings.nameLabel", "Setting name")}
|
|
value={setting.name}
|
|
disabled={readOnly}
|
|
onChange={(e) => patchSetting(setting.id, { name: e.target.value })}
|
|
/>
|
|
<button
|
|
className="wf-setting-remove"
|
|
aria-label={t("workflowSettings.remove", "Remove setting")}
|
|
disabled={readOnly}
|
|
onClick={() => removeSetting(setting.id)}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="wf-setting-id-row">
|
|
{idEditing ? (
|
|
<>
|
|
<input
|
|
className="wf-setting-id"
|
|
aria-label={t("workflowSettings.idLabel", "Setting id")}
|
|
defaultValue={setting.id}
|
|
disabled={readOnly}
|
|
onBlur={(e) => {
|
|
changeId(setting.id, e.target.value);
|
|
setEditingId(null);
|
|
}}
|
|
/>
|
|
<p className="wf-setting-id-warn" role="note">
|
|
<AlertTriangle size={11} aria-hidden />{" "}
|
|
{t("workflowSettings.idWarn", "Changing the id discards values stored under the old id (remove + add).")}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<code className="wf-setting-id-static">{setting.id}</code>
|
|
<button
|
|
className="wf-setting-id-edit"
|
|
disabled={readOnly}
|
|
onClick={() => setEditingId(setting.id)}
|
|
>
|
|
{t("workflowSettings.editId", "Edit id")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="wf-setting-row">
|
|
<label className="wf-setting-sub">
|
|
<span>{t("workflowSettings.typeLabel", "Type")}</span>
|
|
<select
|
|
value={setting.type}
|
|
disabled={readOnly}
|
|
onChange={(e) => changeType(setting.id, e.target.value as WorkflowSettingType)}
|
|
>
|
|
{SETTING_TYPES.map((ty) => (
|
|
<option key={ty} value={ty}>
|
|
{ty}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="wf-setting-sub">
|
|
<span>{t("workflowSettings.widget", "Widget")}</span>
|
|
<select
|
|
value={setting.render?.widget ?? ""}
|
|
disabled={readOnly}
|
|
onChange={(e) =>
|
|
patchSetting(setting.id, {
|
|
render: e.target.value
|
|
? { widget: e.target.value as NonNullable<WorkflowSettingDefinition["render"]>["widget"] }
|
|
: undefined,
|
|
})
|
|
}
|
|
>
|
|
<option value="">{t("workflowSettings.widgetDefault", "Default")}</option>
|
|
{widgets.map((w) => (
|
|
<option key={w} value={w}>
|
|
{w}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label className="wf-setting-sub">
|
|
<span>{t("workflowSettings.default", "Default")}</span>
|
|
{renderDefaultInput(setting)}
|
|
</label>
|
|
|
|
<label className="wf-setting-sub">
|
|
<span>{t("workflowSettings.description", "Description")}</span>
|
|
<input
|
|
aria-label={t("workflowSettings.descriptionLabel", "Setting description")}
|
|
value={setting.description ?? ""}
|
|
disabled={readOnly}
|
|
onChange={(e) => patchSetting(setting.id, { description: e.target.value || undefined })}
|
|
/>
|
|
</label>
|
|
|
|
{isEnumKind(setting.type) && (
|
|
<div className="wf-setting-options" data-testid={`wf-setting-options-${setting.id}`}>
|
|
<span className="wf-setting-options-label">{t("workflowSettings.options", "Options")}</span>
|
|
{(setting.options ?? []).map((opt, i) => (
|
|
<div key={i} className="wf-setting-option-row">
|
|
<input
|
|
className="wf-setting-option-value"
|
|
aria-label={t("workflowSettings.optionValue", "Option value")}
|
|
value={opt.value}
|
|
disabled={readOnly}
|
|
onChange={(e) => {
|
|
const next = [...(setting.options ?? [])];
|
|
next[i] = { ...opt, value: e.target.value };
|
|
setOptions(setting.id, next);
|
|
}}
|
|
/>
|
|
<input
|
|
className="wf-setting-option-label"
|
|
aria-label={t("workflowSettings.optionLabel", "Option label")}
|
|
value={opt.label}
|
|
disabled={readOnly}
|
|
onChange={(e) => {
|
|
const next = [...(setting.options ?? [])];
|
|
next[i] = { ...opt, label: e.target.value };
|
|
setOptions(setting.id, next);
|
|
}}
|
|
/>
|
|
<div
|
|
className="wf-setting-option-colors"
|
|
role="group"
|
|
aria-label={t("workflowSettings.optionColor", "Option color")}
|
|
>
|
|
{PRESET_COLORS.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={`wf-setting-color-swatch${opt.color === c ? " is-active" : ""}`}
|
|
style={{ backgroundColor: c }}
|
|
aria-label={c}
|
|
aria-pressed={opt.color === c}
|
|
disabled={readOnly}
|
|
onClick={() => {
|
|
const next = [...(setting.options ?? [])];
|
|
next[i] = { ...opt, color: opt.color === c ? undefined : c };
|
|
setOptions(setting.id, next);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="wf-setting-option-remove"
|
|
aria-label={t("workflowSettings.removeOption", "Remove option")}
|
|
disabled={readOnly}
|
|
onClick={() => setOptions(setting.id, (setting.options ?? []).filter((_, j) => j !== i))}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
className="wf-setting-option-add"
|
|
disabled={readOnly}
|
|
onClick={() => {
|
|
const n = (setting.options ?? []).length + 1;
|
|
setOptions(setting.id, [
|
|
...(setting.options ?? []),
|
|
{ value: `option-${n}`, label: t("workflowSettings.optionN", "Option {{n}}", { n }) },
|
|
]);
|
|
}}
|
|
>
|
|
<Plus size={12} /> {t("workflowSettings.addOption", "Add option")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Values tab ──────────────────────────────────────────────────────────────
|
|
|
|
/** Stable display string for an orphaned/raw stored value. */
|
|
function rawValueDisplay(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
interface WorkflowModelLanePair {
|
|
id: string;
|
|
providerId: string;
|
|
modelId: string;
|
|
label: string;
|
|
help: string;
|
|
}
|
|
|
|
const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
|
|
{
|
|
id: "planning",
|
|
providerId: "planningProvider",
|
|
modelId: "planningModelId",
|
|
label: "Plan/Triage Model",
|
|
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.",
|
|
},
|
|
{
|
|
id: "execution",
|
|
providerId: "executionProvider",
|
|
modelId: "executionModelId",
|
|
label: "Executor Model",
|
|
help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.",
|
|
},
|
|
{
|
|
id: "validator",
|
|
providerId: "validatorProvider",
|
|
modelId: "validatorModelId",
|
|
label: "Reviewer Model",
|
|
help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.",
|
|
},
|
|
{
|
|
id: "planning-fallback",
|
|
providerId: "planningFallbackProvider",
|
|
modelId: "planningFallbackModelId",
|
|
label: "Planning Fallback Model",
|
|
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
|
|
},
|
|
{
|
|
id: "validator-fallback",
|
|
providerId: "validatorFallbackProvider",
|
|
modelId: "validatorFallbackModelId",
|
|
label: "Reviewer Fallback Model",
|
|
help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
|
|
},
|
|
{
|
|
id: "title-summarizer",
|
|
providerId: "titleSummarizerProvider",
|
|
modelId: "titleSummarizerModelId",
|
|
label: "Title Summarizer Model",
|
|
help: "Provider and model used for title summarization when this workflow declares the lane.",
|
|
},
|
|
{
|
|
id: "title-summarizer-fallback",
|
|
providerId: "titleSummarizerFallbackProvider",
|
|
modelId: "titleSummarizerFallbackModelId",
|
|
label: "Title Summarizer Fallback Model",
|
|
help: "Fallback provider and model used for title summarization when this workflow declares the lane.",
|
|
},
|
|
];
|
|
|
|
function splitModelDropdownValue(value: string): { provider: string; modelId: string } | null {
|
|
if (!value) return null;
|
|
const slashIdx = value.indexOf("/");
|
|
if (slashIdx <= 0 || slashIdx === value.length - 1) return null;
|
|
return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) };
|
|
}
|
|
|
|
function ValuesTab({
|
|
workflowId,
|
|
settings,
|
|
boundProjectId,
|
|
currentProjectId,
|
|
addToast,
|
|
}: {
|
|
workflowId: string;
|
|
settings: WorkflowSettingDefinition[];
|
|
/** projectId bound at panel open. Undefined → requires-project state. */
|
|
boundProjectId: string | undefined;
|
|
/** the dashboard's currently active project (may have changed since open). */
|
|
currentProjectId: string | undefined;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
}) {
|
|
const { t } = useTranslation("app");
|
|
const [payload, setPayload] = useState<WorkflowSettingValuesPayload | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
// Batched, per-key pending edits. `null` = clear-to-default (delete the row).
|
|
const [pending, setPending] = useState<Record<string, unknown>>({});
|
|
const [rejections, setRejections] = useState<Record<string, WorkflowSettingRejection>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [orphanOpen, setOrphanOpen] = useState(false);
|
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
|
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
|
const reqSeq = useRef(0);
|
|
const modelReqSeq = useRef(0);
|
|
|
|
const settingsById = useMemo(() => new Map(settings.map((setting) => [setting.id, setting])), [settings]);
|
|
const modelLanePairs = useMemo(
|
|
() =>
|
|
WORKFLOW_MODEL_LANE_CATALOG.filter((pair) => {
|
|
const provider = settingsById.get(pair.providerId);
|
|
const model = settingsById.get(pair.modelId);
|
|
return provider?.type === "string" && model?.type === "string";
|
|
}),
|
|
[settingsById],
|
|
);
|
|
const modelPairSettingIds = useMemo(
|
|
() => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId])),
|
|
[modelLanePairs],
|
|
);
|
|
|
|
const staleContext =
|
|
boundProjectId !== undefined && currentProjectId !== undefined && currentProjectId !== boundProjectId;
|
|
|
|
const load = useCallback(async () => {
|
|
if (boundProjectId === undefined) return;
|
|
const seq = ++reqSeq.current;
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetchWorkflowSettingValues(workflowId, boundProjectId);
|
|
if (reqSeq.current === seq) {
|
|
setPayload(res);
|
|
setPending({});
|
|
setRejections({});
|
|
}
|
|
} catch {
|
|
if (reqSeq.current === seq) addToast(t("workflowSettings.loadFailed", "Failed to load setting values"), "error");
|
|
} finally {
|
|
if (reqSeq.current === seq) setLoading(false);
|
|
}
|
|
}, [workflowId, boundProjectId, addToast, t]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (boundProjectId === undefined || modelLanePairs.length === 0) {
|
|
setAvailableModels([]);
|
|
setFavoriteProviders([]);
|
|
setFavoriteModels([]);
|
|
setModelsLoading(false);
|
|
return;
|
|
}
|
|
const seq = ++modelReqSeq.current;
|
|
setModelsLoading(true);
|
|
fetchModels()
|
|
.then((res) => {
|
|
if (modelReqSeq.current !== seq) return;
|
|
setAvailableModels(res.models ?? []);
|
|
setFavoriteProviders(res.favoriteProviders ?? []);
|
|
setFavoriteModels(res.favoriteModels ?? []);
|
|
})
|
|
.catch(() => {
|
|
if (modelReqSeq.current === seq) {
|
|
setAvailableModels([]);
|
|
setFavoriteProviders([]);
|
|
setFavoriteModels([]);
|
|
addToast(t("workflowSettings.modelsLoadFailed", "Failed to load available models"), "error");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (modelReqSeq.current === seq) setModelsLoading(false);
|
|
});
|
|
}, [boundProjectId, modelLanePairs.length, addToast, t]);
|
|
|
|
// No active project bound → requires-project state, no write path.
|
|
if (boundProjectId === undefined) {
|
|
return (
|
|
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
|
|
<p className="wf-settings-note wf-settings-note--info" role="note">
|
|
{t("workflowSettings.requiresProject", "Open a project to view and edit per-project setting values.")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The effective value to show for a setting: a pending edit (incl. a pending
|
|
// clear, which falls back to the declaration default) wins over the server
|
|
// effective value.
|
|
const effectiveOf = (setting: WorkflowSettingDefinition): unknown => {
|
|
if (Object.prototype.hasOwnProperty.call(pending, setting.id)) {
|
|
const p = pending[setting.id];
|
|
return p === null ? setting.default : p;
|
|
}
|
|
return payload?.effective?.[setting.id] ?? setting.default;
|
|
};
|
|
|
|
// "customized" iff a stored row holds this key (server) OR a pending non-clear
|
|
// edit exists; a pending clear removes the customized state.
|
|
const isCustomized = (setting: WorkflowSettingDefinition): boolean => {
|
|
if (Object.prototype.hasOwnProperty.call(pending, setting.id)) {
|
|
return pending[setting.id] !== null;
|
|
}
|
|
return payload ? Object.prototype.hasOwnProperty.call(payload.stored, setting.id) : false;
|
|
};
|
|
|
|
const setValue = (id: string, value: unknown) => {
|
|
setPending((prev) => ({ ...prev, [id]: value }));
|
|
setRejections((prev) => {
|
|
if (!prev[id]) return prev;
|
|
const next = { ...prev };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const clearValue = (id: string) => setValue(id, null);
|
|
|
|
const setModelPairValue = (pair: WorkflowModelLanePair, value: string) => {
|
|
const split = splitModelDropdownValue(value);
|
|
setPending((prev) => ({
|
|
...prev,
|
|
[pair.providerId]: split?.provider ?? null,
|
|
[pair.modelId]: split?.modelId ?? null,
|
|
}));
|
|
setRejections((prev) => {
|
|
if (!prev[pair.providerId] && !prev[pair.modelId]) return prev;
|
|
const next = { ...prev };
|
|
delete next[pair.providerId];
|
|
delete next[pair.modelId];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const dirty = Object.keys(pending).length > 0;
|
|
|
|
const save = useCallback(async () => {
|
|
if (!dirty) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId);
|
|
setPayload(res);
|
|
setPending({});
|
|
setRejections({});
|
|
addToast(t("workflowSettings.valuesSaved", "Setting values saved"), "success");
|
|
} catch (err) {
|
|
if (err instanceof ApiRequestError && err.status === 400 && err.details) {
|
|
const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? [];
|
|
if (rejList.length > 0) {
|
|
const byId: Record<string, WorkflowSettingRejection> = {};
|
|
for (const r of rejList) byId[r.settingId] = r;
|
|
setRejections(byId);
|
|
// The server persisted nothing on rejection (write-boundary contract):
|
|
// keep ALL pending edits applied so the user can fix the offending
|
|
// field(s) and re-save.
|
|
addToast(t("workflowSettings.valuesRejected", "Some values were rejected — see the highlighted fields"), "error");
|
|
return;
|
|
}
|
|
}
|
|
addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [dirty, workflowId, pending, boundProjectId, addToast, t]);
|
|
|
|
const valueOfSettingId = (id: string): unknown => {
|
|
const setting = settingsById.get(id);
|
|
return setting ? effectiveOf(setting) : undefined;
|
|
};
|
|
|
|
const isCustomizedId = (id: string): boolean => {
|
|
const setting = settingsById.get(id);
|
|
return setting ? isCustomized(setting) : false;
|
|
};
|
|
|
|
const renderModelPairControl = (pair: WorkflowModelLanePair) => {
|
|
const providerValue = valueOfSettingId(pair.providerId);
|
|
const modelValue = valueOfSettingId(pair.modelId);
|
|
const value = typeof providerValue === "string" && typeof modelValue === "string" ? `${providerValue}/${modelValue}` : "";
|
|
const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message;
|
|
const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId);
|
|
const dropdownDisabled = modelsLoading || availableModels.length === 0;
|
|
const emptyHelp =
|
|
!modelsLoading && availableModels.length === 0
|
|
? ` ${t("workflowSettings.noModelsAvailable", "No models are available. Configure authentication before selecting a workflow model.")}`
|
|
: "";
|
|
|
|
return (
|
|
<div key={pair.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${pair.id}`}>
|
|
<SettingsFieldRow
|
|
htmlFor={`wf-model-lane-${pair.id}`}
|
|
label={pair.label}
|
|
help={`${pair.help}${emptyHelp}`}
|
|
error={error}
|
|
scope="project"
|
|
disabled={modelsLoading}
|
|
clearable={customized}
|
|
onClear={() => setModelPairValue(pair, "")}
|
|
>
|
|
<CustomModelDropdown
|
|
id={`wf-model-lane-${pair.id}`}
|
|
label={pair.label}
|
|
models={availableModels}
|
|
value={value}
|
|
onChange={(next) => setModelPairValue(pair, next)}
|
|
placeholder={t("workflowSettings.selectModel", "Select a model…")}
|
|
defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")}
|
|
disabled={dropdownDisabled}
|
|
favoriteProviders={favoriteProviders}
|
|
favoriteModels={favoriteModels}
|
|
/>
|
|
</SettingsFieldRow>
|
|
{customized && (
|
|
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${pair.id}`}>
|
|
{t("workflowSettings.customized", "Customized")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderValueControl = (setting: WorkflowSettingDefinition) => {
|
|
const value = effectiveOf(setting);
|
|
const error = rejections[setting.id]?.message;
|
|
const customized = isCustomized(setting);
|
|
const display = getWorkflowSettingDisplay(setting);
|
|
const descriptor = {
|
|
key: setting.id,
|
|
label: display.label,
|
|
help: display.description ?? setting.description,
|
|
scope: "project" as const,
|
|
};
|
|
const clearable = customized;
|
|
|
|
switch (setting.type) {
|
|
case "boolean":
|
|
return (
|
|
<SettingsToggleRow
|
|
descriptor={descriptor}
|
|
value={value === true}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
|
|
/>
|
|
);
|
|
case "number":
|
|
return (
|
|
<SettingsNumberRow
|
|
descriptor={descriptor}
|
|
value={typeof value === "number" ? value : null}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
|
|
/>
|
|
);
|
|
case "enum":
|
|
return (
|
|
<SettingsSelectRow
|
|
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ value: o.value, label: o.label })) }}
|
|
value={typeof value === "string" ? value : null}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
|
|
/>
|
|
);
|
|
case "multi-enum": {
|
|
// No multi-select primitive in U8 yet; offer the first/clear via select.
|
|
const current = Array.isArray(value) ? (value as string[])[0] ?? null : null;
|
|
return (
|
|
<SettingsSelectRow
|
|
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ value: o.value, label: o.label })) }}
|
|
value={current}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, [v]))}
|
|
/>
|
|
);
|
|
}
|
|
case "text":
|
|
return (
|
|
<SettingsTextareaRow
|
|
descriptor={descriptor}
|
|
value={typeof value === "string" ? value : null}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))}
|
|
/>
|
|
);
|
|
case "string":
|
|
default:
|
|
return (
|
|
<SettingsTextRow
|
|
descriptor={descriptor}
|
|
value={typeof value === "string" ? value : null}
|
|
error={error}
|
|
clearable={clearable}
|
|
onChange={(v) => (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
const orphaned = payload?.orphaned ?? [];
|
|
|
|
const deleteOrphan = useCallback(
|
|
async (id: string) => {
|
|
try {
|
|
const res = await updateWorkflowSettingValues(workflowId, { [id]: null }, boundProjectId);
|
|
setPayload(res);
|
|
addToast(t("workflowSettings.orphanDeleted", "Orphaned value removed"), "success");
|
|
} catch {
|
|
addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error");
|
|
}
|
|
},
|
|
[workflowId, boundProjectId, addToast, t],
|
|
);
|
|
|
|
return (
|
|
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
|
|
{staleContext && (
|
|
<p className="wf-settings-note wf-settings-note--warn" role="note" data-testid="wf-settings-stale-notice">
|
|
<AlertTriangle size={12} aria-hidden />{" "}
|
|
{t(
|
|
"workflowSettings.staleContext",
|
|
"Values shown are for project {{project}} — reopen the editor to edit values for the current project.",
|
|
{ project: boundProjectId },
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
<div className="wf-settings-values-head">
|
|
<button
|
|
className="wf-settings-save-values"
|
|
onClick={save}
|
|
disabled={!dirty || saving || staleContext}
|
|
data-testid="wf-settings-save-values"
|
|
>
|
|
<Save size={13} /> {t("workflowSettings.saveValues", "Save values")}
|
|
</button>
|
|
</div>
|
|
|
|
{settings.length === 0 ? (
|
|
<p className="wf-settings-empty">
|
|
{loading
|
|
? t("workflowSettings.loading", "Loading…")
|
|
: t("workflowSettings.noDeclarations", "This workflow declares no settings, so there are no values to edit.")}
|
|
</p>
|
|
) : (
|
|
<div className="wf-settings-values-list">
|
|
{groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => {
|
|
const groupSettingIds = new Set(groupSettings.map((setting) => setting.id));
|
|
const groupPairs =
|
|
group === "models"
|
|
? modelLanePairs.filter(
|
|
(pair) => groupSettingIds.has(pair.providerId) && groupSettingIds.has(pair.modelId),
|
|
)
|
|
: [];
|
|
const primitiveSettings = groupSettings.filter((setting) => !modelPairSettingIds.has(setting.id));
|
|
|
|
return (
|
|
<section key={group} className="wf-settings-value-group" data-testid={`wf-settings-group-${group}`}>
|
|
<h4 className="wf-settings-value-group-title">
|
|
{t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
|
|
</h4>
|
|
{groupPairs.map((pair) => renderModelPairControl(pair))}
|
|
{primitiveSettings.map((setting) => (
|
|
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
|
|
{renderValueControl(setting)}
|
|
{isCustomized(setting) && (
|
|
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
|
|
{t("workflowSettings.customized", "Customized")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{orphaned.length > 0 && (
|
|
<div className="wf-settings-orphaned" data-testid="wf-settings-orphaned">
|
|
<button
|
|
className="wf-settings-orphaned-toggle"
|
|
onClick={() => setOrphanOpen((v) => !v)}
|
|
aria-expanded={orphanOpen}
|
|
>
|
|
{orphanOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}{" "}
|
|
{t("workflowSettings.orphanedTitle", "Orphaned values ({{count}})", { count: orphaned.length })}
|
|
</button>
|
|
{orphanOpen && (
|
|
<div className="wf-settings-orphaned-body">
|
|
<p className="wf-settings-note wf-settings-note--muted" role="note">
|
|
{t(
|
|
"workflowSettings.orphanedNote",
|
|
"These stored values no longer match a current declaration (the setting was retyped or removed). They are ignored by the engine; delete them to clean up.",
|
|
)}
|
|
</p>
|
|
<ul className="wf-settings-orphaned-list">
|
|
{orphaned.map((o) => (
|
|
<li key={o.id} className="wf-settings-orphaned-row" data-testid={`wf-settings-orphan-${o.id}`}>
|
|
<code className="wf-settings-orphan-id">{o.id}</code>
|
|
<span className="wf-settings-orphan-value">{rawValueDisplay(o.value)}</span>
|
|
<button
|
|
className="wf-settings-orphan-delete"
|
|
aria-label={t("workflowSettings.deleteOrphan", "Delete orphaned value")}
|
|
disabled={staleContext || saving}
|
|
onClick={() => deleteOrphan(o.id)}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{settings.length > 0 && (
|
|
<p className="wf-settings-reset-hint" role="note">
|
|
<RotateCcw size={11} aria-hidden />{" "}
|
|
{t("workflowSettings.clearHint", "Use the reset control on a row to clear a value back to its declaration default.")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Panel shell (tab pair) ──────────────────────────────────────────────────
|
|
|
|
export function WorkflowSettingsPanel({
|
|
workflowId,
|
|
settings,
|
|
onChange,
|
|
readOnly,
|
|
projectId,
|
|
addToast,
|
|
initialTab,
|
|
}: WorkflowSettingsPanelProps) {
|
|
const { t } = useTranslation("app");
|
|
const [tab, setTab] = useState<"definitions" | "values">(
|
|
() => initialTab ?? (settings.length > 0 ? "values" : "definitions"),
|
|
);
|
|
|
|
// Bind the projectId active when the panel first mounted for this workflow.
|
|
// The Values tab uses this bound id; a later change to `projectId` surfaces a
|
|
// stale-context notice rather than rebinding writes. Re-bind only when the
|
|
// workflow itself changes (the editor re-keys/remounts per active workflow).
|
|
const boundRef = useRef<{ workflowId: string; projectId: string | undefined }>({ workflowId, projectId });
|
|
if (boundRef.current.workflowId !== workflowId) {
|
|
boundRef.current = { workflowId, projectId };
|
|
}
|
|
const boundProjectId = boundRef.current.projectId;
|
|
|
|
return (
|
|
<aside className="wf-settings-panel" data-testid="wf-settings-panel">
|
|
<header className="wf-settings-panel-header">
|
|
<h3>{t("workflowSettings.title", "Settings")}</h3>
|
|
</header>
|
|
|
|
<div className="wf-settings-tabs" role="tablist">
|
|
<button
|
|
role="tab"
|
|
aria-selected={tab === "definitions"}
|
|
className={`wf-settings-tab${tab === "definitions" ? " is-active" : ""}`}
|
|
onClick={() => setTab("definitions")}
|
|
data-testid="wf-settings-tab-definitions"
|
|
>
|
|
{t("workflowSettings.definitionsTab", "Definitions")}
|
|
</button>
|
|
<button
|
|
role="tab"
|
|
aria-selected={tab === "values"}
|
|
className={`wf-settings-tab${tab === "values" ? " is-active" : ""}`}
|
|
onClick={() => setTab("values")}
|
|
data-testid="wf-settings-tab-values"
|
|
>
|
|
{t("workflowSettings.valuesTab", "Values")}
|
|
</button>
|
|
</div>
|
|
|
|
{tab === "definitions" ? (
|
|
<DefinitionsTab settings={settings} onChange={onChange} readOnly={readOnly} addToast={addToast} />
|
|
) : (
|
|
<ValuesTab
|
|
key={workflowId}
|
|
workflowId={workflowId}
|
|
settings={settings}
|
|
boundProjectId={boundProjectId}
|
|
currentProjectId={projectId}
|
|
addToast={addToast}
|
|
/>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export default WorkflowSettingsPanel;
|