feat: add vault ai agent permission modes

This commit is contained in:
lucaronin
2026-04-29 00:52:27 +02:00
parent 91db7c0098
commit d2d4746d5c
31 changed files with 1402 additions and 231 deletions

View File

@@ -18,6 +18,7 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
localMarker?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
@@ -27,6 +28,18 @@ export interface AiMessageProps {
onNavigateWikilink?: (target: string) => void
}
function LocalMarker({ text }: { text: string }) {
return (
<div
className="mx-auto text-center text-muted-foreground"
style={{ fontSize: 11, margin: '8px 0 16px', maxWidth: '85%' }}
data-testid="ai-local-marker"
>
{text}
</div>
)
}
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
@@ -176,7 +189,15 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
export function AiMessage(props: AiMessageProps) {
if (props.localMarker) {
return <LocalMarker text={props.localMarker} />
}
return <ConversationMessage {...props} />
}
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())

View File

@@ -4,20 +4,27 @@ import { AiPanel } from './AiPanel'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
const mockSendMessage = vi.fn()
const mockClearConversation = vi.fn()
const mockAddLocalMarker = vi.fn()
const mockUseCliAiAgent = vi.fn()
vi.mock('../hooks/useCliAiAgent', () => ({
useCliAiAgent: () => ({
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
}),
useCliAiAgent: (...args: unknown[]) => {
mockUseCliAiAgent(...args)
return {
messages: mockMessages,
status: mockStatus,
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
addLocalMarker: mockAddLocalMarker,
}
},
}))
vi.mock('../utils/ai-chat', () => ({
@@ -45,7 +52,18 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
...overrides,
})
@@ -55,12 +73,78 @@ describe('AiPanel', () => {
mockStatus = 'idle'
mockSendMessage.mockReset()
mockClearConversation.mockReset()
mockAddLocalMarker.mockReset()
mockUseCliAiAgent.mockReset()
resetVaultConfigStore()
bindVaultConfigStore({
zoom: null,
view_mode: null,
editor_mode: null,
note_layout: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
allNotes: null,
ai_agent_permission_mode: 'safe',
}, vi.fn())
})
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('Claude Code')).toBeTruthy()
expect(screen.getByText('Claude Code · Safe')).toBeTruthy()
})
it('passes the vault permission mode to the AI agent session', () => {
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'power_user',
}, vi.fn())
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Claude Code · Power User')).toBeTruthy()
expect(mockUseCliAiAgent).toHaveBeenCalledWith(
'/tmp/vault',
undefined,
expect.any(Object),
expect.objectContaining({ permissionMode: 'power_user' }),
)
})
it('persists permission mode changes and records a local transcript marker', () => {
const save = vi.fn()
bindVaultConfigStore({
...getVaultConfig(),
ai_agent_permission_mode: 'safe',
}, save)
mockMessages = [{
userMessage: 'Existing question',
actions: [],
response: 'Existing answer.',
id: 'msg-existing',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Power User' }))
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
ai_agent_permission_mode: 'power_user',
}))
expect(mockAddLocalMarker).toHaveBeenCalledWith(
'AI permission mode changed to Power User. It will apply to the next message.',
)
})
it('disables permission mode changes while the AI agent is running', () => {
mockStatus = 'thinking'
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByRole('button', { name: 'Vault Safe' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Power User' })).toBeDisabled()
})
it('renders data-testid ai-panel', () => {

View File

@@ -82,8 +82,10 @@ export function AiPanelView({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
} = controller
@@ -115,6 +117,9 @@ export function AiPanelView({
<AiPanelHeader
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
permissionMode={permissionMode}
permissionModeDisabled={isActive}
onPermissionModeChange={handlePermissionModeChange}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}

View File

@@ -5,6 +5,10 @@ import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import {
AI_AGENT_PERMISSION_MODE_LABELS,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
@@ -13,6 +17,9 @@ import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
permissionMode: AiAgentPermissionMode
permissionModeDisabled: boolean
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
@@ -124,59 +131,108 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReadiness,
permissionMode,
permissionModeDisabled,
onPermissionModeChange,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 52, padding: '0 12px', gap: 8 }}
className="flex shrink-0 flex-col border-b border-border"
style={{ padding: '8px 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
{onCopyMcpConfig ? (
<div className="flex items-center" style={{ gap: 8 }}>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`}
</span>
</div>
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Copy size={15} />
<Plus size={16} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</Button>
</div>
<AiPermissionModeToggle
value={permissionMode}
disabled={permissionModeDisabled}
onChange={onPermissionModeChange}
/>
</div>
)
}
function AiPermissionModeToggle({
value,
disabled,
onChange,
}: {
value: AiAgentPermissionMode
disabled: boolean
onChange: (mode: AiAgentPermissionMode) => void
}) {
return (
<div
className="grid rounded-md bg-muted"
style={{ gridTemplateColumns: '1fr 1fr', gap: 2, padding: 2 }}
role="group"
aria-label="AI agent permission mode"
data-testid="ai-permission-mode-toggle"
>
{(['safe', 'power_user'] as const).map((mode) => {
const selected = value === mode
return (
<Button
key={mode}
type="button"
size="xs"
variant={selected ? 'secondary' : 'ghost'}
disabled={disabled}
aria-pressed={selected}
onClick={() => onChange(mode)}
>
{AI_AGENT_PERMISSION_MODE_LABELS[mode].control}
</Button>
)
})}
</div>
)
}
@@ -275,16 +331,20 @@ export function AiPanelComposer({
inputRef={inputRef}
/>
</div>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={sendButtonStyle}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={!canSend}
aria-label="Send message"
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</Button>
</div>
</div>
)

View File

@@ -1,7 +1,17 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
import {
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent'
import type { VaultEntry } from '../types'
import {
getVaultConfig,
subscribeVaultConfig,
updateVaultConfigField,
} from '../utils/vaultConfigStore'
import {
type NoteListItem,
type NoteReference,
@@ -32,8 +42,10 @@ export interface AiPanelController {
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
hasContext: boolean
isActive: boolean
permissionMode: AiAgentPermissionMode
handleSend: (text: string, references: NoteReference[]) => void
handleNavigateWikilink: (target: string) => void
handlePermissionModeChange: (mode: AiAgentPermissionMode) => void
handleNewChat: () => void
}
@@ -44,6 +56,26 @@ function resolveAgentReady(
return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready'
}
function useVaultAiAgentPermissionMode(): AiAgentPermissionMode {
const vaultConfig = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
return normalizeAiAgentPermissionMode(vaultConfig.ai_agent_permission_mode)
}
function useAgentFileCallbacks({
onFileCreated,
onFileModified,
onVaultChanged,
}: Pick<
UseAiPanelControllerArgs,
'onFileCreated' | 'onFileModified' | 'onVaultChanged'
>): AgentFileCallbacks {
return useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
@@ -71,15 +103,13 @@ export function useAiPanelController({
noteListFilter,
})
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
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 isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -94,6 +124,14 @@ 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)
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode))
}, [agent, isActive, permissionMode])
const handleNewChat = useCallback(() => {
agent.clearConversation()
setInput('')
@@ -106,8 +144,10 @@ export function useAiPanelController({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
}
}