Skip to content
Merged
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
104 changes: 80 additions & 24 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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") &&
Expand Down Expand Up @@ -2229,6 +2261,24 @@ export default function ChatView({
antigravityModelsQuery.data?.source === "antigravity.cli" &&
(antigravityModelsQuery.data.models.length ?? 0) > 0
) && isInitialModelDiscoveryPending(antigravityModelsQuery);
const providerModelDiscoveryPendingByProvider = useMemo<Partial<Record<ProviderKind, boolean>>>(
() => ({
antigravity: antigravityModelDiscoveryPending,
cursor: cursorModelDiscoveryPending,
droid: droidModelDiscoveryPending,
kilo: kiloModelDiscoveryPending,
opencode: openCodeModelDiscoveryPending,
pi: piModelDiscoveryPending,
}),
[
antigravityModelDiscoveryPending,
cursorModelDiscoveryPending,
droidModelDiscoveryPending,
kiloModelDiscoveryPending,
openCodeModelDiscoveryPending,
piModelDiscoveryPending,
],
);
const modelOptionsByProvider = useMemo(() => {
const staticOptions: Record<ProviderKind, ReturnType<typeof getAppModelOptions>> = {
codex: getAppModelOptions(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -8975,6 +9030,7 @@ export default function ChatView({
prompt={prompt}
onPromptChange={setPromptFromTraits}
onProviderModelChange={onProviderModelSelect}
onProviderConnectionRequested={onProviderConnectionRequested}
onSelectionCommitted={scheduleComposerFocus}
open={isComposerModelEffortPickerOpen}
onOpenChange={handleComposerModelEffortPickerOpenChange}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ComposerModelEffortPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
/>
</ComposerPickerMenuSubPopup>
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/ProviderModelPicker.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async function mountPicker(props: {
providers?: ReadonlyArray<ServerProviderStatus>;
loadingModelProviders?: Partial<Record<ProviderKind, boolean>>;
onSelectionCommitted?: () => void;
onProviderConnectionRequested?: (provider: ProviderKind) => void;
modelOptionsByProvider?: Record<
ProviderKind,
ReadonlyArray<ProviderModelOption & { slug: ModelSlug }>
Expand All @@ -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 },
Expand Down Expand Up @@ -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",
Expand All @@ -552,6 +557,7 @@ describe("ProviderModelPicker", () => {
checkedAt: "2026-04-10T10:00:00.000Z",
},
],
onProviderConnectionRequested,
});

try {
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ type ProviderModelMenuItemsProps = {
providerOrder?: ReadonlyArray<ProviderKind>;
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;
Expand Down Expand Up @@ -447,6 +450,7 @@ export const ProviderModelMenuItems = memo(function ProviderModelMenuItems(
<MenuItem
key={option.value}
onClick={() => {
props.onProviderConnectionRequested?.(option.value);
onAfterSelection?.();
window.setTimeout(() => openProviderConnection(option.value, "provider_picker"), 0);
}}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}
/>
</ComposerPickerMenuPopup>
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/components/kanban/KanbanNewTaskDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<RuntimeMode>(DEFAULT_RUNTIME_MODE);
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -527,6 +549,7 @@ export function KanbanNewTaskDialog({
hiddenProviders={settings.hiddenProviders}
providerOrder={settings.providerOrder}
onProviderModelChange={handleProviderModelChange}
onProviderConnectionRequested={handleProviderConnectionRequested}
open={isModelPickerOpen}
onOpenChange={setIsModelPickerOpen}
/>
Expand Down
Loading
Loading