From 0e7d6f1679c7496fffe85a408c86671ff43c9c22 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:09:20 +0300 Subject: [PATCH] fix: select providers after connection --- apps/web/src/components/ChatView.tsx | 104 +++-- .../chat/ComposerModelEffortPicker.tsx | 4 + .../chat/ProviderModelPicker.browser.tsx | 9 + .../components/chat/ProviderModelPicker.tsx | 8 + .../components/kanban/KanbanNewTaskDialog.tsx | 25 +- ...oviderSelectionAfterConnection.browser.tsx | 207 ++++++++++ ...seProviderSelectionAfterConnection.test.ts | 184 +++++++++ .../useProviderSelectionAfterConnection.ts | 357 ++++++++++++++++++ apps/web/src/routes/-automations.shared.tsx | 34 +- 9 files changed, 904 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx create mode 100644 apps/web/src/hooks/useProviderSelectionAfterConnection.test.ts create mode 100644 apps/web/src/hooks/useProviderSelectionAfterConnection.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7dcbb705..e554873f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -92,6 +92,10 @@ import { import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { useRefreshProviderStatusesNow } from "~/hooks/useProviderStatusRefresh"; +import { + useApplyProviderSelectionAfterConnection, + useProviderConnectionSelectionIntent, +} from "~/hooks/useProviderSelectionAfterConnection"; import { SINGLE_CHAT_PANE_SCOPE_ID } from "~/lib/chatPaneScope"; import { composerMentionPathNeedsQuoting, @@ -2047,6 +2051,8 @@ export default function ChatView({ : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? settings.defaultProvider; + const providerSelectionIntent = useProviderConnectionSelectionIntent(threadId); + const pendingProviderSelection = providerSelectionIntent.pendingProvider; const previousSelectedProviderRef = useRef<{ threadId: ThreadId; provider: ProviderKind; @@ -2097,17 +2103,30 @@ export default function ChatView({ ); const codexDynamicModelsQuery = useQuery(providerModelsQueryOptions({ provider: "codex" })); const openCodeModelDiscoveryEnabled = - selectedProvider === "opencode" || lockedProvider === "opencode" || isModelPickerOpen; + selectedProvider === "opencode" || + lockedProvider === "opencode" || + pendingProviderSelection === "opencode" || + isModelPickerOpen; const kiloModelDiscoveryEnabled = - selectedProvider === "kilo" || lockedProvider === "kilo" || isModelPickerOpen; + selectedProvider === "kilo" || + lockedProvider === "kilo" || + pendingProviderSelection === "kilo" || + isModelPickerOpen; const piModelDiscoveryEnabled = - selectedProvider === "pi" || lockedProvider === "pi" || isModelPickerOpen; + selectedProvider === "pi" || + lockedProvider === "pi" || + pendingProviderSelection === "pi" || + isModelPickerOpen; const cursorDynamicModelsQuery = useQuery( providerModelsQueryOptions({ provider: "cursor", binaryPath: settings.cursorBinaryPath || null, apiEndpoint: settings.cursorApiEndpoint || null, - enabled: selectedProvider === "cursor" || lockedProvider === "cursor" || isModelPickerOpen, + enabled: + selectedProvider === "cursor" || + lockedProvider === "cursor" || + pendingProviderSelection === "cursor" || + isModelPickerOpen, }), ); const antigravityModelsQuery = useQuery( @@ -2116,17 +2135,27 @@ export default function ChatView({ binaryPath: settings.antigravityBinaryPath || null, cwd: providerModelDiscoveryCwd, enabled: - selectedProvider === "antigravity" || lockedProvider === "antigravity" || isModelPickerOpen, + selectedProvider === "antigravity" || + lockedProvider === "antigravity" || + pendingProviderSelection === "antigravity" || + isModelPickerOpen, }), ); const grokDynamicModelsQuery = useQuery( providerModelsQueryOptions({ provider: "grok", binaryPath: settings.grokBinaryPath || null, - enabled: selectedProvider === "grok" || lockedProvider === "grok" || isModelPickerOpen, + enabled: + selectedProvider === "grok" || + lockedProvider === "grok" || + pendingProviderSelection === "grok" || + isModelPickerOpen, }), ); - const droidModelDiscoveryEnabled = selectedProvider === "droid" || lockedProvider === "droid"; + const droidModelDiscoveryEnabled = + selectedProvider === "droid" || + lockedProvider === "droid" || + pendingProviderSelection === "droid"; const droidDynamicModelsQuery = useQuery( providerModelsQueryOptions({ provider: "droid", @@ -2185,7 +2214,10 @@ export default function ChatView({ [cursorDynamicModelsQuery.data?.models], ); const cursorModelDiscoveryEnabled = - selectedProvider === "cursor" || lockedProvider === "cursor" || isModelPickerOpen; + selectedProvider === "cursor" || + lockedProvider === "cursor" || + pendingProviderSelection === "cursor" || + isModelPickerOpen; const hasResolvedCursorModelDiscovery = (cursorDynamicModelsQuery.data?.source === "cursor.cli" || cursorDynamicModelsQuery.data?.source === "cursor.acp") && @@ -2229,6 +2261,24 @@ export default function ChatView({ antigravityModelsQuery.data?.source === "antigravity.cli" && (antigravityModelsQuery.data.models.length ?? 0) > 0 ) && isInitialModelDiscoveryPending(antigravityModelsQuery); + const providerModelDiscoveryPendingByProvider = useMemo>>( + () => ({ + antigravity: antigravityModelDiscoveryPending, + cursor: cursorModelDiscoveryPending, + droid: droidModelDiscoveryPending, + kilo: kiloModelDiscoveryPending, + opencode: openCodeModelDiscoveryPending, + pi: piModelDiscoveryPending, + }), + [ + antigravityModelDiscoveryPending, + cursorModelDiscoveryPending, + droidModelDiscoveryPending, + kiloModelDiscoveryPending, + openCodeModelDiscoveryPending, + piModelDiscoveryPending, + ], + ); const modelOptionsByProvider = useMemo(() => { const staticOptions: Record> = { codex: getAppModelOptions( @@ -5849,6 +5899,24 @@ export default function ChatView({ ], ); + const onProviderConnectionRequested = useCallback( + (provider: ProviderKind) => { + providerSelectionIntent.request(provider, findProviderStatus(providerStatuses, provider)); + }, + [providerSelectionIntent, providerStatuses], + ); + useApplyProviderSelectionAfterConnection({ + controller: providerSelectionIntent, + scopeKey: threadId, + lockedProvider, + statuses: providerStatuses, + modelOptionsByProvider, + loadingModelProviders: providerModelDiscoveryPendingByProvider, + preferredModelByProvider: composerModelHintByProvider, + canApply: activeThread !== undefined, + onProviderModelChange: onProviderModelSelect, + }); + useEffect(() => { if (surfaceMode === "split" && !isFocusedPane) { return; @@ -8914,17 +8982,11 @@ export default function ChatView({ lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} - loadingModelProviders={{ - antigravity: antigravityModelDiscoveryPending, - cursor: cursorModelDiscoveryPending, - droid: droidModelDiscoveryPending, - kilo: kiloModelDiscoveryPending, - opencode: openCodeModelDiscoveryPending, - pi: piModelDiscoveryPending, - }} + loadingModelProviders={providerModelDiscoveryPendingByProvider} hiddenProviders={settings.hiddenProviders} providerOrder={settings.providerOrder} onProviderModelChange={onProviderModelSelect} + onProviderConnectionRequested={onProviderConnectionRequested} onSelectionCommitted={scheduleComposerFocus} open={isModelPickerOpen} onOpenChange={handleModelPickerOpenChange} @@ -8957,14 +9019,7 @@ export default function ChatView({ lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} - loadingModelProviders={{ - antigravity: antigravityModelDiscoveryPending, - cursor: cursorModelDiscoveryPending, - droid: droidModelDiscoveryPending, - kilo: kiloModelDiscoveryPending, - opencode: openCodeModelDiscoveryPending, - pi: piModelDiscoveryPending, - }} + loadingModelProviders={providerModelDiscoveryPendingByProvider} hiddenProviders={settings.hiddenProviders} providerOrder={settings.providerOrder} threadId={threadId} @@ -8975,6 +9030,7 @@ export default function ChatView({ prompt={prompt} onPromptChange={setPromptFromTraits} onProviderModelChange={onProviderModelSelect} + onProviderConnectionRequested={onProviderConnectionRequested} onSelectionCommitted={scheduleComposerFocus} open={isComposerModelEffortPickerOpen} onOpenChange={handleComposerModelEffortPickerOpenChange} diff --git a/apps/web/src/components/chat/ComposerModelEffortPicker.tsx b/apps/web/src/components/chat/ComposerModelEffortPicker.tsx index 39c4b3b78..679a112a4 100644 --- a/apps/web/src/components/chat/ComposerModelEffortPicker.tsx +++ b/apps/web/src/components/chat/ComposerModelEffortPicker.tsx @@ -63,6 +63,7 @@ type ComposerModelEffortPickerProps = { hideStatusLabel?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onProviderConnectionRequested?: (provider: ProviderKind) => void; onSelectionCommitted?: () => void; // Traits/effort/speed data. @@ -315,6 +316,9 @@ export const ComposerModelEffortPicker = memo(function ComposerModelEffortPicker {...(props.providerOrder ? { providerOrder: props.providerOrder } : {})} {...(props.disabled !== undefined ? { disabled: props.disabled } : {})} onProviderModelChange={props.onProviderModelChange} + {...(props.onProviderConnectionRequested + ? { onProviderConnectionRequested: props.onProviderConnectionRequested } + : {})} onAfterSelection={handleAfterModelSelection} /> diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index a171bb333..b1c4b9b32 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -137,6 +137,7 @@ async function mountPicker(props: { providers?: ReadonlyArray; loadingModelProviders?: Partial>; onSelectionCommitted?: () => void; + onProviderConnectionRequested?: (provider: ProviderKind) => void; modelOptionsByProvider?: Record< ProviderKind, ReadonlyArray @@ -156,6 +157,9 @@ async function mountPicker(props: { : {})} {...(props.providers ? { providers: props.providers } : {})} {...(props.onSelectionCommitted ? { onSelectionCommitted: props.onSelectionCommitted } : {})} + {...(props.onProviderConnectionRequested + ? { onProviderConnectionRequested: props.onProviderConnectionRequested } + : {})} onProviderModelChange={onProviderModelChange} />, { container: host }, @@ -532,6 +536,7 @@ describe("ProviderModelPicker", () => { }); it("opens guided connection from an unavailable provider row", async () => { + const onProviderConnectionRequested = vi.fn(); const mounted = await mountPicker({ provider: "codex", model: "gpt-5-codex", @@ -552,6 +557,7 @@ describe("ProviderModelPicker", () => { checkedAt: "2026-04-10T10:00:00.000Z", }, ], + onProviderConnectionRequested, }); try { @@ -565,6 +571,9 @@ describe("ProviderModelPicker", () => { }); await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + expect(onProviderConnectionRequested).toHaveBeenCalledOnce(); + expect(onProviderConnectionRequested).toHaveBeenCalledWith("claudeAgent"); + await vi.waitFor(() => { expect(useProviderConnectionDialogStore.getState()).toMatchObject({ isOpen: true, diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index d39c49544..532f9f14b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -199,6 +199,9 @@ type ProviderModelMenuItemsProps = { providerOrder?: ReadonlyArray; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + // Records that this composer should activate the provider once the guided + // connection flow reaches verified readiness. + onProviderConnectionRequested?: (provider: ProviderKind) => void; // Invoked after a model selection commits so callers can close ancestor // menus and refocus the composer. onAfterSelection?: () => void; @@ -447,6 +450,7 @@ export const ProviderModelMenuItems = memo(function ProviderModelMenuItems( { + props.onProviderConnectionRequested?.(option.value); onAfterSelection?.(); window.setTimeout(() => openProviderConnection(option.value, "provider_picker"), 0); }} @@ -545,6 +549,7 @@ type ProviderModelPickerProps = { onSelectionCommitted?: () => void; shortcutLabel?: string | null; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onProviderConnectionRequested?: (provider: ProviderKind) => void; }; export const ProviderModelPicker = memo(function ProviderModelPicker( @@ -662,6 +667,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker( {...(props.providerOrder ? { providerOrder: props.providerOrder } : {})} {...(props.disabled !== undefined ? { disabled: props.disabled } : {})} onProviderModelChange={props.onProviderModelChange} + {...(props.onProviderConnectionRequested + ? { onProviderConnectionRequested: props.onProviderConnectionRequested } + : {})} onAfterSelection={handleAfterSelection} /> diff --git a/apps/web/src/components/kanban/KanbanNewTaskDialog.tsx b/apps/web/src/components/kanban/KanbanNewTaskDialog.tsx index db922d8d6..8c26a8195 100644 --- a/apps/web/src/components/kanban/KanbanNewTaskDialog.tsx +++ b/apps/web/src/components/kanban/KanbanNewTaskDialog.tsx @@ -56,6 +56,10 @@ import { Switch } from "~/components/ui/switch"; import { useProviderModelCatalog } from "~/hooks/useProviderModelCatalog"; import { useRefreshProviderStatusesNow } from "~/hooks/useProviderStatusRefresh"; import { useProviderStatusesForLocalConfig } from "~/hooks/useProviderStatusesForLocalConfig"; +import { + useApplyProviderSelectionAfterConnection, + useProviderConnectionSelectionIntent, +} from "~/hooks/useProviderSelectionAfterConnection"; import { useComposerDropzone } from "~/hooks/useComposerDropzone"; import { toastManager } from "~/components/ui/toast"; import { useTheme } from "~/hooks/useTheme"; @@ -147,6 +151,7 @@ export function KanbanNewTaskDialog({ clearComposerFileComments, removeComposerTerminalContext, } = useKanbanTaskScratchDraft({ defaultProvider: settings.defaultProvider }); + const providerSelectionIntent = useProviderConnectionSelectionIntent(scratchThreadId); const promptRef = useRef(prompt); const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); @@ -193,10 +198,27 @@ export function KanbanNewTaskDialog({ selectedProvider, // Keep discovery warm whenever either picker can open so cursor/codex effort // and fast-mode controls are populated, not just the model list. - discoveryEnabled: isModelPickerOpen || isTraitsPickerOpen, + discoveryEnabled: + isModelPickerOpen || isTraitsPickerOpen || providerSelectionIntent.pendingProvider !== null, cwd: providerModelDiscoveryCwd, modelHintByProvider, }); + const handleProviderConnectionRequested = useCallback( + (provider: ProviderKind) => { + providerSelectionIntent.request(provider, findProviderStatus(providerStatuses, provider)); + }, + [providerSelectionIntent, providerStatuses], + ); + useApplyProviderSelectionAfterConnection({ + controller: providerSelectionIntent, + scopeKey: scratchThreadId, + lockedProvider: null, + statuses: providerStatuses, + modelOptionsByProvider, + loadingModelProviders, + preferredModelByProvider: modelHintByProvider, + onProviderModelChange: handleProviderModelChange, + }); const trimmedPrompt = prompt.trim(); const hasSendableContent = trimmedPrompt.length > 0 || @@ -527,6 +549,7 @@ export function KanbanNewTaskDialog({ hiddenProviders={settings.hiddenProviders} providerOrder={settings.providerOrder} onProviderModelChange={handleProviderModelChange} + onProviderConnectionRequested={handleProviderConnectionRequested} open={isModelPickerOpen} onOpenChange={setIsModelPickerOpen} /> diff --git a/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx b/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx new file mode 100644 index 000000000..dc54f4e05 --- /dev/null +++ b/apps/web/src/hooks/useProviderSelectionAfterConnection.browser.tsx @@ -0,0 +1,207 @@ +import { type ModelSlug, type ProviderKind, type ServerProviderStatus } from "@synara/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; +import { useCallback, useMemo, useState } from "react"; + +import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; +import type { ProviderModelOption } from "../providerModelOptions"; +import { useProviderConnectionDialogStore } from "../providerConnectionDialogStore"; +import { findProviderStatus } from "../lib/providerAvailability"; +import { + useApplyProviderSelectionAfterConnection, + useProviderConnectionSelectionIntent, +} from "./useProviderSelectionAfterConnection"; + +const MODEL_OPTIONS: Record> = { + codex: [{ slug: "gpt-5.5", name: "GPT-5.5" }], + claudeAgent: [ + { slug: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, + ], + cursor: [{ slug: "auto", name: "Auto" }], + antigravity: [{ slug: "Gemini 3.5 Flash", name: "Gemini 3.5 Flash" }], + grok: [{ slug: "grok-build", name: "Grok" }], + droid: [{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" }], + kilo: [{ slug: "kilo/kilo-auto/free", name: "Kilo Auto" }], + opencode: [{ slug: "openai/gpt-5", name: "GPT-5" }], + pi: [{ slug: "anthropic/claude-opus", name: "Claude Opus" }], +}; + +const CODEX_READY: ServerProviderStatus = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-07-23T10:00:00.000Z", +}; + +const CLAUDE_UNAVAILABLE: ServerProviderStatus = { + provider: "claudeAgent", + status: "error", + available: false, + authStatus: "unauthenticated", + checkedAt: "2026-07-23T10:00:00.000Z", +}; + +function SelectionHarness() { + const [provider, setProvider] = useState("codex"); + const [model, setModel] = useState("gpt-5.5"); + const [lockedProvider, setLockedProvider] = useState(null); + const [statuses, setStatuses] = useState([ + CODEX_READY, + CLAUDE_UNAVAILABLE, + ]); + const controller = useProviderConnectionSelectionIntent("thread-1"); + const preferredModelByProvider = useMemo(() => ({ claudeAgent: "claude-opus-5" }), []); + const handleProviderModelChange = useCallback( + (nextProvider: ProviderKind, nextModel: ModelSlug) => { + setProvider(nextProvider); + setModel(nextModel); + }, + [], + ); + const handleProviderConnectionRequested = useCallback( + (requestedProvider: ProviderKind) => { + controller.request(requestedProvider, findProviderStatus(statuses, requestedProvider)); + }, + [controller, statuses], + ); + useApplyProviderSelectionAfterConnection({ + controller, + scopeKey: "thread-1", + lockedProvider, + statuses, + modelOptionsByProvider: MODEL_OPTIONS, + preferredModelByProvider, + onProviderModelChange: handleProviderModelChange, + }); + + const setClaudeConnection = ( + connectionStatus: "waiting_for_browser" | "connected" | "failed", + ) => { + setStatuses([ + CODEX_READY, + { + provider: "claudeAgent", + status: connectionStatus === "connected" ? "ready" : "error", + available: connectionStatus !== "failed", + authStatus: connectionStatus === "connected" ? "authenticated" : "unauthenticated", + checkedAt: "2026-07-23T10:00:02.000Z", + connectionState: { + operationId: "connect-claude-1", + method: "claude_account", + status: connectionStatus, + startedAt: "2099-07-23T10:00:01.000Z", + finishedAt: + connectionStatus === "waiting_for_browser" ? null : "2099-07-23T10:00:02.000Z", + message: + connectionStatus === "connected" + ? "Connected." + : connectionStatus === "failed" + ? "Sign in failed." + : "Finish signing in.", + }, + }, + ]); + }; + + return ( + <> + {`${provider}:${model}`} + + + + + + + ); +} + +describe("provider selection after connection browser journey", () => { + afterEach(() => { + document.body.innerHTML = ""; + useProviderConnectionDialogStore.getState().setOpen(false); + }); + + it("selects the requested provider and its prior model after background verification", async () => { + const screen = await render(); + try { + await page.getByRole("button", { name: "GPT-5.5" }).click(); + await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + await page.getByRole("button", { name: "Start connection" }).click(); + useProviderConnectionDialogStore.getState().setOpen(false); + await page.getByRole("button", { name: "Complete connection" }).click(); + + await expect + .element(page.getByLabelText("Selected provider and model")) + .toHaveTextContent("claudeAgent:claude-opus-5"); + } finally { + await screen.unmount(); + } + }); + + it("clears the pending selection when the connection fails", async () => { + const screen = await render(); + try { + await page.getByRole("button", { name: "GPT-5.5" }).click(); + await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + await page.getByRole("button", { name: "Start connection" }).click(); + await page.getByRole("button", { name: "Fail connection" }).click(); + await page.getByRole("button", { name: "Complete connection" }).click(); + await expect + .element(page.getByLabelText("Selected provider and model")) + .toHaveTextContent("codex:gpt-5.5"); + } finally { + await screen.unmount(); + } + }); + + it("clears the selection intent when setup is dismissed before an operation starts", async () => { + const screen = await render(); + try { + await page.getByRole("button", { name: "GPT-5.5" }).click(); + await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + useProviderConnectionDialogStore.getState().setOpen(false); + await page.getByRole("button", { name: "Complete connection" }).click(); + + await expect + .element(page.getByLabelText("Selected provider and model")) + .toHaveTextContent("codex:gpt-5.5"); + } finally { + await screen.unmount(); + } + }); + + it("does not override a composer that becomes provider-locked during connection", async () => { + const screen = await render(); + try { + await page.getByRole("button", { name: "GPT-5.5" }).click(); + await page.getByRole("menuitem", { name: /Claude.*Set up/u }).click(); + await page.getByRole("button", { name: "Start connection" }).click(); + await page.getByRole("button", { name: "Start Codex thread" }).click(); + await page.getByRole("button", { name: "Complete connection" }).click(); + await expect + .element(page.getByLabelText("Selected provider and model")) + .toHaveTextContent("codex:gpt-5.5"); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/hooks/useProviderSelectionAfterConnection.test.ts b/apps/web/src/hooks/useProviderSelectionAfterConnection.test.ts new file mode 100644 index 000000000..4547d72db --- /dev/null +++ b/apps/web/src/hooks/useProviderSelectionAfterConnection.test.ts @@ -0,0 +1,184 @@ +import type { ServerProviderStatus } from "@synara/contracts"; +import { describe, expect, it } from "vitest"; + +import { + createProviderSelectionIntent, + evaluateProviderSelectionIntent, + resolvePostConnectionModel, +} from "./useProviderSelectionAfterConnection"; + +const REQUESTED_AT = Date.parse("2026-07-23T10:00:00.000Z"); + +function status(overrides: Partial = {}): ServerProviderStatus { + return { + provider: "claudeAgent", + status: "error", + available: false, + authStatus: "unauthenticated", + checkedAt: "2026-07-23T09:59:00.000Z", + ...overrides, + }; +} + +function intent(initialStatus: ServerProviderStatus | null = status()) { + return createProviderSelectionIntent({ + token: 1, + scopeKey: "thread-1", + provider: "claudeAgent", + status: initialStatus, + requestedAt: REQUESTED_AT, + }); +} + +describe("provider selection after connection", () => { + it("becomes ready only after the provider is verified usable", () => { + expect( + evaluateProviderSelectionIntent({ + intent: intent(), + scopeKey: "thread-1", + lockedProvider: null, + status: status({ + status: "ready", + available: true, + authStatus: "authenticated", + connectionState: { + operationId: "connect-1", + method: "claude_account", + status: "connected", + startedAt: "2026-07-23T10:00:01.000Z", + finishedAt: "2026-07-23T10:00:02.000Z", + message: "Connected.", + }, + }), + }), + ).toEqual({ type: "ready", provider: "claudeAgent" }); + }); + + it("survives an in-progress connection and clears when that operation fails", () => { + const activeStatus = status({ + available: true, + connectionState: { + operationId: "connect-1", + method: "claude_account", + status: "waiting_for_browser", + startedAt: "2026-07-23T10:00:01.000Z", + finishedAt: null, + message: "Finish signing in.", + }, + }); + const active = evaluateProviderSelectionIntent({ + intent: intent(), + scopeKey: "thread-1", + lockedProvider: null, + status: activeStatus, + }); + expect(active.type).toBe("pending"); + if (active.type !== "pending") throw new Error("expected pending intent"); + + expect( + evaluateProviderSelectionIntent({ + intent: active.intent, + scopeKey: "thread-1", + lockedProvider: null, + status: status({ + available: true, + connectionState: { + ...activeStatus.connectionState!, + status: "failed", + finishedAt: "2026-07-23T10:00:03.000Z", + message: "Sign in failed.", + }, + }), + }), + ).toEqual({ type: "clear", reason: "failed" }); + }); + + it("does not mistake a stale terminal operation for the requested retry", () => { + const staleFailure = status({ + connectionState: { + operationId: "connect-old", + method: "claude_account", + status: "failed", + startedAt: "2026-07-23T09:00:00.000Z", + finishedAt: "2026-07-23T09:01:00.000Z", + message: "Earlier failure.", + }, + }); + const outcome = evaluateProviderSelectionIntent({ + intent: intent(staleFailure), + scopeKey: "thread-1", + lockedProvider: null, + status: staleFailure, + }); + expect(outcome.type).toBe("pending"); + }); + + it("clears after a new installation attempt fails", () => { + const outcome = evaluateProviderSelectionIntent({ + intent: intent(), + scopeKey: "thread-1", + lockedProvider: null, + status: status({ + installationState: { + operationId: "install-1", + operation: "install", + status: "failed", + startedAt: "2026-07-23T10:00:01.000Z", + finishedAt: "2026-07-23T10:00:03.000Z", + message: "Install failed.", + }, + }), + }); + expect(outcome).toEqual({ type: "clear", reason: "failed" }); + }); + + it("clears instead of overriding a thread that became provider-locked", () => { + expect( + evaluateProviderSelectionIntent({ + intent: intent(), + scopeKey: "thread-1", + lockedProvider: "codex", + status: status({ status: "ready", available: true, authStatus: "authenticated" }), + }), + ).toEqual({ type: "clear", reason: "provider_locked" }); + }); + + it("clears an intent when its owning composer scope changes", () => { + expect( + evaluateProviderSelectionIntent({ + intent: intent(), + scopeKey: "thread-2", + lockedProvider: null, + status: status(), + }), + ).toEqual({ type: "clear", reason: "scope_changed" }); + }); + + it("preserves a previous provider model, then uses the curated default, then catalog order", () => { + const options = [ + { slug: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, + ]; + expect( + resolvePostConnectionModel({ + provider: "claudeAgent", + preferredModel: "claude-opus-5", + options, + }), + ).toBe("claude-opus-5"); + expect( + resolvePostConnectionModel({ + provider: "claudeAgent", + preferredModel: "removed-model", + options, + }), + ).toBe("claude-sonnet-5"); + expect( + resolvePostConnectionModel({ + provider: "pi", + preferredModel: null, + options: [{ slug: "anthropic/claude-opus", name: "Claude Opus" }], + }), + ).toBe("anthropic/claude-opus"); + }); +}); diff --git a/apps/web/src/hooks/useProviderSelectionAfterConnection.ts b/apps/web/src/hooks/useProviderSelectionAfterConnection.ts new file mode 100644 index 000000000..7ba3a20e0 --- /dev/null +++ b/apps/web/src/hooks/useProviderSelectionAfterConnection.ts @@ -0,0 +1,357 @@ +// FILE: useProviderSelectionAfterConnection.ts +// Purpose: Carries a picker-origin provider selection through installation/authentication +// and commits it only after the provider is verified usable. +// Layer: Web UI orchestration + +import { + type ModelSlug, + type ProviderKind, + type ServerProviderConnectionState, + type ServerProviderInstallationState, + type ServerProviderStatus, +} from "@synara/contracts"; +import { getDefaultModel, resolveSelectableModel } from "@synara/shared/model"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { findProviderStatus, isProviderUsable } from "../lib/providerAvailability"; +import type { ProviderModelOption } from "../providerModelOptions"; +import { useProviderConnectionDialogStore } from "../providerConnectionDialogStore"; + +type ConnectionStatus = ServerProviderConnectionState["status"]; +type InstallationStatus = ServerProviderInstallationState["status"]; + +const ACTIVE_CONNECTION_STATUSES = new Set([ + "starting", + "waiting_for_browser", + "verifying", +]); +const FAILED_CONNECTION_STATUSES = new Set(["failed", "cancelled"]); +const ACTIVE_INSTALLATION_STATUSES = new Set([ + "resolving", + "awaiting_consent", + "downloading", + "verifying", + "installing", + "smoke_testing", +]); +const FAILED_INSTALLATION_STATUSES = new Set(["failed", "cancelled"]); + +interface OperationBaseline { + readonly operationId: string; + readonly status: TStatus; +} + +export interface ProviderSelectionIntent { + readonly token: number; + readonly scopeKey: string; + readonly provider: ProviderKind; + readonly requestedAt: number; + readonly baselineConnection: OperationBaseline | null; + readonly baselineInstallation: OperationBaseline | null; + readonly observedConnectionOperationId: string | null; + readonly observedInstallationOperationId: string | null; +} + +export interface ProviderSelectionIntentController { + readonly intent: ProviderSelectionIntent | null; + readonly pendingProvider: ProviderKind | null; + request: (provider: ProviderKind, status: ServerProviderStatus | null | undefined) => void; + clear: (token?: number) => void; + replace: (token: number, intent: ProviderSelectionIntent) => void; +} + +export type ProviderSelectionIntentOutcome = + | { readonly type: "pending"; readonly intent: ProviderSelectionIntent } + | { readonly type: "ready"; readonly provider: ProviderKind } + | { readonly type: "clear"; readonly reason: "scope_changed" | "provider_locked" | "failed" }; + +function operationBaseline( + operation: { readonly operationId: string; readonly status: TStatus } | null | undefined, +): OperationBaseline | null { + return operation + ? { + operationId: operation.operationId, + status: operation.status, + } + : null; +} + +export function createProviderSelectionIntent(input: { + readonly token: number; + readonly scopeKey: string; + readonly provider: ProviderKind; + readonly status: ServerProviderStatus | null | undefined; + readonly requestedAt?: number; +}): ProviderSelectionIntent { + const connection = input.status?.connectionState; + const installation = input.status?.installationState; + return { + token: input.token, + scopeKey: input.scopeKey, + provider: input.provider, + requestedAt: input.requestedAt ?? Date.now(), + baselineConnection: operationBaseline(connection), + baselineInstallation: operationBaseline(installation), + observedConnectionOperationId: + connection && ACTIVE_CONNECTION_STATUSES.has(connection.status) + ? connection.operationId + : null, + observedInstallationOperationId: + installation && ACTIVE_INSTALLATION_STATUSES.has(installation.status) + ? installation.operationId + : null, + }; +} + +function startedAfterRequest(startedAt: string, requestedAt: number): boolean { + const parsed = Date.parse(startedAt); + return Number.isFinite(parsed) && parsed >= requestedAt; +} + +function operationFailedAfterIntent(input: { + readonly operation: + | { readonly operationId: string; readonly status: TStatus; readonly startedAt: string } + | null + | undefined; + readonly failedStatuses: ReadonlySet; + readonly baseline: OperationBaseline | null; + readonly observedOperationId: string | null; + readonly requestedAt: number; +}): boolean { + const operation = input.operation; + if (!operation || !input.failedStatuses.has(operation.status)) { + return false; + } + if (input.observedOperationId === operation.operationId) { + return true; + } + if ( + input.baseline?.operationId === operation.operationId && + input.baseline.status !== operation.status + ) { + return true; + } + return startedAfterRequest(operation.startedAt, input.requestedAt); +} + +export function evaluateProviderSelectionIntent(input: { + readonly intent: ProviderSelectionIntent; + readonly scopeKey: string; + readonly lockedProvider: ProviderKind | null; + readonly status: ServerProviderStatus | null | undefined; +}): ProviderSelectionIntentOutcome { + const { intent } = input; + if (intent.scopeKey !== input.scopeKey) { + return { type: "clear", reason: "scope_changed" }; + } + if (input.lockedProvider !== null) { + return { type: "clear", reason: "provider_locked" }; + } + if (isProviderUsable(input.status)) { + return { type: "ready", provider: intent.provider }; + } + + const connection = input.status?.connectionState; + const installation = input.status?.installationState; + if ( + operationFailedAfterIntent({ + operation: connection, + failedStatuses: FAILED_CONNECTION_STATUSES, + baseline: intent.baselineConnection, + observedOperationId: intent.observedConnectionOperationId, + requestedAt: intent.requestedAt, + }) || + operationFailedAfterIntent({ + operation: installation, + failedStatuses: FAILED_INSTALLATION_STATUSES, + baseline: intent.baselineInstallation, + observedOperationId: intent.observedInstallationOperationId, + requestedAt: intent.requestedAt, + }) + ) { + return { type: "clear", reason: "failed" }; + } + + const observedConnectionOperationId = + connection && ACTIVE_CONNECTION_STATUSES.has(connection.status) + ? connection.operationId + : intent.observedConnectionOperationId; + const observedInstallationOperationId = + installation && ACTIVE_INSTALLATION_STATUSES.has(installation.status) + ? installation.operationId + : intent.observedInstallationOperationId; + if ( + observedConnectionOperationId === intent.observedConnectionOperationId && + observedInstallationOperationId === intent.observedInstallationOperationId + ) { + return { type: "pending", intent }; + } + return { + type: "pending", + intent: { + ...intent, + observedConnectionOperationId, + observedInstallationOperationId, + }, + }; +} + +export function resolvePostConnectionModel(input: { + readonly provider: ProviderKind; + readonly preferredModel: string | null | undefined; + readonly options: ReadonlyArray; +}): ModelSlug | null { + const preferred = resolveSelectableModel(input.provider, input.preferredModel, input.options); + if (preferred) { + return preferred; + } + const providerDefault = getDefaultModel(input.provider); + const resolvedDefault = resolveSelectableModel(input.provider, providerDefault, input.options); + return resolvedDefault ?? input.options[0]?.slug ?? null; +} + +export function useProviderConnectionSelectionIntent( + scopeKey: string, +): ProviderSelectionIntentController { + const [intent, setIntent] = useState(null); + const nextTokenRef = useRef(0); + + const request = useCallback( + (provider: ProviderKind, status: ServerProviderStatus | null | undefined) => { + nextTokenRef.current += 1; + setIntent( + createProviderSelectionIntent({ + token: nextTokenRef.current, + scopeKey, + provider, + status, + }), + ); + }, + [scopeKey], + ); + const clear = useCallback((token?: number) => { + setIntent((current) => (token === undefined || current?.token === token ? null : current)); + }, []); + const replace = useCallback((token: number, nextIntent: ProviderSelectionIntent) => { + setIntent((current) => (current?.token === token ? nextIntent : current)); + }, []); + + return useMemo( + () => ({ + intent, + pendingProvider: intent?.provider ?? null, + request, + clear, + replace, + }), + [clear, intent, replace, request], + ); +} + +export function useApplyProviderSelectionAfterConnection(input: { + readonly controller: ProviderSelectionIntentController; + readonly scopeKey: string; + readonly lockedProvider: ProviderKind | null; + readonly statuses: readonly ServerProviderStatus[]; + readonly modelOptionsByProvider: Record>; + readonly loadingModelProviders?: Partial>; + readonly preferredModelByProvider?: Partial>; + readonly canApply?: boolean; + readonly onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; +}): void { + const consumedTokenRef = useRef(null); + const seenPickerDialogTokenRef = useRef(null); + const connectionDialogOpen = useProviderConnectionDialogStore((state) => state.isOpen); + const connectionDialogProvider = useProviderConnectionDialogStore((state) => state.provider); + const connectionDialogSource = useProviderConnectionDialogStore((state) => state.source); + const { + canApply, + controller, + loadingModelProviders, + lockedProvider, + modelOptionsByProvider, + onProviderModelChange, + preferredModelByProvider, + scopeKey, + statuses, + } = input; + + useEffect(() => { + const intent = controller.intent; + if (!intent) { + return; + } + const status = findProviderStatus(statuses, intent.provider); + const pickerDialogMatchesIntent = + connectionDialogOpen && + connectionDialogProvider === intent.provider && + connectionDialogSource === "provider_picker"; + if (pickerDialogMatchesIntent) { + seenPickerDialogTokenRef.current = intent.token; + } + const outcome = evaluateProviderSelectionIntent({ + intent, + scopeKey, + lockedProvider, + status, + }); + if (outcome.type === "clear") { + controller.clear(intent.token); + return; + } + if (outcome.type === "pending") { + const operationStillRunning = + (status?.connectionState && + ACTIVE_CONNECTION_STATUSES.has(status.connectionState.status)) || + (status?.installationState && + ACTIVE_INSTALLATION_STATUSES.has(status.installationState.status)); + const operationWasObserved = + outcome.intent.observedConnectionOperationId !== null || + outcome.intent.observedInstallationOperationId !== null; + if ( + seenPickerDialogTokenRef.current === intent.token && + !pickerDialogMatchesIntent && + !operationStillRunning && + !operationWasObserved + ) { + controller.clear(intent.token); + return; + } + if (outcome.intent !== intent) { + controller.replace(intent.token, outcome.intent); + } + return; + } + if (loadingModelProviders?.[outcome.provider]) { + return; + } + if (canApply === false) { + return; + } + const model = resolvePostConnectionModel({ + provider: outcome.provider, + preferredModel: preferredModelByProvider?.[outcome.provider], + options: modelOptionsByProvider[outcome.provider], + }); + if (!model || consumedTokenRef.current === intent.token) { + return; + } + consumedTokenRef.current = intent.token; + controller.clear(intent.token); + onProviderModelChange(outcome.provider, model); + }, [ + canApply, + controller, + connectionDialogOpen, + connectionDialogProvider, + connectionDialogSource, + loadingModelProviders, + lockedProvider, + modelOptionsByProvider, + onProviderModelChange, + preferredModelByProvider, + scopeKey, + statuses, + ]); +} diff --git a/apps/web/src/routes/-automations.shared.tsx b/apps/web/src/routes/-automations.shared.tsx index 9ab2c1ed2..7a8643191 100644 --- a/apps/web/src/routes/-automations.shared.tsx +++ b/apps/web/src/routes/-automations.shared.tsx @@ -15,7 +15,7 @@ import { type ThreadId, } from "@synara/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useAppSettings } from "~/appSettings"; import { @@ -41,11 +41,16 @@ import { } from "~/components/ui/menu"; import { TimePicker } from "~/components/ui/time-picker"; import { toastManager } from "~/components/ui/toast"; +import { + useApplyProviderSelectionAfterConnection, + useProviderConnectionSelectionIntent, +} from "~/hooks/useProviderSelectionAfterConnection"; import { hasBlockingAutomationDraftWarnings, type AutomationDraftWarning, type AutomationDraftWarningId, } from "~/lib/automationDraft"; +import { findProviderStatus } from "~/lib/providerAvailability"; import { acknowledgedRiskIdsForFormWarnings, applyScheduleToForm, @@ -703,6 +708,8 @@ export function AutomationModelPicker({ const serverConfigQuery = useQuery(serverConfigQueryOptions()); const providerStatuses = useProviderStatusesForLocalConfig(); const [open, setOpen] = useState(false); + const selectionScope = useId(); + const providerSelectionIntent = useProviderConnectionSelectionIntent(selectionScope); const modelHintByProvider = useMemo>>( () => ({ [value.provider]: value.model }), [value.model, value.provider], @@ -714,10 +721,30 @@ export function AutomationModelPicker({ }); const { modelOptionsByProvider, loadingModelProviders } = useProviderModelCatalog({ selectedProvider: value.provider, - discoveryEnabled: open, + discoveryEnabled: open || providerSelectionIntent.pendingProvider !== null, cwd: providerModelDiscoveryCwd, modelHintByProvider, }); + const handleProviderModelChange = useCallback( + (provider: ProviderKind, model: string) => onChange(buildModelSelection(provider, model)), + [onChange], + ); + const handleProviderConnectionRequested = useCallback( + (provider: ProviderKind) => { + providerSelectionIntent.request(provider, findProviderStatus(providerStatuses, provider)); + }, + [providerSelectionIntent, providerStatuses], + ); + useApplyProviderSelectionAfterConnection({ + controller: providerSelectionIntent, + scopeKey: selectionScope, + lockedProvider: null, + statuses: providerStatuses, + modelOptionsByProvider, + loadingModelProviders, + preferredModelByProvider: modelHintByProvider, + onProviderModelChange: handleProviderModelChange, + }); return ( onChange(buildModelSelection(provider, model))} + onProviderModelChange={handleProviderModelChange} + onProviderConnectionRequested={handleProviderConnectionRequested} /> ); }