feat: add direct AI model providers

This commit is contained in:
lucaronin
2026-05-03 16:15:03 +02:00
parent 5b906cb3a4
commit aaf0336733
57 changed files with 3899 additions and 845 deletions

View File

@@ -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', () => {

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
)
})

View 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>
)
}

View File

@@ -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)} />
})

View File

@@ -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}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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}

View File

@@ -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',

View File

@@ -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"

View File

@@ -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({

View File

@@ -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,