refactor(automations): streamline model eligibility handling in automation creation

- Removed the eligibility gate for model selection in the automation creation process, allowing users to choose models directly in the builder.
- Updated the `AutomationBuilderForm` to incorporate model selection logic, ensuring that selected models are validated and preserved during automation creation and editing.
- Simplified the `AutomationsContent` and `AutomationNewContent` components by eliminating unnecessary eligibility checks and alerts.
- Enhanced the user experience by integrating model selection directly into the automation approval process, ensuring that only billable models are used.
- Refactored related tests to cover new model selection behavior and ensure proper validation of user-selected models.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-29 20:27:40 -07:00
parent fade9d1b9d
commit 9d1a01eb0c
13 changed files with 887 additions and 263 deletions

View file

@ -1,8 +1,6 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { useAutomations } from "@/hooks/use-automations";
import { AutomationModelGateAlert } from "./components/automation-model-gate-alert";
import { AutomationsEmptyState } from "./components/automations-empty-state";
import { AutomationsHeader } from "./components/automations-header";
import { AutomationsTable } from "./components/automations-table";
@ -24,18 +22,6 @@ interface AutomationsContentProps {
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
// Gate creation on billable models (premium/BYOK). Only meaningful for
// users who can create; the eligibility query loads in parallel.
const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
perms.canCreate ? searchSpaceId : undefined
);
const modelViolations = eligibility?.violations ?? [];
// Disable create CTAs while loading (avoid a flash of enabled buttons) and
// when the resolved models aren't billable.
const createDisabled = perms.canCreate && (eligibilityLoading || modelViolations.length > 0);
const disabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
if (perms.loading) {
// Permissions gate the entire page; defer everything until we know.
@ -91,11 +77,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState
searchSpaceId={searchSpaceId}
canCreate={perms.canCreate}
modelViolations={modelViolations}
/>
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
</>
);
}
@ -107,12 +89,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
total={total}
loading={loading}
canCreate={perms.canCreate}
createDisabled={createDisabled}
disabledReason={disabledReason}
/>
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationsTable
automations={automations}
searchSpaceId={searchSpaceId}

View file

@ -1,61 +0,0 @@
"use client";
import { Lock } from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
interface AutomationModelGateAlertProps {
searchSpaceId: number;
violations: ModelEligibilityViolation[];
className?: string;
}
// Model selection for automations is managed under the roles tab, so every
// blocked slot deep-links there (the label still names which slot to fix).
const SETTINGS_TAB = "roles";
const KIND_LABEL: Record<ModelEligibilityViolation["kind"], string> = {
llm: "Agent model",
image: "Image model",
vision: "Vision model",
};
/**
* Warns that the search space's models aren't billable for automations.
*
* Automations may only use premium global models or your own (BYOK) models
* free models and Auto mode are blocked so every run is metered in premium
* credits. Surfaced wherever a user can start creating an automation.
*/
export function AutomationModelGateAlert({
searchSpaceId,
violations,
className,
}: AutomationModelGateAlertProps) {
if (violations.length === 0) return null;
return (
<Alert variant="warning" className={className}>
<Lock aria-hidden />
<AlertTitle>Automations need a premium or your own model</AlertTitle>
<AlertDescription>
<p>
Automations run unattended, so every run must use a premium model or your own (BYOK)
model. Update these in your model settings, then create your automation.
</p>
<ul className="mt-1 list-inside list-disc">
{violations.map((violation) => (
<li key={violation.kind}>
<Link
href={`/dashboard/${searchSpaceId}/search-space-settings/${SETTINGS_TAB}`}
className="font-medium text-foreground underline underline-offset-2 hover:no-underline"
>
{KIND_LABEL[violation.kind]}
</Link>
<span className="text-muted-foreground"> {violation.reason}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}

View file

@ -2,14 +2,10 @@
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
import { AutomationModelGateAlert } from "./automation-model-gate-alert";
interface AutomationsEmptyStateProps {
searchSpaceId: number;
canCreate: boolean;
/** Model slots that block creation (free/Auto). Empty when eligible. */
modelViolations?: ModelEligibilityViolation[];
}
/**
@ -18,13 +14,7 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
export function AutomationsEmptyState({
searchSpaceId,
canCreate,
modelViolations = [],
}: AutomationsEmptyStateProps) {
const modelsBlocked = modelViolations.length > 0;
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
@ -36,26 +26,20 @@ export function AutomationsEmptyState({
SurfSense drafts the automation for your approval.
</p>
{canCreate ? (
modelsBlocked ? (
<div className="mt-6 mx-auto max-w-md text-left">
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
</div>
) : (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
</div>
)
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
</div>
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.

View file

@ -1,9 +1,7 @@
"use client";
import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface AutomationsHeaderProps {
searchSpaceId: number;
@ -16,22 +14,13 @@ interface AutomationsHeaderProps {
* there to avoid a duplicate button.
*/
showCreateCta?: boolean;
/**
* Disable the create CTAs when the search space's models aren't billable
* for automations (free/Auto). When set, a tooltip explains why and the
* buttons render disabled rather than as links.
*/
createDisabled?: boolean;
disabledReason?: string;
}
const DEFAULT_DISABLED_REASON =
"Automations need a premium or your own (BYOK) model. Update your model settings to enable.";
/**
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
* (the create_automation tool runs inside chat with a HITL approval card), so
* the CTA links to a new chat rather than opening a form.
* the CTA links to a new chat rather than opening a form. Model eligibility is
* handled per-automation in the builder + approval card, not gated here.
*/
export function AutomationsHeader({
searchSpaceId,
@ -39,8 +28,6 @@ export function AutomationsHeader({
loading,
canCreate,
showCreateCta = true,
createDisabled = false,
disabledReason,
}: AutomationsHeaderProps) {
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
@ -54,70 +41,20 @@ export function AutomationsHeader({
</div>
{canCreate && showCreateCta && (
<div className="flex items-center gap-2">
{createDisabled ? (
<>
<DisabledCta
variant="outline"
icon={<SquarePen className="mr-2 h-4 w-4" />}
label="Create manually"
reason={disabledReason ?? DEFAULT_DISABLED_REASON}
/>
<DisabledCta
icon={<MessageSquarePlus className="mr-2 h-4 w-4" />}
label="Create via chat"
reason={disabledReason ?? DEFAULT_DISABLED_REASON}
/>
</>
) : (
<>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
</>
)}
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
</div>
)}
</div>
);
}
function DisabledCta({
icon,
label,
reason,
variant,
}: {
icon: ReactNode;
label: string;
reason: string;
variant?: "outline";
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* aria-disabled (not `disabled`) keeps the button focusable so the
tooltip is reachable by hover and keyboard; onClick is a no-op. */}
<Button
size="sm"
variant={variant}
aria-disabled
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
{icon}
{label}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
</Tooltip>
);
}

View file

@ -21,8 +21,10 @@ import {
automationCreateRequest,
automationUpdateRequest,
} from "@/contracts/types/automation.types";
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
import {
type BuilderForm,
type BuilderModels,
buildCreatePayload,
builderFormSchema,
buildScheduleTrigger,
@ -30,10 +32,12 @@ import {
createEmptyForm,
formFromAutomation,
type HydratableTrigger,
hasResolvedModels,
hydrateForm,
} from "@/lib/automations/builder-schema";
import { cn } from "@/lib/utils";
import { AdvancedSection } from "./advanced-section";
import { AutomationModelFields } from "./automation-model-fields";
import { BasicsSection } from "./basics-section";
import { BuilderSummary } from "./builder-summary";
import { JsonModePanel } from "./json-mode-panel";
@ -47,9 +51,9 @@ interface AutomationBuilderFormProps {
/** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation;
/**
* When set (create mode only), the search space's models aren't billable
* for automations. Submit is disabled with this reason as the tooltip; the
* orchestrator also renders the full gate alert above the form.
* Optional extra create-mode block reason (composed with the form's own
* model-eligibility gate). Shown as the submit button's tooltip. Model
* eligibility itself is now owned by the in-form pickers.
*/
submitDisabledReason?: string;
}
@ -117,15 +121,46 @@ export function AutomationBuilderForm({
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
: `/dashboard/${searchSpaceId}/automations`;
// Eligible models + the search-space-seeded defaults. Models are chosen per
// automation on create; in edit mode the backend preserves the captured
// snapshot, so the picker is create-only.
const eligibleModels = useAutomationEligibleModels();
// Resolve each slot during render: an explicit (non-zero) pick wins,
// otherwise fall back to the eligible default. No effect copies async hook
// data into state, so there's no flicker/loop and the user's pick is sticky.
const resolvedModels = useMemo<BuilderModels>(
() => ({
agentLlmId: form.models.agentLlmId || eligibleModels.llm.defaultId || 0,
imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0,
visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0,
}),
[
form.models,
eligibleModels.llm.defaultId,
eligibleModels.image.defaultId,
eligibleModels.vision.defaultId,
]
);
// The form with resolved models folded in — what every payload builder reads.
const formForPayload = useMemo<BuilderForm>(
() => ({ ...form, models: resolvedModels }),
[form, resolvedModels]
);
function patchForm(patch: Partial<BuilderForm>) {
setForm((prev) => ({ ...prev, ...patch }));
}
function jsonFromCurrentForm(): Record<string, unknown> {
if (mode === "edit" && automation) {
return { ...buildUpdatePayload(form), status: automation.status };
return { ...buildUpdatePayload(formForPayload), status: automation.status };
}
const { search_space_id: _ignored, ...rest } = buildCreatePayload(form, searchSpaceId);
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
formForPayload,
searchSpaceId
);
return rest;
}
@ -223,7 +258,7 @@ export function AutomationBuilderForm({
setSubmitting(true);
try {
if (mode === "edit" && automation) {
const payload = buildUpdatePayload(form);
const payload = buildUpdatePayload(formForPayload);
const parsed = automationUpdateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
@ -233,7 +268,7 @@ export function AutomationBuilderForm({
await reconcileTriggers(automation.id);
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
} else {
const payload = buildCreatePayload(form, searchSpaceId);
const payload = buildCreatePayload(formForPayload, searchSpaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
@ -281,8 +316,18 @@ export function AutomationBuilderForm({
}
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
// Block creation until every model slot resolves to an eligible id. The
// per-field Alert already explains *why* a slot is empty; this just guards
// submit. `submitDisabledReason` (from the caller) still composes in.
const modelsUnresolved =
mode === "create" && !eligibleModels.isLoading && !hasResolvedModels(resolvedModels);
const effectiveDisabledReason =
submitDisabledReason ??
(modelsUnresolved
? "Set up a premium or your own (BYOK) agent, image, and vision model in role settings before creating an automation."
: undefined);
// Only gate creation; editing an existing automation isn't blocked here.
const submitBlocked = mode === "create" && !!submitDisabledReason;
const submitBlocked = mode === "create" && !!effectiveDisabledReason;
return (
<div className="space-y-4">
@ -364,6 +409,19 @@ export function AutomationBuilderForm({
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Models</CardTitle>
</CardHeader>
<CardContent>
<AutomationModelFields
searchSpaceId={searchSpaceId}
value={resolvedModels}
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
@ -416,7 +474,7 @@ export function AutomationBuilderForm({
{submitLabel}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{submitDisabledReason}</TooltipContent>
<TooltipContent className="max-w-xs">{effectiveDisabledReason}</TooltipContent>
</Tooltip>
) : (
<Button

View file

@ -0,0 +1,198 @@
"use client";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { memo, useId } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
type EligibleModelKind,
type EligibleModelOption,
useAutomationEligibleModels,
} from "@/hooks/use-automation-eligible-models";
import { getProviderIcon } from "@/lib/provider-icons";
import { Field } from "./form-field";
export interface AutomationModelSelection {
agentLlmId: number;
imageConfigId: number;
visionConfigId: number;
}
interface AutomationModelFieldsProps {
/** Resolved (effective) ids — never `0` once defaults are seeded. */
value: AutomationModelSelection;
onChange: (patch: Partial<AutomationModelSelection>) => void;
searchSpaceId: number;
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
}
/**
* Three eligible-only model pickers (Agent LLM / Image / Vision) for the
* automation builder + chat approval card. Options come from
* {@link useAutomationEligibleModels} (premium globals + BYOK only); selection
* is validated + snapshotted onto `definition.models` at create time.
*/
export function AutomationModelFields({
value,
onChange,
searchSpaceId,
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/roles`;
return (
<div className="flex flex-col gap-4">
<ModelSelectField
label="Agent model"
kind={llm}
value={value.agentLlmId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.agentLlmId}
onChange={(id) => onChange({ agentLlmId: id })}
/>
<ModelSelectField
label="Image model"
kind={image}
value={value.imageConfigId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.imageConfigId}
onChange={(id) => onChange({ imageConfigId: id })}
/>
<ModelSelectField
label="Vision model"
kind={vision}
value={value.visionConfigId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.visionConfigId}
onChange={(id) => onChange({ visionConfigId: id })}
/>
</div>
);
}
interface ModelSelectFieldProps {
label: string;
kind: EligibleModelKind;
value: number;
isLoading: boolean;
rolesHref: string;
error?: string;
onChange: (id: number) => void;
}
const ModelSelectField = memo(function ModelSelectField({
label,
kind,
value,
isLoading,
rolesHref,
error,
onChange,
}: ModelSelectFieldProps) {
const triggerId = useId();
if (isLoading) {
return (
<Field label={label}>
<Skeleton className="h-9 w-full" />
</Field>
);
}
if (kind.options.length === 0) {
return (
<Field label={label}>
<Alert>
<TriangleAlert aria-hidden />
<AlertTitle>No eligible models</AlertTitle>
<AlertDescription>
Automations need a premium or your own (BYOK) model. Set one up in{" "}
<Link href={rolesHref} className="font-medium underline underline-offset-2">
role settings
</Link>
.
</AlertDescription>
</Alert>
</Field>
);
}
const premium = kind.options.filter((o) => !o.isBYOK);
const byok = kind.options.filter((o) => o.isBYOK);
const selected = value ? kind.byId.get(value) : undefined;
return (
<Field label={label} htmlFor={triggerId} error={error}>
<Select value={value ? String(value) : undefined} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger
id={triggerId}
aria-label={label}
aria-invalid={error ? true : undefined}
className="w-full"
>
{selected ? (
<span className="flex items-center gap-2">
{getProviderIcon(selected.provider)}
<span className="truncate">{selected.name}</span>
</span>
) : (
<SelectValue placeholder="Select a model" />
)}
</SelectTrigger>
<SelectContent>
{premium.length > 0 ? (
<SelectGroup>
<SelectLabel>Premium</SelectLabel>
{premium.map((option) => (
<ModelOption key={option.id} option={option} badge="Premium" />
))}
</SelectGroup>
) : null}
{premium.length > 0 && byok.length > 0 ? <SelectSeparator /> : null}
{byok.length > 0 ? (
<SelectGroup>
<SelectLabel>Your models</SelectLabel>
{byok.map((option) => (
<ModelOption key={option.id} option={option} badge="BYOK" />
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
</Field>
);
});
function ModelOption({
option,
badge,
}: {
option: EligibleModelOption;
badge: "Premium" | "BYOK";
}) {
return (
<SelectItem value={String(option.id)} description={option.modelName}>
<span className="flex items-center gap-2">
{getProviderIcon(option.provider)}
<span className="truncate">{option.name}</span>
<Badge variant={badge === "Premium" ? "secondary" : "outline"}>{badge}</Badge>
</span>
</SelectItem>
);
}

View file

@ -1,7 +1,5 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { AutomationModelGateAlert } from "../components/automation-model-gate-alert";
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationNewHeader } from "./components/automation-new-header";
@ -15,12 +13,13 @@ interface AutomationNewContentProps {
* who can't create don't even see the form; same panel as the detail page's
* access-denied state for consistency. The builder defaults to the friendly
* form with a raw-JSON escape hatch.
*
* Model eligibility is no longer gated here the builder's own model pickers
* list eligible (premium/BYOK) models, surface a per-slot notice when none
* exist, and block submit until each slot resolves.
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
perms.canCreate ? searchSpaceId : undefined
);
if (perms.loading) {
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
@ -38,22 +37,10 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
);
}
const modelViolations = eligibility?.violations ?? [];
const submitDisabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
return (
<>
<AutomationNewHeader searchSpaceId={searchSpaceId} />
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationBuilderForm
mode="create"
searchSpaceId={searchSpaceId}
submitDisabledReason={submitDisabledReason}
/>
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
</>
);
}