feat: add vault ai agent permission modes
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user