Files
claudecodeui/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx

307 lines
10 KiB
TypeScript

import React, { useCallback, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
Card,
} from "../../../../shared/view/ui";
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: LLMProvider;
setProvider: (next: LLMProvider) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
};
interface ProviderGroup {
id: LLMProvider;
name: string;
models: { value: string; label: string }[];
}
const PROVIDER_GROUPS: ProviderGroup[] = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS },
];
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getCurrentModel(
p: LLMProvider,
c: string,
cu: string,
co: string,
g: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
return cu;
}
function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
return "Gemini";
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const [dialogOpen, setDialogOpen] = useState(false);
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
const currentModel = getCurrentModel(
provider,
claudeModel,
cursorModel,
codexModel,
geminiModel,
);
const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
const handleModelSelect = useCallback(
(providerId: LLMProvider, modelValue: string) => {
// Set provider
setProvider(providerId);
localStorage.setItem("selected-provider", providerId);
// Set model for the correct provider
if (providerId === "claude") {
setClaudeModel(modelValue);
localStorage.setItem("claude-model", modelValue);
} else if (providerId === "codex") {
setCodexModel(modelValue);
localStorage.setItem("codex-model", modelValue);
} else if (providerId === "gemini") {
setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
setDialogOpen(false);
setTimeout(() => textareaRef.current?.focus(), 100);
},
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
);
/* ── New session — provider + model picker ── */
if (!selectedSession && !currentSessionId) {
return (
<div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Heading */}
<div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t("providerSelection.title")}
</h2>
<p className="mt-1 text-[13px] text-muted-foreground">
{t("providerSelection.description")}
</p>
</div>
{/* Model selector trigger — hero card style */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Card
className="group mx-auto max-w-sm cursor-pointer border-border/60 transition-all duration-150 hover:border-border hover:shadow-md active:scale-[0.99]"
role="button"
tabIndex={0}
>
<div className="flex items-center gap-3 p-4">
<SessionProviderLogo
provider={provider}
className="h-8 w-8 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-foreground">
{getProviderDisplayName(provider)}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="truncate text-sm text-foreground">
{currentModelLabel}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providerSelection.clickToChange", {
defaultValue: "Click to change model",
})}
</p>
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-y-0.5" />
</div>
</Card>
</DialogTrigger>
<DialogContent className="max-w-md overflow-hidden p-0">
<DialogTitle>Model Selector</DialogTitle>
<Command>
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
<CommandList className="max-h-[350px]">
<CommandEmpty>
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
</CommandEmpty>
{PROVIDER_GROUPS.map((group) => (
<CommandGroup
key={group.id}
heading={
<span className="flex items-center gap-1.5">
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
{group.name}
</span>
}
>
{group.models.map((model) => {
const isSelected =
provider === group.id && currentModel === model.value;
return (
<CommandItem
key={`${group.id}-${model.value}`}
value={`${group.name} ${model.label}`}
onSelect={() => handleModelSelect(group.id, model.value)}
>
<span className="flex-1 truncate">{model.label}</span>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
{/* Ready prompt */}
<p className="mt-4 text-center text-sm text-muted-foreground/70">
{
{
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
</div>
)}
</div>
</div>
);
}
/* ── Existing session — continue prompt ── */
if (selectedSession) {
return (
<div className="flex h-full items-center justify-center">
<div className="max-w-md px-6 text-center">
<p className="mb-1.5 text-lg font-semibold text-foreground">
{t("session.continue.title")}
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
{t("session.continue.description")}
</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner
onStartTask={() => setInput(nextTaskPrompt)}
onShowAllTasks={onShowAllTasks}
/>
</div>
)}
</div>
</div>
);
}
return null;
}