Files
tolaria/src/components/AiAgentsOnboardingPrompt.tsx
2026-05-26 11:42:22 +02:00

191 lines
7.1 KiB
TypeScript

import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Cloud, HardDrive, Robot as Bot, Terminal } from '@phosphor-icons/react'
import {
AI_AGENT_DEFINITIONS,
getAiAgentAvailability,
getAiAgentDefinition,
hasAnyInstalledAiAgent,
isAiAgentsStatusChecking,
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
interface AiAgentsOnboardingPromptProps {
statuses: AiAgentsStatus
onContinue: () => void
}
function getPromptCopy(statuses: AiAgentsStatus) {
if (isAiAgentsStatusChecking(statuses)) {
return {
accentClassName: 'bg-muted text-muted-foreground',
description: 'Checking local agents. You can also use a local model or API provider.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking AI agents',
}
}
if (!hasAnyInstalledAiAgent(statuses)) {
return {
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
description: 'Connect a local model, an API provider, or a desktop coding agent.',
icon: <Bot className="size-7" />,
title: 'Choose how Tolaria should use AI',
}
}
return {
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
description: 'You can use the detected local agents, or add local/API models in Settings.',
icon: <CheckCircle2 className="size-7" />,
title: 'AI is ready',
}
}
function AiModeChoices() {
const choices = [
{
icon: <HardDrive className="size-4" />,
title: 'Local model',
description: 'Use Ollama, LM Studio, or another local OpenAI-compatible endpoint. API keys are usually not needed.',
},
{
icon: <Cloud className="size-4" />,
title: 'API provider',
description: 'Use OpenAI, Anthropic, OpenRouter, or a gateway. API keys are read from environment variables, not saved in settings.',
},
{
icon: <Terminal className="size-4" />,
title: 'Local agent',
description: 'Use Claude Code, Codex, OpenCode, Gemini CLI, or Pi for tool-capable vault editing on desktop.',
},
]
return (
<div className="grid gap-3 md:grid-cols-3">
{choices.map((choice) => (
<div key={choice.title} className="rounded-lg border border-border bg-muted/20 p-3 text-left">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
{choice.icon}
{choice.title}
</div>
<div className="text-xs leading-5 text-muted-foreground">{choice.description}</div>
</div>
))}
</div>
)
}
function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
return (
<div className="space-y-3">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = getAiAgentAvailability(statuses, definition.id)
const ready = status.status === 'installed'
return (
<div
key={definition.id}
className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm"
>
<div className="space-y-1 text-left">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
</div>
</div>
<span
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]' : 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]'}`}
>
{ready ? 'Installed' : 'Missing'}
</span>
</div>
)
})}
</div>
)
}
export function AiAgentsOnboardingPrompt({
statuses,
onContinue,
}: AiAgentsOnboardingPromptProps) {
const copy = getPromptCopy(statuses)
const showLegacyClaudeCompatibility = getAiAgentAvailability(statuses, 'claude_code').status !== 'installed'
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => getAiAgentAvailability(statuses, definition.id).status === 'missing')
return (
<OnboardingShell
className="bg-sidebar px-6 py-10"
contentClassName="w-full max-w-2xl"
testId="ai-agents-onboarding-screen"
>
<Card
className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden border-border bg-background shadow-sm"
data-testid="ai-agents-onboarding-card"
>
<CardHeader className="shrink-0 items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
</div>
<div className="space-y-2">
<CardTitle className="text-3xl tracking-tight">
{copy.title}
</CardTitle>
<p className="text-sm leading-6 text-muted-foreground" data-testid="ai-agents-onboarding-description">
{copy.description}
</p>
</div>
</CardHeader>
<CardContent
className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain"
data-testid="ai-agents-onboarding-scroll"
>
<AiModeChoices />
{showLegacyClaudeCompatibility ? (
<div
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
data-testid="claude-onboarding-screen"
>
<div className="text-sm font-medium text-[var(--feedback-warning-text)]">Claude Code not detected</div>
<p className="mt-1 text-xs leading-5 text-[var(--feedback-warning-text)]">
Install Claude Code or continue without it.
</p>
</div>
) : null}
<AgentStatusList statuses={statuses} />
</CardContent>
<CardFooter className="shrink-0 flex-wrap justify-center gap-3">
{missingAgents.map((definition) => (
<Button
key={definition.id}
type="button"
variant="outline"
onClick={() => void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)}
data-testid={`ai-agents-onboarding-install-${definition.id}`}
>
Install {definition.label}
<ArrowUpRight className="size-4" />
</Button>
))}
<div data-testid="ai-agents-onboarding-continue">
<Button
type="button"
onClick={onContinue}
disabled={isAiAgentsStatusChecking(statuses)}
data-testid={showLegacyClaudeCompatibility ? 'claude-onboarding-continue' : undefined}
>
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Set up later'}
</Button>
</div>
</CardFooter>
</Card>
</OnboardingShell>
)
}