Files
kandev/apps/web/components/settings/workflow-session-config-rule-card.tsx
T
2026-09-10 18:14:05 +01:00

380 lines
11 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
configOptionToModelOptions,
isModelConfigOption,
ModelConfigSelector,
type SelectConfigOption,
} from "@/components/model-config-selector";
import { ModelConfigResolutionStatus } from "@/components/settings/model-config-resolution-status";
import type { AvailableAgent } from "@/lib/types/http";
import type { ConfigureSessionOperation, ConfigureSessionRule } from "@/lib/types/workflow-actions";
import { reconcileConfigOptionValues } from "./profile-model-config";
import { useSessionConfigModelOptions } from "./use-session-config-model-options";
import {
type AgentChoice,
defaultModelForAgent,
operationRule,
} from "./workflow-session-config-shared";
import { settingsActionClassName, settingsControlClassName } from "./settings-control";
function modelSelectionForRule(
rule: ConfigureSessionRule,
configOptions: SelectConfigOption[],
selectedAgent: AvailableAgent | undefined,
) {
const modelConfigOption = configOptions.find(isModelConfigOption);
const modelOptions = modelConfigOption
? configOptionToModelOptions(modelConfigOption)
: (selectedAgent?.model_config?.available_models ?? []).map((model) => ({
id: model.id,
name: model.name,
description: model.description ?? (model.id !== model.name ? model.id : undefined),
usageMultiplier:
typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined,
}));
return {
modelOptions,
currentModel: currentModelForRule(rule, modelConfigOption, selectedAgent),
};
}
export function SessionConfigRuleCard({
rule,
index,
choices,
availableAgents,
readOnly,
onChange,
onRemove,
onResolutionPendingChange,
}: {
rule: ConfigureSessionRule;
index: number;
choices: AgentChoice[];
availableAgents: AvailableAgent[];
readOnly: boolean;
onChange: (rule: ConfigureSessionRule) => void;
onRemove: () => void;
onResolutionPendingChange: (key: string, pending: boolean) => void;
}) {
const selectedAgent = availableAgents.find((agent) => agent.name === rule.agent_name);
const {
configOptions,
configStatus,
configError,
configIsLoading,
isResolutionPending,
refreshConfig,
modelOptions,
currentModel,
handleRuleChange,
} = useSessionConfigRuleModelState({
rule,
index,
selectedAgent,
readOnly,
onChange,
onResolutionPendingChange,
});
return (
<div
className="space-y-3 rounded-md border bg-card p-3"
data-testid={`session-config-rule-${index}`}
>
<SessionConfigRuleHeader
rule={rule}
index={index}
choices={choices}
availableAgents={availableAgents}
readOnly={readOnly}
onChange={onChange}
onRemove={onRemove}
/>
{rule.operation === "set" && (
<SessionConfigRuleSettings
rule={rule}
modelOptions={modelOptions}
currentModel={currentModel}
configOptions={configOptions}
configStatus={configStatus}
configError={configError}
configIsLoading={configIsLoading}
isConfigResolutionPending={isResolutionPending}
onRetryConfig={refreshConfig}
readOnly={readOnly}
onChange={handleRuleChange}
/>
)}
</div>
);
}
function useSessionConfigRuleModelState({
rule,
index,
selectedAgent,
readOnly,
onChange,
onResolutionPendingChange,
}: {
rule: ConfigureSessionRule;
index: number;
selectedAgent: AvailableAgent | undefined;
readOnly: boolean;
onChange: (rule: ConfigureSessionRule) => void;
onResolutionPendingChange: (key: string, pending: boolean) => void;
}) {
const {
configOptions,
configStatus,
configError,
configIsLoading,
isConfigResolutionPending,
refreshConfig,
isConfigResolvedForRequest,
resolvedConfigOptions,
} = useSessionConfigModelOptions(selectedAgent, rule);
const selectedRuleModel = rule.operation === "set" ? rule.model : undefined;
const initialRuleModel = useRef(selectedRuleModel);
const hasUserSelectedModel = useRef(false);
const pendingKey = `${rule.agent_name}:${index}`;
const currentConfigOptions = rule.operation === "set" ? rule.config_options : undefined;
const reconciledConfigOptions = reconcileConfigOptionValues(
currentConfigOptions,
resolvedConfigOptions,
);
const needsReconciliation =
!readOnly &&
rule.operation === "set" &&
configStatus === "ok" &&
isConfigResolvedForRequest &&
JSON.stringify(reconciledConfigOptions) !== JSON.stringify(currentConfigOptions ?? {});
const isResolutionPending = isConfigResolutionPending || needsReconciliation;
useEffect(() => {
onResolutionPendingChange(pendingKey, isResolutionPending);
return () => onResolutionPendingChange(pendingKey, false);
}, [isResolutionPending, onResolutionPendingChange, pendingKey]);
useEffect(() => {
if (selectedRuleModel !== initialRuleModel.current) {
hasUserSelectedModel.current = true;
}
}, [selectedRuleModel]);
useEffect(() => {
if (
readOnly ||
!hasUserSelectedModel.current ||
rule.operation !== "set" ||
configStatus !== "ok" ||
!isConfigResolvedForRequest
) {
return;
}
const nextConfigOptions = reconciledConfigOptions;
if (JSON.stringify(nextConfigOptions) === JSON.stringify(currentConfigOptions ?? {})) {
return;
}
const nextRule = { ...rule };
if (Object.keys(nextConfigOptions).length > 0) {
nextRule.config_options = nextConfigOptions;
} else {
delete nextRule.config_options;
}
onChange(nextRule);
}, [configStatus, isConfigResolvedForRequest, onChange, readOnly, reconciledConfigOptions, rule]);
const { modelOptions, currentModel } = modelSelectionForRule(rule, configOptions, selectedAgent);
const handleRuleChange = (nextRule: ConfigureSessionRule) => {
const nextModel = nextRule.operation === "set" ? nextRule.model : undefined;
if (nextModel !== selectedRuleModel) {
hasUserSelectedModel.current = true;
}
onChange(nextRule);
};
return {
configOptions,
configStatus,
configError,
configIsLoading,
isResolutionPending,
refreshConfig,
modelOptions,
currentModel,
handleRuleChange,
};
}
function SessionConfigRuleHeader({
rule,
index,
choices,
availableAgents,
readOnly,
onChange,
onRemove,
}: {
rule: ConfigureSessionRule;
index: number;
choices: AgentChoice[];
availableAgents: AvailableAgent[];
readOnly: boolean;
onChange: (rule: ConfigureSessionRule) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Label className="text-xs font-medium text-muted-foreground sm:w-24">
{t("workflows:whenAgentIs")}
</Label>
<Select
value={rule.agent_name}
onValueChange={(agentName) =>
onChange({
agent_name: agentName,
operation: rule.operation,
...(rule.operation === "set"
? { model: defaultModelForAgent(agentName, availableAgents) }
: {}),
})
}
disabled={readOnly}
>
<SelectTrigger
className={settingsControlClassName("w-full cursor-pointer sm:flex-1")}
data-testid={`session-config-agent-${index}`}
>
<SelectValue placeholder={t("workflows:chooseAgentFamily")} />
</SelectTrigger>
<SelectContent>
{choices.map((choice) => (
<SelectItem key={choice.name} value={choice.name}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={rule.operation}
onValueChange={(operation) =>
onChange(operationRule(rule, operation as ConfigureSessionOperation, availableAgents))
}
disabled={readOnly}
>
<SelectTrigger
className={settingsControlClassName("w-full cursor-pointer sm:w-44")}
data-testid={`session-config-operation-${index}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">{t("workflows:setSettings")}</SelectItem>
<SelectItem value="keep">{t("workflows:keepCurrent")}</SelectItem>
<SelectItem value="restore_original">{t("workflows:restoreOriginal")}</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
className={settingsActionClassName(
"cursor-pointer self-end text-destructive hover:text-destructive sm:self-auto",
)}
onClick={onRemove}
disabled={readOnly}
aria-label={t("workflows:removeAgentCondition", { index: index + 1 })}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
);
}
function currentModelForRule(
rule: ConfigureSessionRule,
modelConfigOption: SelectConfigOption | undefined,
selectedAgent: AvailableAgent | undefined,
): string | null {
return (
(rule.operation === "set" ? rule.model : undefined) ||
modelConfigOption?.currentValue ||
selectedAgent?.model_config?.current_model_id ||
selectedAgent?.model_config?.default_model ||
null
);
}
function SessionConfigRuleSettings({
rule,
modelOptions,
currentModel,
configOptions,
configStatus,
configError,
configIsLoading,
isConfigResolutionPending,
onRetryConfig,
readOnly,
onChange,
}: {
rule: Extract<ConfigureSessionRule, { operation: "set" }>;
modelOptions: { id: string; name: string; description?: string; usageMultiplier?: string }[];
currentModel: string | null;
configOptions: SelectConfigOption[];
configStatus: ReturnType<typeof useSessionConfigModelOptions>["configStatus"];
configError: string | null;
configIsLoading: boolean;
isConfigResolutionPending: boolean;
onRetryConfig: () => Promise<void>;
readOnly: boolean;
onChange: (rule: ConfigureSessionRule) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2 border-t border-border/60 pt-3">
{modelOptions.length > 0 || configOptions.length > 0 ? (
<ModelConfigSelector
modelOptions={modelOptions}
currentModel={currentModel}
configOptions={configOptions}
onModelChange={(model) => onChange({ ...rule, model })}
onConfigChange={(configId, value) =>
onChange({
...rule,
config_options: { ...(rule.config_options ?? {}), [configId]: value },
})
}
disabled={readOnly}
placeholder={t("workflows:chooseModelAndSessionSettings")}
ariaLabel={t("workflows:settingsForAgent", { agent: rule.agent_name })}
triggerClassName={settingsControlClassName("w-full")}
/>
) : (
<p className="rounded-md border border-border/60 p-2 text-xs text-muted-foreground">
{t("workflows:sessionConfigModelOptionsUnavailable")}
</p>
)}
<ModelConfigResolutionStatus
status={configStatus}
error={configError}
isLoading={configIsLoading || isConfigResolutionPending}
onRetry={onRetryConfig}
/>
</div>
);
}