diff --git a/apps/app/src/components/tools/openwork-automation-proposal.tsx b/apps/app/src/components/tools/openwork-automation-proposal.tsx index ed1c087b6d..bd6ec4710d 100644 --- a/apps/app/src/components/tools/openwork-automation-proposal.tsx +++ b/apps/app/src/components/tools/openwork-automation-proposal.tsx @@ -1,13 +1,18 @@ "use client" -import { useState } from "react" +import { useEffect, useId, useState } from "react" import { AlertTriangle, CalendarClock, Check } from "lucide-react" import type { DynamicToolUIPart } from "ai" import { useNavigate } from "react-router" import { toast } from "sonner" import { useQuery } from "@tanstack/react-query" -import { automationProposalSchema, type AutomationProposal } from "@openwork/types/automations" +import { + AUTOMATION_FREE_MODEL, + automationProposalSchema, + type AutomationModel, + type AutomationProposal, +} from "@openwork/types/automations" import { createDenClient, readDenSettings } from "@/app/lib/den" import { Button } from "@/components/ui/button" @@ -15,12 +20,15 @@ import { Tool } from "@/components/ui/tool" import { useDenAuth } from "@/react-app/domains/cloud/den-auth-provider" import { automationCreationPlacement, useAutomationDeploymentEnabled } from "@/react-app/domains/automations/automation-availability" import { formatAutomationSchedule } from "@/react-app/domains/automations/automation-format" +import { AutomationModelButton } from "@/react-app/domains/automations/automation-model-button" import { automationModelOptions, + automationPickerOptions, automationProviderCatalog, - describeAutomationModel, resolveProposalModel, } from "@/react-app/domains/automations/automation-model-options" +import { ModelPickerModal } from "@/react-app/domains/session/modals/model-picker-modal" +import { openAutoProviderSettings } from "@/react-app/domains/cloud/auto-access-ui" import { automationsRoute } from "@/react-app/shell/workspace-routes" import { useWorkspaceMaybe } from "@/react-app/shell/workspace-provider" import { useDesktopRestriction } from "@/react-app/domains/cloud/desktop-config-provider" @@ -65,8 +73,21 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa const workspaceContext = useWorkspaceMaybe() const [created, setCreated] = useState(null) const [busy, setBusy] = useState(false) + const [selectedModelState, setSelectedModelState] = useState<{ proposalKey: string; model: AutomationModel } | null>(null) + const [modelPickerOpen, setModelPickerOpen] = useState(false) + const [modelQuery, setModelQuery] = useState("") + const modelPickerId = useId() const proposal = part.state === "output-available" ? parseAutomationProposal(part.output) : null + const proposalKey = proposal ? `${part.toolCallId ?? part.toolName}:${JSON.stringify(proposal)}` : null + const selectedModel = proposalKey && selectedModelState?.proposalKey === proposalKey ? selectedModelState.model : null + const setSelectedModel = (model: AutomationModel) => { + if (proposalKey) setSelectedModelState({ proposalKey, model }) + } + useEffect(() => { + setModelPickerOpen(false) + setModelQuery("") + }, [proposalKey]) const settings = readDenSettings() const token = settings.authToken?.trim() ?? "" const organizationId = settings.activeOrgId?.trim() ?? "" @@ -93,12 +114,25 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa const modelLookupFailed = providersQuery.isError || (placement === "desktop" && runtimeProviders.isError) const modelLookupPending = providersQuery.data === undefined || (placement === "desktop" && runtimeProviders.data === undefined) + const modelOptions = automationModelOptions(providers, availability) + // Keep "no model proposed" undefined through resolution: Cloud and restricted + // Desktop placements must choose from models the member can actually run. + const modelForSelection = selectedModel ?? proposal?.model const resolved = modelLookupFailed || modelLookupPending || !proposal ? null - : resolveProposalModel(proposal.model, providers, availability) - const modelLabel = resolved - ? describeAutomationModel(resolved.model, automationModelOptions(providers, availability)) - : null + : resolveProposalModel(modelForSelection, providers, availability) + const pickerCurrentModel = resolved?.model ?? modelForSelection ?? { + providerId: AUTOMATION_FREE_MODEL.providerId, + modelId: AUTOMATION_FREE_MODEL.modelId, + variant: null, + } + const pickerOptions = automationPickerOptions({ + options: modelOptions, + catalog: availability.catalog ?? {}, + selected: pickerCurrentModel, + }) + const modelPickerDisabled = modelLookupFailed || modelLookupPending || !signedIn + || !automationsEnabled || busy || Boolean(created) || modelOptions.length === 0 if (!proposal) { return @@ -180,10 +214,55 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa

{proposal.name}

{formatAutomationSchedule(proposal.schedule)}

- {modelLabel ?

Runs with {modelLabel}

: null} +
+ + setModelPickerOpen(true)} + /> +
{pinnedWorkspaceId && placement === "desktop" ?

Runs in this workspace

: null}

{proposal.instructions}

+ { + setSelectedModel({ + providerId: model.providerID, + modelId: model.modelID, + variant: pickerCurrentModel.providerId === model.providerID && pickerCurrentModel.modelId === model.modelID + ? pickerCurrentModel.variant + : null, + }) + setModelPickerOpen(false) + }} + onBehaviorChange={(model, variant) => setSelectedModel({ + providerId: model.providerID, + modelId: model.modelID, + variant, + })} + onOpenSettings={() => { + setModelPickerOpen(false) + openAutoProviderSettings() + }} + onOpenProviderSettings={() => { + setModelPickerOpen(false) + openAutoProviderSettings() + }} + onClose={() => setModelPickerOpen(false)} + />
@@ -225,7 +304,7 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa
- The proposed model isn't available for Automations. Choose a supported model in a new proposal or in Automations before creating. No replacement model was selected. + The proposed model isn't available for Automations. Select an available model above before creating. No replacement model was selected automatically.
) : null} diff --git a/apps/app/src/react-app/domains/automations/automation-model-button.tsx b/apps/app/src/react-app/domains/automations/automation-model-button.tsx index 46e7918869..5626ba7b1d 100644 --- a/apps/app/src/react-app/domains/automations/automation-model-button.tsx +++ b/apps/app/src/react-app/domains/automations/automation-model-button.tsx @@ -41,6 +41,7 @@ export function AutomationModelButton(props: { model: AutomationModel options: readonly AutomationModelOption[] size?: "sm" | "default" + disabled?: boolean onClick: () => void }) { return ( @@ -48,6 +49,7 @@ export function AutomationModelButton(props: { id={props.id} type="button" variant="outline" + disabled={props.disabled} className={cn("w-full justify-between gap-2 font-normal", props.size === "sm" ? "h-8 px-2" : "h-10 px-2.5")} onClick={props.onClick} > diff --git a/apps/app/tests/automation-proposal-model.test.ts b/apps/app/tests/automation-proposal-model.test.ts index d519f14445..dadc231173 100644 --- a/apps/app/tests/automation-proposal-model.test.ts +++ b/apps/app/tests/automation-proposal-model.test.ts @@ -19,3 +19,14 @@ test("the free starter model still wins on Desktop when it is offered", () => { expect(resolved.resolution).toBe("default"); expect(resolved.model.modelId).not.toBe("team-model"); }); + +test("an explicitly selected available model replaces an unavailable proposal without silent fallback", () => { + const unavailable = { providerId: "revoked-provider", modelId: "removed-model", variant: null }; + expect(resolveProposalModel(unavailable, [custom], { includeFreeStarter: false }).resolution).toBe("unavailable"); + + const selected = { providerId: "lpr_team", modelId: "team-model", variant: "high" }; + expect(resolveProposalModel(selected, [custom], { includeFreeStarter: false })).toEqual({ + model: selected, + resolution: "exact", + }); +});