Files
kandev/apps/web/components/task-create-dialog-autopick.ts
T

200 lines
8.1 KiB
TypeScript

"use client";
import { useEffect } from "react";
import type { AgentProfileOption } from "@/lib/state/slices";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { createDebugLogger } from "@/lib/debug/log";
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
/**
* Agent-profile autopick: the logic that decides which profile to pre-select
* when the task-create dialog opens (or when its inputs change). Lives in its
* own file so `task-create-dialog-effects.ts` stays under the 600-line lint
* cap. Tests in `task-create-dialog-effects.test.ts` exercise both effects
* through the same import surface re-exported from there.
*/
const autopickDebug = createDebugLogger("executor-compat:autopick");
const workflowAutopickDebug = createDebugLogger("executor-compat:workflow-autopick");
/**
* Pure decision function for the agent-profile autopick. Extracted so the
* effect can stay below the 100-line lint cap once the autopick trace logs
* are inlined, and so the same decision can be tested without rendering.
*
* Order: lastId (localStorage) → defId (workspace default) → first
* compatible. Every candidate is filtered against `compatibleAgentProfiles`
* so a previously-used profile that's not wired for the chosen executor
* isn't restored, then immediately fails the executor-compat gate.
*/
export type AutopickDecision =
| { kind: "skip"; reason: string }
| { kind: "defer"; reason: string }
| { kind: "pick"; source: "lastId" | "defId" | "first"; id: string };
export function decideAgentProfileAutopick(input: {
open: boolean;
agentProfileId: string;
workflowAgentProfileId: string;
workflowHasAgent: boolean;
agentProfiles: AgentProfileOption[];
compatibleAgentProfiles: AgentProfileOption[];
authLoaded: boolean;
executorProfileId: string;
hasExecutors: boolean;
defaultAgentProfileId: string | null;
}): AutopickDecision {
if (!input.open) return { kind: "skip", reason: "closed" };
if (input.agentProfileId) return { kind: "skip", reason: "already-set" };
if (input.workflowAgentProfileId) return { kind: "skip", reason: "workflow-locked" };
if (input.workflowHasAgent) return { kind: "skip", reason: "workflow-has-agent" };
if (input.agentProfiles.length === 0) return { kind: "skip", reason: "no-profiles" };
if (!input.authLoaded) return { kind: "defer", reason: "auth-not-loaded" };
// Defer until the executor profile is selected too - useExecutorProfileCompat
// short-circuits to the unfiltered list when `selectedExecutorProfile` is null,
// so without this gate we'd happily restore an incompatible lastId during the
// single render where `authLoaded` is already true but the executor
// auto-select hasn't queued through yet. Only deferrable if there ARE
// executors to pick from - otherwise the executor effect will never fire and
// we'd defer forever.
if (!input.executorProfileId && input.hasExecutors) {
return { kind: "defer", reason: "executor-not-selected" };
}
if (input.compatibleAgentProfiles.length === 0) return { kind: "skip", reason: "no-compatible" };
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
if (lastId && input.compatibleAgentProfiles.some((p) => p.id === lastId)) {
return { kind: "pick", source: "lastId", id: lastId };
}
const defId = input.defaultAgentProfileId;
if (defId && input.compatibleAgentProfiles.some((p) => p.id === defId)) {
return { kind: "pick", source: "defId", id: defId };
}
return { kind: "pick", source: "first", id: input.compatibleAgentProfiles[0].id };
}
export function useWorkflowAgentProfileEffect(
fs: DialogFormState,
workflows: Array<{ id: string; agent_profile_id?: string }>,
agentProfiles: AgentProfileOption[],
compatibleAgentProfiles: AgentProfileOption[],
) {
const { selectedWorkflowId, setAgentProfileId, setWorkflowAgentProfileId } = fs;
useEffect(() => {
if (!selectedWorkflowId) {
setWorkflowAgentProfileId("");
workflowAutopickDebug("no-workflow", { cleared: "workflowAgentProfileId" });
return;
}
const workflow = workflows.find((w) => w.id === selectedWorkflowId);
if (workflow?.agent_profile_id) {
// Always lock the selector when the workflow specifies an agent profile.
// This prevents the race condition where agentProfiles hasn't loaded yet.
setWorkflowAgentProfileId(workflow.agent_profile_id);
// Only set the agentProfileId once the profile is confirmed available.
const profileExists = agentProfiles.some((p) => p.id === workflow.agent_profile_id);
if (profileExists) {
setAgentProfileId(workflow.agent_profile_id);
workflowAutopickDebug("locked", {
workflow: selectedWorkflowId,
set_to: workflow.agent_profile_id,
});
} else {
workflowAutopickDebug("locked-missing", {
workflow: selectedWorkflowId,
missing: workflow.agent_profile_id,
});
}
} else {
setWorkflowAgentProfileId("");
// Restore the user's last-used agent profile when unlocking. Filter
// against `compatibleAgentProfiles` (not the full `agentProfiles` list)
// so an executor-incompatible id from a previous session - including
// stale UUIDs from a wiped DB - is dropped rather than restored.
// useDefaultSelectionsEffect would otherwise see agentProfileId become
// truthy, early-exit on "already-set", and leave the dialog stuck on
// "No compatible agent profiles".
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
const isValidLastId = Boolean(lastId && compatibleAgentProfiles.some((p) => p.id === lastId));
const finalId = isValidLastId && lastId ? lastId : "";
setAgentProfileId(finalId);
workflowAutopickDebug("workflow-no-override", {
last_id: lastId ?? "-",
valid: isValidLastId,
set_to: finalId || "-empty-",
});
}
}, [
selectedWorkflowId,
workflows,
agentProfiles,
compatibleAgentProfiles,
setAgentProfileId,
setWorkflowAgentProfileId,
]);
}
export function useAgentProfileAutopickEffect(
fs: DialogFormState,
open: boolean,
sel: StoreSelections,
workflows: Array<{ id: string; agent_profile_id?: string }>,
) {
const { agentProfiles, compatibleAgentProfiles, authLoaded, executors, workspaceDefaults } = sel;
const {
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
executorProfileId,
setAgentProfileId,
} = fs;
useEffect(() => {
// Check synchronously whether the selected workflow has an agent override.
// This avoids a race condition where workflowAgentProfileId state hasn't
// been committed yet by the workflow effect running in the same cycle.
const workflowHasAgent = selectedWorkflowId
? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id)
: false;
const decision = decideAgentProfileAutopick({
open,
agentProfileId,
workflowAgentProfileId,
workflowHasAgent,
agentProfiles,
compatibleAgentProfiles,
authLoaded,
executorProfileId,
hasExecutors: executors.length > 0,
defaultAgentProfileId: workspaceDefaults?.default_agent_profile_id ?? null,
});
autopickDebug(decision.kind, {
reason: decision.kind === "pick" ? decision.source : decision.reason,
pick: decision.kind === "pick" ? decision.id : "-",
current: agentProfileId || "-",
workflow_id: selectedWorkflowId ?? "-",
executor_profile_id: executorProfileId || "-",
agent_count: agentProfiles.length,
compat_count: compatibleAgentProfiles.length,
auth_loaded: authLoaded,
});
if (decision.kind === "pick") {
const id = decision.id;
void Promise.resolve().then(() => setAgentProfileId(id));
}
}, [
open,
agentProfileId,
workflowAgentProfileId,
selectedWorkflowId,
workflows,
agentProfiles,
compatibleAgentProfiles,
authLoaded,
executorProfileId,
executors,
workspaceDefaults,
setAgentProfileId,
]);
}