Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 88 additions & 9 deletions apps/app/src/components/tools/openwork-automation-proposal.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
"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"
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"
Expand Down Expand Up @@ -65,8 +73,21 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa
const workspaceContext = useWorkspaceMaybe()
const [created, setCreated] = useState<string | null>(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() ?? ""
Expand All @@ -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 <Tool toolPart={part} title="Proposed an Automation" />
Expand Down Expand Up @@ -180,10 +214,55 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa
<div>
<p className="truncate text-sm font-medium text-dls-primary" title={proposal.name}>{proposal.name}</p>
<p className="text-xs text-dls-secondary">{formatAutomationSchedule(proposal.schedule)}</p>
{modelLabel ? <p className="text-xs text-dls-secondary">Runs with {modelLabel}</p> : null}
<div className="mt-2 space-y-1" data-automation-model-selection>
<label htmlFor={modelPickerId} className="text-xs text-dls-secondary">Model</label>
<AutomationModelButton
id={modelPickerId}
model={pickerCurrentModel}
options={modelOptions}
size="sm"
disabled={modelPickerDisabled}
onClick={() => setModelPickerOpen(true)}
/>
</div>
{pinnedWorkspaceId && placement === "desktop" ? <p className="text-xs text-dls-secondary" data-automation-pinned-workspace={pinnedWorkspaceId}>Runs in this workspace</p> : null}
</div>
<p className="whitespace-pre-wrap text-sm text-dls-secondary">{proposal.instructions}</p>
<ModelPickerModal
open={modelPickerOpen && !created}
options={pickerOptions}
query={modelQuery}
setQuery={setModelQuery}
subtitle={placement === "cloud"
? "Your cloud Automation uses this model and reasoning level."
: "Your desktop Automation uses this model and reasoning level."}
target="default"
current={{ providerID: pickerCurrentModel.providerId, modelID: pickerCurrentModel.modelId }}
onSelect={(model) => {
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)}
/>
</div>

<div className="flex items-center justify-between gap-3 border-t border-dls-border px-4 py-3">
Expand Down Expand Up @@ -225,7 +304,7 @@ export function OpenWorkAutomationProposalTool({ part }: { part: DynamicToolUIPa
<div className="flex items-center gap-2 border-t border-dls-border px-4 py-2 text-xs text-amber-11">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1">
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.
</span>
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ export function AutomationModelButton(props: {
model: AutomationModel
options: readonly AutomationModelOption[]
size?: "sm" | "default"
disabled?: boolean
onClick: () => void
}) {
return (
<Button
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}
>
Expand Down
11 changes: 11 additions & 0 deletions apps/app/tests/automation-proposal-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
Loading