feat: add direct AI model providers
This commit is contained in:
@@ -58,7 +58,7 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
claude_code: { status: 'installed', version: '1.0.20' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI is ready')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
|
||||
})
|
||||
@@ -66,12 +66,12 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
it('shows the missing state when no agents are installed', () => {
|
||||
renderPrompt()
|
||||
|
||||
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose how Tolaria should use AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Set up later')
|
||||
})
|
||||
|
||||
it('opens the agent install links', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
getAiAgentDefinition,
|
||||
@@ -20,7 +20,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (isAiAgentsStatusChecking(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-muted text-muted-foreground',
|
||||
description: 'Checking which AI agents are available on this machine.',
|
||||
description: 'Checking coding agents. You can also use a local model or API provider.',
|
||||
icon: <Loader2 className="size-7 animate-spin" />,
|
||||
title: 'Checking AI agents',
|
||||
}
|
||||
@@ -29,20 +29,54 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (!hasAnyInstalledAiAgent(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
|
||||
description: 'Tolaria works best with a local CLI AI agent installed.',
|
||||
description: 'Connect a local model, an API provider, or a desktop coding agent.',
|
||||
icon: <Bot className="size-7" />,
|
||||
title: 'No AI agents detected',
|
||||
title: 'Choose how Tolaria should use AI',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
|
||||
description: 'Your AI agents are ready to use in Tolaria.',
|
||||
description: 'You can use the detected coding agents, or add local/API models in Settings.',
|
||||
icon: <CheckCircle2 className="size-7" />,
|
||||
title: 'AI agents ready',
|
||||
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: 'Coding 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">
|
||||
@@ -104,6 +138,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<AiModeChoices />
|
||||
{showLegacyClaudeCompatibility ? (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
|
||||
@@ -138,7 +173,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
disabled={isAiAgentsStatusChecking(statuses)}
|
||||
data-testid={showLegacyClaudeCompatibility ? 'claude-onboarding-continue' : undefined}
|
||||
>
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Continue without it'}
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Set up later'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AiAgentId,
|
||||
type AiAgentReadiness,
|
||||
} from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -25,6 +26,7 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -47,6 +49,7 @@ interface AiPanelViewProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -64,6 +67,7 @@ export function AiPanelView({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -75,7 +79,9 @@ export function AiPanelView({
|
||||
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
const activeTarget = defaultAiTarget
|
||||
const agentLabel = activeTarget?.label ?? getAiAgentDefinition(defaultAiAgent).label
|
||||
const targetKind = activeTarget?.kind ?? 'agent'
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
@@ -118,6 +124,7 @@ export function AiPanelView({
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
targetKind={targetKind}
|
||||
locale={locale}
|
||||
permissionMode={permissionMode}
|
||||
permissionModeDisabled={isActive}
|
||||
@@ -159,6 +166,7 @@ export function AiPanel({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -178,6 +186,7 @@ export function AiPanel({
|
||||
const controller = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry,
|
||||
@@ -200,6 +209,7 @@ export function AiPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
locale={locale}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { VaultEntry } from '../types'
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
targetKind?: 'agent' | 'api_model'
|
||||
locale?: AppLocale
|
||||
permissionMode: AiAgentPermissionMode
|
||||
permissionModeDisabled: boolean
|
||||
@@ -167,6 +168,7 @@ function AiPanelEmptyState({
|
||||
export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
targetKind = 'agent',
|
||||
locale = 'en',
|
||||
permissionMode,
|
||||
permissionModeDisabled,
|
||||
@@ -175,7 +177,9 @@ export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
const t = createTranslator(locale)
|
||||
const modeLabel = aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
const modeLabel = targetKind === 'api_model'
|
||||
? t('ai.panel.mode.chat')
|
||||
: aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -213,12 +217,18 @@ export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
{targetKind === 'agent' ? (
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-muted px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
||||
{t('ai.panel.mode.chatDescription')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
363
src/components/AiProviderSettings.tsx
Normal file
363
src/components/AiProviderSettings.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
configuredModelTargets,
|
||||
isLocalAiProvider,
|
||||
normalizeAiModelProviders,
|
||||
type AiModelProvider,
|
||||
type AiModelProviderKind,
|
||||
} from '../lib/aiTargets'
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { deleteAiModelProviderApiKey, saveAiModelProviderApiKey, testAiModelProvider } from '../utils/aiProviderSecrets'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
type ProviderMode = 'local' | 'api'
|
||||
type ApiKeyStorage = 'none' | 'local_file' | 'env'
|
||||
type TestState = 'idle' | 'testing' | 'success'
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onChange: (providers: AiModelProvider[]) => void
|
||||
}
|
||||
|
||||
interface ProviderDraft {
|
||||
kind: AiModelProviderKind
|
||||
name: string
|
||||
baseUrl: string
|
||||
modelId: string
|
||||
apiKeyStorage: ApiKeyStorage
|
||||
apiKey: string
|
||||
apiKeyEnvVar: string
|
||||
}
|
||||
|
||||
const LOCAL_PROVIDER_KINDS: AiModelProviderKind[] = ['ollama', 'lm_studio']
|
||||
const API_PROVIDER_KINDS: AiModelProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
|
||||
|
||||
const PROVIDER_PRESETS: Record<AiModelProviderKind, { name: string; baseUrl: string }> = {
|
||||
ollama: { name: 'Ollama', baseUrl: 'http://localhost:11434/v1' },
|
||||
lm_studio: { name: 'LM Studio', baseUrl: 'http://127.0.0.1:1234/v1' },
|
||||
open_ai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
|
||||
anthropic: { name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1' },
|
||||
gemini: { name: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
open_router: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' },
|
||||
open_ai_compatible: { name: 'Custom provider', baseUrl: 'https://api.example.com/v1' },
|
||||
}
|
||||
|
||||
function initialDraft(mode: ProviderMode): ProviderDraft {
|
||||
const kind = mode === 'local' ? 'ollama' : 'open_ai'
|
||||
return {
|
||||
kind,
|
||||
name: PROVIDER_PRESETS[kind].name,
|
||||
baseUrl: PROVIDER_PRESETS[kind].baseUrl,
|
||||
modelId: '',
|
||||
apiKeyStorage: mode === 'local' ? 'none' : 'local_file',
|
||||
apiKey: '',
|
||||
apiKeyEnvVar: '',
|
||||
}
|
||||
}
|
||||
|
||||
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
|
||||
const kinds = mode === 'local' ? LOCAL_PROVIDER_KINDS : API_PROVIDER_KINDS
|
||||
return kinds.map((kind) => ({ value: kind, label: providerKindLabel(kind, t) }))
|
||||
}
|
||||
|
||||
function providerKindLabel(kind: AiModelProviderKind, t: Translate): string {
|
||||
return {
|
||||
ollama: t('settings.aiProviders.kind.ollama'),
|
||||
lm_studio: t('settings.aiProviders.kind.lmStudio'),
|
||||
open_ai: t('settings.aiProviders.kind.openAi'),
|
||||
anthropic: t('settings.aiProviders.kind.anthropic'),
|
||||
gemini: t('settings.aiProviders.kind.gemini'),
|
||||
open_router: t('settings.aiProviders.kind.openRouter'),
|
||||
open_ai_compatible: t('settings.aiProviders.kind.compatible'),
|
||||
}[kind]
|
||||
}
|
||||
|
||||
function modelPlaceholder(kind: AiModelProviderKind, mode: ProviderMode): string {
|
||||
if (mode === 'local') return 'llama3.2'
|
||||
if (kind === 'anthropic') return 'claude-3-5-sonnet-latest'
|
||||
if (kind === 'gemini') return 'gemini-2.5-flash'
|
||||
if (kind === 'open_router') return 'openai/gpt-4.1-mini'
|
||||
return 'gpt-4.1-mini'
|
||||
}
|
||||
|
||||
function apiKeyEnvPlaceholder(kind: AiModelProviderKind): string {
|
||||
if (kind === 'anthropic') return 'ANTHROPIC_API_KEY'
|
||||
if (kind === 'gemini') return 'GEMINI_API_KEY'
|
||||
if (kind === 'open_router') return 'OPENROUTER_API_KEY'
|
||||
return 'OPENAI_API_KEY'
|
||||
}
|
||||
|
||||
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
name: draft.name,
|
||||
kind: draft.kind,
|
||||
base_url: draft.baseUrl || null,
|
||||
api_key_storage: draft.apiKeyStorage,
|
||||
api_key_env_var: draft.apiKeyStorage === 'env' ? draft.apiKeyEnvVar || null : null,
|
||||
headers: null,
|
||||
models: [{
|
||||
id: draft.modelId,
|
||||
display_name: null,
|
||||
context_window: null,
|
||||
max_output_tokens: null,
|
||||
capabilities: DEFAULT_MODEL_CAPABILITIES,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function providerModeTitle(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localTitle') : t('settings.aiProviders.apiTitle')
|
||||
}
|
||||
|
||||
function providerModeDescription(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localDescription') : t('settings.aiProviders.apiDescription')
|
||||
}
|
||||
|
||||
function providerStorageLabel(provider: AiModelProvider, t: Translate): string {
|
||||
if (provider.api_key_storage === 'local_file') return t('settings.aiProviders.keyLocalSaved')
|
||||
if (provider.api_key_storage === 'env' && provider.api_key_env_var) {
|
||||
return t('settings.aiProviders.keyEnvSaved', { env: provider.api_key_env_var })
|
||||
}
|
||||
return t('settings.aiProviders.noKey')
|
||||
}
|
||||
|
||||
function visibleProviders(providers: AiModelProvider[], mode: ProviderMode): AiModelProvider[] {
|
||||
return providers.filter((provider) => mode === 'local' ? isLocalAiProvider(provider) : !isLocalAiProvider(provider))
|
||||
}
|
||||
|
||||
function editableInputClassName(): string {
|
||||
return 'border-border bg-background text-foreground placeholder:text-muted-foreground/65 shadow-xs'
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: 'text' | 'password'
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{label}</span>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={editableInputClassName()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKindSelect({
|
||||
mode,
|
||||
t,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
mode: ProviderMode
|
||||
t: Translate
|
||||
value: AiModelProviderKind
|
||||
onChange: (value: AiModelProviderKind) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{t('settings.aiProviders.kind')}</span>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as AiModelProviderKind)}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerKindOptions(mode, t).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyStorageFields({
|
||||
t,
|
||||
draft,
|
||||
updateDraft,
|
||||
}: {
|
||||
t: Translate
|
||||
draft: ProviderDraft
|
||||
updateDraft: (patch: Partial<ProviderDraft>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{t('settings.aiProviders.keyStorage')}</span>
|
||||
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as ApiKeyStorage })}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local_file">{t('settings.aiProviders.keyStorage.local')}</SelectItem>
|
||||
<SelectItem value="env">{t('settings.aiProviders.keyStorage.env')}</SelectItem>
|
||||
<SelectItem value="none">{t('settings.aiProviders.keyStorage.none')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{draft.apiKeyStorage === 'local_file' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.key')}
|
||||
value={draft.apiKey}
|
||||
onChange={(apiKey) => updateDraft({ apiKey })}
|
||||
placeholder={t('settings.aiProviders.keyPlaceholder')}
|
||||
type="password"
|
||||
/>
|
||||
) : null}
|
||||
{draft.apiKeyStorage === 'env' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.keyEnv')}
|
||||
value={draft.apiKeyEnvVar}
|
||||
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
|
||||
placeholder={apiKeyEnvPlaceholder(draft.kind)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderList({
|
||||
t,
|
||||
mode,
|
||||
providers,
|
||||
onRemove,
|
||||
}: {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onRemove: (providerId: string) => void
|
||||
}) {
|
||||
const visible = visibleProviders(providers, mode)
|
||||
if (visible.length === 0) {
|
||||
return <div className="rounded-md border border-dashed border-border bg-background px-3 py-2 text-xs text-muted-foreground">{t('settings.aiProviders.empty')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{configuredModelTargets(visible).map((target) => (
|
||||
<div key={target.id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{target.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{target.provider.base_url || t('settings.aiProviders.defaultEndpoint')} · {providerStorageLabel(target.provider, t)}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onRemove(target.provider.id)}>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderSettingsProps) {
|
||||
const [draft, setDraft] = useState<ProviderDraft>(() => initialDraft(mode))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testState, setTestState] = useState<TestState>('idle')
|
||||
const updateDraft = (patch: Partial<ProviderDraft>) => setDraft((current) => ({ ...current, ...patch }))
|
||||
const resetTest = () => {
|
||||
setTestState('idle')
|
||||
setError(null)
|
||||
}
|
||||
const updateForm = (patch: Partial<ProviderDraft>) => {
|
||||
resetTest()
|
||||
updateDraft(patch)
|
||||
}
|
||||
const updateKind = (kind: AiModelProviderKind) => updateForm({ kind, ...PROVIDER_PRESETS[kind] })
|
||||
const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim())
|
||||
const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null
|
||||
|
||||
const addProvider = async () => {
|
||||
const providerId = `${draft.kind}-${Date.now().toString(36)}`
|
||||
setError(null)
|
||||
try {
|
||||
if (draft.apiKeyStorage === 'local_file') {
|
||||
await saveAiModelProviderApiKey(providerId, draft.apiKey)
|
||||
}
|
||||
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
|
||||
setDraft((current) => ({ ...initialDraft(mode), kind: current.kind, name: current.name, baseUrl: current.baseUrl }))
|
||||
setTestState('idle')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const testProvider = async () => {
|
||||
setError(null)
|
||||
setTestState('testing')
|
||||
try {
|
||||
await testAiModelProvider(buildProvider(draft, 'draft-provider-test'), draft.modelId, apiKeyOverride)
|
||||
setTestState('success')
|
||||
} catch (error) {
|
||||
setTestState('idle')
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const removeProvider = (providerId: string) => {
|
||||
void deleteAiModelProviderApiKey(providerId)
|
||||
onChange(providers.filter((provider) => provider.id !== providerId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{providerModeTitle(mode, t)}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{providerModeDescription(mode, t)}</div>
|
||||
</div>
|
||||
<ProviderList t={t} mode={mode} providers={providers} onRemove={removeProvider} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ProviderKindSelect mode={mode} t={t} value={draft.kind} onChange={updateKind} />
|
||||
<LabeledInput label={t('settings.aiProviders.name')} value={draft.name} onChange={(name) => updateForm({ name })} />
|
||||
<LabeledInput label={t('settings.aiProviders.baseUrl')} value={draft.baseUrl} onChange={(baseUrl) => updateForm({ baseUrl })} />
|
||||
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={modelPlaceholder(draft.kind, mode)} />
|
||||
{mode === 'api' ? <ApiKeyStorageFields t={t} draft={draft} updateDraft={updateForm} /> : null}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
{mode === 'api' ? t('settings.aiProviders.keySafetyLocal') : t('settings.aiProviders.localSafety')}
|
||||
</div>
|
||||
{testState === 'success' ? <div className="text-xs text-emerald-700">{t('settings.aiProviders.testSuccess')}</div> : null}
|
||||
{error ? <div className="text-xs text-destructive">{error}</div> : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" size="sm" onClick={() => void addProvider()} disabled={!canSave}>
|
||||
{mode === 'local' ? t('settings.aiProviders.addLocal') : t('settings.aiProviders.addApi')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void testProvider()}
|
||||
disabled={!canSave || testState === 'testing'}
|
||||
>
|
||||
{testState === 'testing' ? t('settings.aiProviders.testing') : t('settings.aiProviders.test')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '@blocknote/mantine/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
|
||||
@@ -56,6 +57,7 @@ interface EditorProps {
|
||||
onToggleInspector: () => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
onInspectorResize: (delta: number) => void
|
||||
@@ -346,6 +348,7 @@ function EditorLayout({
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
@@ -410,6 +413,7 @@ function EditorLayout({
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
@@ -496,6 +500,7 @@ function EditorLayout({
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
@@ -529,122 +534,52 @@ function EditorLayout({
|
||||
)
|
||||
}
|
||||
|
||||
type EditorRuntime = ReturnType<typeof useEditorSetup>
|
||||
type EditorLayoutProps = Parameters<typeof EditorLayout>[0]
|
||||
|
||||
function buildEditorLayoutProps(
|
||||
props: EditorProps,
|
||||
runtime: EditorRuntime,
|
||||
findRequest: RawEditorFindRequest | null,
|
||||
): EditorLayoutProps {
|
||||
return {
|
||||
...props,
|
||||
...runtime,
|
||||
activeTabPath: props.activeTabPath,
|
||||
defaultAiAgent: props.defaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiAgentReady: props.defaultAiAgentReady ?? true,
|
||||
findRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
isVaultLoading,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteWidth, onToggleNoteWidth,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
|
||||
} = props
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
const runtime = useEditorSetup({
|
||||
tabs: props.tabs,
|
||||
activeTabPath: props.activeTabPath,
|
||||
vaultPath: props.vaultPath,
|
||||
onContentChange: props.onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
getNoteStatus: props.getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef,
|
||||
diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const findRequest = useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
activeTab: runtime.activeTab,
|
||||
findInNoteRef: props.findInNoteRef,
|
||||
handleToggleRawExclusive: runtime.handleToggleRawExclusive,
|
||||
rawMode: runtime.rawMode,
|
||||
})
|
||||
useRegisterEditorContentFlushes({
|
||||
activeTab,
|
||||
flushPendingEditorChange,
|
||||
flushPendingEditorContentRef,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
activeTab: runtime.activeTab,
|
||||
flushPendingEditorChange: runtime.flushPendingEditorChange,
|
||||
flushPendingEditorContentRef: props.flushPendingEditorContentRef,
|
||||
rawLatestContentRef: runtime.rawLatestContentRef,
|
||||
rawMode: runtime.rawMode,
|
||||
onContentChange: props.onContentChange,
|
||||
flushPendingRawContentRef: props.flushPendingRawContentRef,
|
||||
})
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTabPath={props.activeTabPath}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
isVaultLoading={isVaultLoading}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
return <EditorLayout {...buildEditorLayoutProps(props, runtime, findRequest)} />
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
@@ -13,6 +14,7 @@ interface EditorRightPanelProps {
|
||||
inspectorCollapsed: boolean
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
@@ -43,7 +45,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
@@ -55,6 +57,7 @@ export function EditorRightPanel({
|
||||
const aiPanelController = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry: inspectorEntry,
|
||||
@@ -91,6 +94,7 @@ export function EditorRightPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
locale={locale}
|
||||
|
||||
73
src/components/PrivacySettingsSection.tsx
Normal file
73
src/components/PrivacySettingsSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { SectionHeading, SettingsGroup, SettingsGroupItem } from './SettingsControls'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
|
||||
interface PrivacySettingsSectionProps {
|
||||
t: Translate
|
||||
crashReporting: boolean
|
||||
setCrashReporting: (value: boolean) => void
|
||||
analytics: boolean
|
||||
setAnalytics: (value: boolean) => void
|
||||
}
|
||||
|
||||
function isChecked(checked: boolean | 'indeterminate'): boolean {
|
||||
return checked === true
|
||||
}
|
||||
|
||||
function TelemetryToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
testId: string
|
||||
}) {
|
||||
return (
|
||||
<SettingsGroupItem testId={testId}>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" />
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
<span className="block text-xs leading-5 text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
</SettingsGroupItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrivacySettingsSection({
|
||||
t,
|
||||
crashReporting,
|
||||
setCrashReporting,
|
||||
analytics,
|
||||
setAnalytics,
|
||||
}: PrivacySettingsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading title={t('settings.privacy.title')} />
|
||||
<SettingsGroup>
|
||||
<TelemetryToggle
|
||||
label={t('settings.privacy.crashReporting')}
|
||||
description={t('settings.privacy.crashReportingDescription')}
|
||||
checked={crashReporting}
|
||||
onChange={setCrashReporting}
|
||||
testId="settings-crash-reporting"
|
||||
/>
|
||||
<TelemetryToggle
|
||||
label={t('settings.privacy.analytics')}
|
||||
description={t('settings.privacy.analyticsDescription')}
|
||||
checked={analytics}
|
||||
onChange={setAnalytics}
|
||||
testId="settings-analytics"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
273
src/components/SettingsControls.tsx
Normal file
273
src/components/SettingsControls.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
import { Switch } from './ui/switch'
|
||||
|
||||
type SelectOption = { value: string; label: string }
|
||||
type ControlWidth = 'auto' | 'compact' | 'default' | 'wide'
|
||||
|
||||
const SETTINGS_GROUP_ITEM_CLASS = 'border-b border-border px-4 py-3 last:border-b-0'
|
||||
|
||||
function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number {
|
||||
if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children: ReactNode
|
||||
id?: string
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="scroll-mt-4" style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: '18px 0' }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
title,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsGroup({ children }: { children: ReactNode }) {
|
||||
return <div className="overflow-hidden rounded-md border border-border bg-card">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsGroupItem({
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
}) {
|
||||
return <div className={SETTINGS_GROUP_ITEM_CLASS} data-testid={testId}>{children}</div>
|
||||
}
|
||||
|
||||
function controlWidthClass(width: ControlWidth): string {
|
||||
if (width === 'auto') return 'lg:w-auto'
|
||||
if (width === 'compact') return 'lg:w-56'
|
||||
if (width === 'wide') return 'lg:w-[420px]'
|
||||
return 'lg:w-80'
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
controlWidth = 'default',
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
controlWidth?: ControlWidth
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
{description ? <div className="text-xs leading-5 text-muted-foreground">{description}</div> : null}
|
||||
</div>
|
||||
<div className={`w-full min-w-0 lg:shrink-0 ${controlWidthClass(controlWidth)}`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectControl({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
testId,
|
||||
ariaLabel,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
testId: string
|
||||
ariaLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className="w-full bg-transparent"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
data-value={value}
|
||||
data-settings-autofocus={autoFocus ? 'true' : undefined}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" data-anchor-strategy="popper">
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberInputControl({
|
||||
value,
|
||||
onValueChange,
|
||||
testId,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number
|
||||
onValueChange: (value: number) => void
|
||||
testId: string
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
id={testId}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(event) => onValueChange(sanitizePositiveInteger(Number(event.target.value), value))}
|
||||
data-testid={testId}
|
||||
className="w-full bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchControl({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return <Switch checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
|
||||
}
|
||||
|
||||
export function LabeledSelect({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
testId,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
testId: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
|
||||
<SelectControl
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
options={options}
|
||||
testId={testId}
|
||||
ariaLabel={label}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabeledNumberInput({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
testId,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onValueChange: (value: number) => void
|
||||
testId: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }} htmlFor={testId}>{label}</label>
|
||||
<NumberInputControl
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
testId={testId}
|
||||
ariaLabel={label}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`}
|
||||
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<div className="flex justify-start lg:shrink-0 lg:justify-end">
|
||||
<SettingsSwitchControl label={label} checked={checked} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
31
src/components/SettingsFooter.tsx
Normal file
31
src/components/SettingsFooter.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
|
||||
export function SettingsFooter({
|
||||
onClose,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
t: Translate
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between shrink-0"
|
||||
style={{ height: 56, padding: '0 24px', borderTop: '1px solid var(--border)' }}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{t('settings.footerShortcut')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t('settings.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSave} data-testid="settings-save">
|
||||
{t('settings.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import type { Settings } from '../types'
|
||||
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
|
||||
import type { AiAgentsStatus } from '../lib/aiAgents'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
@@ -83,7 +84,48 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sync & Updates')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Sync & Updates').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('separates coding agents, local models, and API models in AI settings', async () => {
|
||||
const aiAgentsStatus: AiAgentsStatus = {
|
||||
claude_code: { status: 'installed', version: '2.1.18' },
|
||||
codex: { status: 'missing', version: null },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { status: 'missing', version: null },
|
||||
gemini: { status: 'missing', version: null },
|
||||
}
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Recognized coding agents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.1.18')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Local model' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'API model' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Local model' }), { button: 0, ctrlKey: false })
|
||||
fireEvent.change(screen.getByLabelText('Model ID'), { target: { value: 'llama3.2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test model' }))
|
||||
expect(await screen.findByText('Connection works. The model replied successfully.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add local model' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Recognized coding agents')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'API model' }), { button: 0, ctrlKey: false })
|
||||
expect(screen.getByRole('button', { name: 'Add API model' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add local model' })).not.toBeInTheDocument()
|
||||
fireEvent.pointerDown(screen.getByText('OpenAI').closest('button')!, { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Gemini' }))
|
||||
expect(screen.getByDisplayValue('Gemini')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://generativelanguage.googleapis.com/v1beta/openai')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('gemini-2.5-flash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the draft language when stored settings finish loading', () => {
|
||||
@@ -140,18 +182,18 @@ describe('SettingsPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders All Notes file visibility checkboxes off by default', () => {
|
||||
it('renders All Notes file visibility switches off by default', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('All Notes visibility')).toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText('Show PDFs')).toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('preserves saved All Notes file visibility checkboxes', () => {
|
||||
it('preserves saved All Notes file visibility switches', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
@@ -166,20 +208,18 @@ describe('SettingsPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('saves All Notes file visibility from keyboard toggles before Escape close', () => {
|
||||
it('saves All Notes file visibility immediately before Escape close', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
const pdfCheckbox = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')
|
||||
pdfCheckbox.focus()
|
||||
fireEvent.keyDown(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
fireEvent.keyUp(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
const pdfSwitch = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')
|
||||
fireEvent.click(pdfSwitch)
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -195,7 +235,7 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox'))
|
||||
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch'))
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('all_notes_visibility_changed', {
|
||||
category: 'images',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
@@ -92,7 +93,10 @@ interface StatusBarProps {
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -135,7 +139,10 @@ function StatusBarPrimaryFromFooter({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -175,7 +182,10 @@ function StatusBarPrimaryFromFooter({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -233,7 +243,7 @@ function StatusBarFooter(props: StatusBarFooterProps) {
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: stacked ? '4px 8px' : '0 8px',
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
type AiAgentDefinition,
|
||||
type AiAgentsStatus,
|
||||
} from '../../lib/aiAgents'
|
||||
import {
|
||||
configuredModelTargets,
|
||||
resolveAiTarget,
|
||||
type AiModelProvider,
|
||||
} from '../../lib/aiTargets'
|
||||
import type { Settings } from '../../types'
|
||||
import {
|
||||
getVaultAiGuidanceSummary,
|
||||
isVaultAiGuidanceStatusChecking,
|
||||
@@ -36,7 +42,10 @@ interface AiAgentsBadgeProps {
|
||||
statuses: AiAgentsStatus
|
||||
guidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAgent: AiAgentId
|
||||
defaultTarget?: string
|
||||
providers?: AiModelProvider[]
|
||||
onSetDefaultAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultTarget?: (target: string) => void
|
||||
onRestoreGuidance?: () => void
|
||||
compact?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -115,8 +124,8 @@ function canShowSwitcherCue(statuses: AiAgentsStatus, defaultAgent: AiAgentId):
|
||||
|
||||
function triggerButtonClassName(compact: boolean): string {
|
||||
return compact
|
||||
? 'h-6 w-6 rounded-sm p-0 text-[11px] font-medium'
|
||||
: 'h-6 px-2 text-[11px] font-medium'
|
||||
? 'h-6 w-6 rounded-sm p-0 text-[12px] font-medium'
|
||||
: 'h-6 px-2 text-[12px] font-medium'
|
||||
}
|
||||
|
||||
function CompactSeparator({ compact }: { compact: boolean }) {
|
||||
@@ -124,11 +133,6 @@ function CompactSeparator({ compact }: { compact: boolean }) {
|
||||
return <span style={SEP_STYLE}>|</span>
|
||||
}
|
||||
|
||||
function TriggerLabel({ compact, defaultAgent }: { compact: boolean; defaultAgent: AiAgentId }) {
|
||||
if (compact) return null
|
||||
return triggerLabel(defaultAgent)
|
||||
}
|
||||
|
||||
function TriggerStateIcon({
|
||||
showWarning,
|
||||
showSwitcherCue,
|
||||
@@ -171,13 +175,17 @@ function AgentMenuContent({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
defaultTarget,
|
||||
providers = [],
|
||||
selectedAgentReady,
|
||||
onSetDefaultAgent,
|
||||
onSetDefaultTarget,
|
||||
onRestoreGuidance,
|
||||
locale = 'en',
|
||||
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
|
||||
const installedAgents = installedAgentDefinitions(statuses)
|
||||
const missingAgents = missingAgentDefinitions(statuses)
|
||||
const modelTargets = configuredModelTargets(providers)
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
@@ -192,7 +200,10 @@ function AgentMenuContent({
|
||||
) : (
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedAgentReady ? defaultAgent : undefined}
|
||||
onValueChange={(value) => onSetDefaultAgent?.(value as AiAgentId)}
|
||||
onValueChange={(value) => {
|
||||
onSetDefaultAgent?.(value as AiAgentId)
|
||||
onSetDefaultTarget?.(`agent:${value}`)
|
||||
}}
|
||||
>
|
||||
{installedAgents.map((definition) => (
|
||||
<DropdownMenuRadioItem key={definition.id} value={definition.id}>
|
||||
@@ -204,6 +215,12 @@ function AgentMenuContent({
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
)}
|
||||
<ModelTargetMenuSection
|
||||
targets={modelTargets}
|
||||
defaultTarget={defaultTarget}
|
||||
locale={locale}
|
||||
onSetDefaultTarget={onSetDefaultTarget}
|
||||
/>
|
||||
{missingAgents.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -227,19 +244,65 @@ function AgentMenuContent({
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTargetMenuSection({
|
||||
targets,
|
||||
defaultTarget,
|
||||
locale,
|
||||
onSetDefaultTarget,
|
||||
}: {
|
||||
targets: ReturnType<typeof configuredModelTargets>
|
||||
defaultTarget?: string
|
||||
locale: AppLocale
|
||||
onSetDefaultTarget?: (target: string) => void
|
||||
}) {
|
||||
if (targets.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{translate(locale, 'status.ai.modelTargets')}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={defaultTarget}
|
||||
onValueChange={(value) => onSetDefaultTarget?.(value)}
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<DropdownMenuRadioItem key={target.id} value={target.id}>
|
||||
<span>{target.label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{target.provider.kind === 'ollama' || target.provider.kind === 'lm_studio'
|
||||
? translate(locale, 'status.ai.localChat')
|
||||
: translate(locale, 'status.ai.apiChat')}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiAgentsBadge({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
defaultTarget,
|
||||
providers = [],
|
||||
onSetDefaultAgent,
|
||||
onSetDefaultTarget,
|
||||
onRestoreGuidance,
|
||||
compact = false,
|
||||
locale = 'en',
|
||||
}: AiAgentsBadgeProps) {
|
||||
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
|
||||
const showWarning = hasAiAgentWarning(statuses, defaultAgent, guidanceStatus)
|
||||
const selectedTarget = resolveAiTarget({
|
||||
default_ai_agent: defaultAgent,
|
||||
default_ai_target: defaultTarget,
|
||||
ai_model_providers: providers,
|
||||
} as Settings)
|
||||
const selectedAgentReady = selectedTarget.kind === 'api_model' || isAiAgentInstalled(statuses, defaultAgent)
|
||||
const showWarning = selectedTarget.kind === 'agent' && hasAiAgentWarning(statuses, defaultAgent, guidanceStatus)
|
||||
const showSwitcherCue = !showWarning && canShowSwitcherCue(statuses, defaultAgent)
|
||||
const tooltip = badgeTooltip(locale, statuses, defaultAgent, guidanceStatus)
|
||||
const tooltip = selectedTarget.kind === 'api_model'
|
||||
? translate(locale, 'status.ai.defaultTarget', { target: selectedTarget.label })
|
||||
: badgeTooltip(locale, statuses, defaultAgent, guidanceStatus)
|
||||
|
||||
if (isAiAgentsStatusChecking(statuses)) return null
|
||||
|
||||
@@ -260,7 +323,7 @@ export function AiAgentsBadge({
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Sparkle size={13} weight="fill" />
|
||||
<TriggerLabel compact={compact} defaultAgent={defaultAgent} />
|
||||
{!compact && (selectedTarget.kind === 'api_model' ? selectedTarget.shortLabel : triggerLabel(defaultAgent))}
|
||||
<TriggerStateIcon showWarning={showWarning} showSwitcherCue={showSwitcherCue} />
|
||||
</span>
|
||||
</Button>
|
||||
@@ -269,7 +332,10 @@ export function AiAgentsBadge({
|
||||
statuses={statuses}
|
||||
guidanceStatus={guidanceStatus}
|
||||
defaultAgent={defaultAgent}
|
||||
defaultTarget={defaultTarget}
|
||||
providers={providers}
|
||||
onSetDefaultAgent={onSetDefaultAgent}
|
||||
onSetDefaultTarget={onSetDefaultTarget}
|
||||
onRestoreGuidance={onRestoreGuidance}
|
||||
selectedAgentReady={selectedAgentReady}
|
||||
locale={locale}
|
||||
|
||||
@@ -156,7 +156,7 @@ function StatusBarAction({
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
compact && 'h-6 gap-0.5 px-0.5',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
className,
|
||||
@@ -742,7 +742,7 @@ export function ChangesBadge({
|
||||
color: 'var(--text-inverse)',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Moon, Package, Settings, Sun } from 'lucide-react'
|
||||
import { Megaphone } from '@phosphor-icons/react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
@@ -70,7 +71,10 @@ interface StatusBarPrimarySectionProps {
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -104,8 +108,8 @@ function BuildNumberButton({
|
||||
locale: AppLocale
|
||||
}) {
|
||||
const className = compact
|
||||
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
|
||||
return (
|
||||
<ActionTooltip copy={{ label: translate(locale, 'status.update.check') }} side="top">
|
||||
@@ -132,7 +136,10 @@ function StatusBarAiBadge({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -143,7 +150,10 @@ function StatusBarAiBadge({
|
||||
| 'aiAgentsStatus'
|
||||
| 'vaultAiGuidanceStatus'
|
||||
| 'defaultAiAgent'
|
||||
| 'defaultAiTarget'
|
||||
| 'aiModelProviders'
|
||||
| 'onSetDefaultAiAgent'
|
||||
| 'onSetDefaultAiTarget'
|
||||
| 'onRestoreVaultAiGuidance'
|
||||
| 'claudeCodeStatus'
|
||||
| 'claudeCodeVersion'
|
||||
@@ -156,7 +166,10 @@ function StatusBarAiBadge({
|
||||
statuses={aiAgentsStatus}
|
||||
guidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAgent={defaultAiAgent}
|
||||
defaultTarget={defaultAiTarget}
|
||||
providers={aiModelProviders}
|
||||
onSetDefaultAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultTarget={onSetDefaultAiTarget}
|
||||
onRestoreGuidance={onRestoreVaultAiGuidance}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
@@ -189,7 +202,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -217,7 +233,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -256,7 +275,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -278,7 +300,7 @@ function FeedbackButton({
|
||||
}) {
|
||||
const className = compact
|
||||
? 'h-6 w-6 rounded-sm p-0 text-muted-foreground hover:text-foreground'
|
||||
: 'h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground'
|
||||
: 'h-6 px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground'
|
||||
|
||||
return (
|
||||
<ActionTooltip copy={{ label: translate(locale, 'status.feedback.contribute') }} side="top">
|
||||
@@ -301,6 +323,24 @@ function FeedbackButton({
|
||||
)
|
||||
}
|
||||
|
||||
function primarySectionStyle(stacked: boolean, compact: boolean) {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: compact ? 8 : 12,
|
||||
rowGap: stacked ? 4 : 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: stacked ? '100%' : 'auto',
|
||||
flexBasis: stacked ? '100%' : 'auto',
|
||||
flexWrap: stacked ? 'wrap' : 'nowrap',
|
||||
} as const
|
||||
}
|
||||
|
||||
function PrimarySeparator({ compact }: { compact: boolean }) {
|
||||
return compact ? null : <span style={SEP_STYLE}>|</span>
|
||||
}
|
||||
|
||||
export function StatusBarPrimarySection({
|
||||
modifiedCount,
|
||||
vaultPath,
|
||||
@@ -333,7 +373,10 @@ export function StatusBarPrimarySection({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -356,17 +399,7 @@ export function StatusBarPrimarySection({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: compact ? 8 : 12,
|
||||
rowGap: stacked ? 4 : 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: stacked ? '100%' : 'auto',
|
||||
flexBasis: stacked ? '100%' : 'auto',
|
||||
flexWrap: stacked ? 'wrap' : 'nowrap',
|
||||
}}
|
||||
style={primarySectionStyle(stacked, compact)}
|
||||
>
|
||||
<VaultMenu
|
||||
vaults={vaults}
|
||||
@@ -380,7 +413,7 @@ export function StatusBarPrimarySection({
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
{compact ? null : <span style={SEP_STYLE}>|</span>}
|
||||
<PrimarySeparator compact={compact} />
|
||||
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} locale={locale} />
|
||||
<StatusBarPrimaryBadges
|
||||
modifiedCount={modifiedCount}
|
||||
@@ -404,7 +437,10 @@ export function StatusBarPrimarySection({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -457,7 +493,7 @@ export function StatusBarSecondarySection({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onZoomReset}
|
||||
aria-label={translate(locale, 'status.zoom.reset')}
|
||||
data-testid="status-zoom"
|
||||
|
||||
@@ -54,8 +54,8 @@ function getVaultTriggerClassName(open: boolean, compact: boolean) {
|
||||
}
|
||||
|
||||
return open
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[12px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
}
|
||||
|
||||
function buildVaultActions({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { trackAiAgentPermissionModeChanged } from '../lib/productAnalytics'
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReady: boolean
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
activeEntry?: VaultEntry | null
|
||||
@@ -79,9 +81,65 @@ function useAgentFileCallbacks({
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
}
|
||||
|
||||
function useAiPermissionModeHandler({
|
||||
agent,
|
||||
defaultAiAgent,
|
||||
isActive,
|
||||
locale,
|
||||
permissionMode,
|
||||
}: {
|
||||
agent: ReturnType<typeof useCliAiAgent>
|
||||
defaultAiAgent: AiAgentId
|
||||
isActive: boolean
|
||||
locale: AppLocale
|
||||
permissionMode: AiAgentPermissionMode
|
||||
}) {
|
||||
return useCallback((mode: AiAgentPermissionMode) => {
|
||||
const nextMode = normalizeAiAgentPermissionMode(mode)
|
||||
if (isActive || nextMode === permissionMode) return
|
||||
|
||||
updateVaultConfigField('ai_agent_permission_mode', nextMode)
|
||||
trackAiAgentPermissionModeChanged(defaultAiAgent, nextMode)
|
||||
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode, locale))
|
||||
}, [agent, defaultAiAgent, isActive, locale, permissionMode])
|
||||
}
|
||||
|
||||
function usePanelAgent({
|
||||
vaultPath,
|
||||
contextPrompt,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: Pick<
|
||||
UseAiPanelControllerArgs,
|
||||
| 'vaultPath'
|
||||
| 'defaultAiAgent'
|
||||
| 'defaultAiTarget'
|
||||
| 'defaultAiAgentReady'
|
||||
| 'defaultAiAgentReadiness'
|
||||
| 'onFileCreated'
|
||||
| 'onFileModified'
|
||||
| 'onVaultChanged'
|
||||
> & { contextPrompt?: string }) {
|
||||
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
|
||||
const permissionMode = useVaultAiAgentPermissionMode()
|
||||
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
|
||||
agent: defaultAiAgent,
|
||||
target: defaultAiTarget,
|
||||
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
|
||||
permissionMode,
|
||||
})
|
||||
return { agent, permissionMode }
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry,
|
||||
@@ -107,15 +165,7 @@ export function useAiPanelController({
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
|
||||
const permissionMode = useVaultAiAgentPermissionMode()
|
||||
|
||||
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
|
||||
agent: defaultAiAgent,
|
||||
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
|
||||
permissionMode,
|
||||
})
|
||||
const hasContext = !!activeEntry
|
||||
const { agent, permissionMode } = usePanelAgent({ vaultPath, contextPrompt, defaultAiAgent, defaultAiTarget, defaultAiAgentReady, defaultAiAgentReadiness, onFileCreated, onFileModified, onVaultChanged })
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
@@ -128,14 +178,7 @@ export function useAiPanelController({
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => {
|
||||
const nextMode = normalizeAiAgentPermissionMode(mode)
|
||||
if (isActive || nextMode === permissionMode) return
|
||||
|
||||
updateVaultConfigField('ai_agent_permission_mode', nextMode)
|
||||
trackAiAgentPermissionModeChanged(defaultAiAgent, nextMode)
|
||||
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode, locale))
|
||||
}, [agent, defaultAiAgent, isActive, locale, permissionMode])
|
||||
const handlePermissionModeChange = useAiPermissionModeHandler({ agent, defaultAiAgent, isActive, locale, permissionMode })
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
agent.clearConversation()
|
||||
@@ -147,7 +190,7 @@ export function useAiPanelController({
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
hasContext: !!activeEntry,
|
||||
isActive,
|
||||
permissionMode,
|
||||
handleSend,
|
||||
|
||||
Reference in New Issue
Block a user