feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
556
product-source/hololake-platform/src/components/AiPanel.test.tsx
Normal file
556
product-source/hololake-platform/src/components/AiPanel.test.tsx
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render as rtlRender, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
import { AiPanel, AiPanelView } from './AiPanel'
|
||||
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { AiPanelController } from './useAiPanelController'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
// 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 mockStopMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
const mockAddLocalMarker = vi.fn()
|
||||
const mockUseCliAiAgent = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useCliAiAgent', () => ({
|
||||
useCliAiAgent: (...args: unknown[]) => {
|
||||
mockUseCliAiAgent(...args)
|
||||
return {
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
stopMessage: mockStopMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
addLocalMarker: mockAddLocalMarker,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
nextMessageId: () => `msg-${Date.now()}`,
|
||||
}))
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
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,
|
||||
})
|
||||
|
||||
function render(ui: Parameters<typeof rtlRender>[0]) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
function QueuedPromptTargetHarness({ onTargetChange }: { onTargetChange: (targetId: string) => void }) {
|
||||
const [input, setInput] = useState('')
|
||||
const [targetId, setTargetId] = useState('agent:claude_code')
|
||||
const handleTargetChange = (nextTargetId: string) => {
|
||||
onTargetChange(nextTargetId)
|
||||
setTargetId(nextTargetId)
|
||||
}
|
||||
const controller: AiPanelController = {
|
||||
agent: {
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
sendMessage: (text: string, references?: NoteReference[]) => {
|
||||
mockSendMessage(text, references)
|
||||
return Promise.resolve()
|
||||
},
|
||||
stopMessage: mockStopMessage,
|
||||
regenerateMessage: () => Promise.resolve(),
|
||||
clearConversation: () => {
|
||||
mockClearConversation()
|
||||
},
|
||||
addLocalMarker: (text: string) => {
|
||||
mockAddLocalMarker(text)
|
||||
},
|
||||
},
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries: [],
|
||||
hasContext: false,
|
||||
isActive: false,
|
||||
permissionMode: 'safe',
|
||||
handleSend: (text: string, references: NoteReference[]) => {
|
||||
mockSendMessage(text, references)
|
||||
setInput('')
|
||||
},
|
||||
handleStop: mockStopMessage,
|
||||
handleNavigateWikilink: vi.fn(),
|
||||
handlePermissionModeChange: vi.fn(),
|
||||
handleNewChat: vi.fn(),
|
||||
}
|
||||
|
||||
return (
|
||||
<AiPanelView
|
||||
controller={controller}
|
||||
onClose={vi.fn()}
|
||||
onQueuedPromptTarget={handleTargetChange}
|
||||
showHeader={false}
|
||||
targetId={targetId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockStopMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
mockAddLocalMarker.mockReset()
|
||||
mockUseCliAiAgent.mockReset()
|
||||
trackEventMock.mockClear()
|
||||
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 · 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,
|
||||
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('radio', { 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.',
|
||||
)
|
||||
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_permission_mode_changed', {
|
||||
agent: 'claude_code',
|
||||
permission_mode: 'power_user',
|
||||
})
|
||||
})
|
||||
|
||||
it('disables permission mode changes while the AI agent is running', () => {
|
||||
mockStatus = 'thinking'
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Vault Safe' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: 'Power User' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('replaces the composer send button with a stop button while the AI agent is running', () => {
|
||||
mockStatus = 'thinking'
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.queryByTestId('agent-send')).toBeNull()
|
||||
const stopButton = screen.getByRole('button', { name: 'Stop response' })
|
||||
expect(stopButton).toHaveAttribute('data-testid', 'agent-stop')
|
||||
expect(stopButton).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(stopButton)
|
||||
|
||||
expect(mockStopMessage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the permission mode toggle with high contrast selected state and per-mode tooltips', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByTestId('ai-permission-mode-toggle')).toHaveClass('border', 'bg-muted')
|
||||
|
||||
const safeMode = screen.getByRole('radio', { name: 'Vault Safe' })
|
||||
const powerUserMode = screen.getByRole('radio', { name: 'Power User' })
|
||||
expect(safeMode).toHaveAttribute('aria-checked', 'true')
|
||||
expect(safeMode).toHaveClass('bg-background', 'text-foreground', 'shadow-xs')
|
||||
expect(powerUserMode).toHaveClass('text-muted-foreground')
|
||||
|
||||
fireEvent.focus(safeMode)
|
||||
|
||||
expect(await screen.findByTestId('ai-permission-mode-tooltip')).toHaveTextContent(
|
||||
'Vault Safe keeps agents limited to file, search, and edit tools.',
|
||||
)
|
||||
|
||||
fireEvent.blur(safeMode)
|
||||
fireEvent.focus(powerUserMode)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ai-permission-mode-tooltip')).toHaveTextContent(
|
||||
'Power User also allows local shell commands for this vault.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n')
|
||||
fireEvent.input(editor)
|
||||
|
||||
expect(editor).toHaveClass('max-h-[120px]', 'overflow-y-auto', 'overscroll-contain')
|
||||
expect(editor).toHaveStyle({ maxHeight: '120px', overflowY: 'auto' })
|
||||
expect(screen.getByTestId('agent-send')).toBeVisible()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
const buttons = panel.querySelectorAll('button')
|
||||
const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close'))
|
||||
expect(closeBtn).toBeTruthy()
|
||||
fireEvent.click(closeBtn!)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a new AI chat when the header action is clicked', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
fireEvent.click(screen.getByTitle('New AI chat'))
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the MCP config action out of the AI panel header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Copy MCP config' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders empty state without context', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByText('Ask anything to Claude Code')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render a context bar for the active entry', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.queryByTestId('context-bar')).toBeNull()
|
||||
expect(screen.queryByText('My Note')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show linked count in a sub-header', () => {
|
||||
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
|
||||
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('+ 1 linked')).toBeNull()
|
||||
expect(screen.queryByTestId('context-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders input field enabled', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect(input).toHaveAttribute('contenteditable', 'true')
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const sendBtn = screen.getByTestId('agent-send')
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows active agent placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
|
||||
})
|
||||
|
||||
it('shows active agent placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
|
||||
})
|
||||
|
||||
it('uses the selected AI agent in the placeholder', () => {
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()}
|
||||
vaultPath="/tmp/vault"
|
||||
defaultAiAgent="codex"
|
||||
defaultAiAgentReady
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
|
||||
})
|
||||
|
||||
it('disables sending while the selected AI agent is still loading', () => {
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()}
|
||||
vaultPath="/tmp/vault"
|
||||
defaultAiAgent="codex"
|
||||
defaultAiAgentReadiness="checking"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Checking availability')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...')
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(document.activeElement).toBe(input)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('focuses the panel shell when reopening with existing messages', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockMessages = [{
|
||||
userMessage: 'Remember this',
|
||||
actions: [],
|
||||
response: 'Still here.',
|
||||
id: 'msg-3',
|
||||
}]
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not steal composer focus after a response when the send button becomes enabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockMessages = [{
|
||||
userMessage: 'First question',
|
||||
actions: [],
|
||||
response: 'First answer.',
|
||||
id: 'msg-3',
|
||||
}]
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
const input = screen.getByTestId('agent-input')
|
||||
input.focus()
|
||||
input.textContent = 'f'
|
||||
fireEvent.input(input)
|
||||
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
|
||||
expect(screen.getByTestId('agent-send')).toBeEnabled()
|
||||
expect(document.activeElement).toBe(screen.getByTestId('agent-input'))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed while panel has focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
// Input is focused inside the panel, so Escape should trigger onClose
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed on panel element', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
panel.focus()
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about notes',
|
||||
actions: [],
|
||||
response: 'Check out [[Build Laputa App]] for details.',
|
||||
id: 'msg-1',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('Build Laputa App')
|
||||
fireEvent.click(wikilink!)
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
|
||||
})
|
||||
|
||||
it('renders wikilinks with special characters and clicking works', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about meetings',
|
||||
actions: [],
|
||||
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
|
||||
id: 'msg-2',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
fireEvent.click(wikilinks[0])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
|
||||
it('auto-sends a queued prompt from the command palette bridge', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
})
|
||||
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('retargets a queued prompt before sending when the active target differs', async () => {
|
||||
const onTargetChange = vi.fn()
|
||||
render(<QueuedPromptTargetHarness onTargetChange={onTargetChange} />)
|
||||
|
||||
await act(async () => {
|
||||
queueAiPrompt('ask codex', [], 'agent:codex')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onTargetChange).toHaveBeenCalledWith('agent:codex')
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('ask codex', [])
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces an unsupported image paste notice without locking the composer', () => {
|
||||
const onUnsupportedAiPaste = vi.fn()
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()}
|
||||
vaultPath="/tmp/vault"
|
||||
activeEntry={entry}
|
||||
entries={[entry]}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.paste(screen.getByTestId('agent-input'), {
|
||||
clipboardData: {
|
||||
getData: vi.fn(() => ''),
|
||||
files: [new File(['image'], 'paste.png', { type: 'image/png' })],
|
||||
items: [{ kind: 'file', type: 'image/png' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE)
|
||||
expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue