600 lines
23 KiB
TypeScript
600 lines
23 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import type { ReactNode } from 'react'
|
|
import { AiWorkspace } from './AiWorkspace'
|
|
import { buildAiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
|
|
import {
|
|
createAiAgentAvailability,
|
|
createMissingAiAgentsStatus,
|
|
type AiAgentsStatus,
|
|
} from '../lib/aiAgents'
|
|
import type { AiModelProvider } from '../lib/aiTargets'
|
|
import type { AgentStatus } from '../hooks/useCliAiAgent'
|
|
import { resetVaultConfigStore } from '../utils/vaultConfigStore'
|
|
import type { VaultEntry } from '../types'
|
|
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
|
|
|
let mockedAgentStatus: AgentStatus = 'idle'
|
|
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
|
|
let controllerCalls: unknown[] = []
|
|
const { generateTitleMock } = vi.hoisted(() => ({
|
|
generateTitleMock: vi.fn(),
|
|
}))
|
|
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {}
|
|
return {
|
|
getItem: (key: string) => store[key] ?? null,
|
|
setItem: (key: string, value: string) => { store[key] = value },
|
|
removeItem: (key: string) => { delete store[key] },
|
|
clear: () => { store = {} },
|
|
}
|
|
})()
|
|
|
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true })
|
|
|
|
vi.mock('./useAiPanelController', () => ({
|
|
useAiPanelController: (args: unknown) => {
|
|
controllerCalls.push(args)
|
|
return {
|
|
agent: {
|
|
messages: mockMessages,
|
|
status: mockedAgentStatus,
|
|
sendMessage: vi.fn(),
|
|
clearConversation: vi.fn(),
|
|
addLocalMarker: vi.fn(),
|
|
},
|
|
input: '',
|
|
setInput: vi.fn(),
|
|
linkedEntries: [],
|
|
hasContext: false,
|
|
isActive: false,
|
|
permissionMode: 'safe',
|
|
handleSend: vi.fn(),
|
|
handleNavigateWikilink: vi.fn(),
|
|
handlePermissionModeChange: vi.fn(),
|
|
handleNewChat: vi.fn(),
|
|
}
|
|
},
|
|
}))
|
|
|
|
vi.mock('./AiPanel', () => ({
|
|
AiPanelView: ({
|
|
composerControls,
|
|
onMessageHistoryScrollStateChange,
|
|
onSendPrompt,
|
|
showHeader,
|
|
}: {
|
|
composerControls?: ReactNode
|
|
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
|
|
onSendPrompt?: (prompt: string) => void
|
|
showHeader?: boolean
|
|
}) => (
|
|
<div data-testid="ai-panel-view" data-show-header={String(showHeader)}>
|
|
<button type="button" onClick={() => onSendPrompt?.('summarize quarterly sponsor outreach')}>
|
|
Send mocked prompt
|
|
</button>
|
|
<button type="button" onClick={() => onMessageHistoryScrollStateChange?.(true)}>
|
|
Mock history scroll
|
|
</button>
|
|
{composerControls}
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('../utils/aiConversationTitle', () => ({
|
|
generateAiConversationTitleForTarget: generateTitleMock,
|
|
}))
|
|
|
|
function installedStatuses(): AiAgentsStatus {
|
|
return {
|
|
...createMissingAiAgentsStatus(),
|
|
claude_code: createAiAgentAvailability('installed', '1.0.0'),
|
|
codex: createAiAgentAvailability('installed', '0.9.0'),
|
|
gemini: createAiAgentAvailability('missing', null),
|
|
}
|
|
}
|
|
|
|
const providers: AiModelProvider[] = [
|
|
{
|
|
id: 'ollama-local',
|
|
name: 'Ollama',
|
|
kind: 'ollama',
|
|
api_key_storage: 'none',
|
|
models: [{
|
|
id: 'llama3.2',
|
|
display_name: 'Llama 3.2',
|
|
capabilities: { streaming: true, tools: false, vision: false, json_mode: false, reasoning: false },
|
|
}],
|
|
},
|
|
{
|
|
id: 'openai',
|
|
name: 'OpenAI',
|
|
kind: 'open_ai',
|
|
api_key_storage: 'env',
|
|
api_key_env_var: 'OPENAI_API_KEY',
|
|
models: [{
|
|
id: 'gpt-4.1',
|
|
display_name: 'GPT-4.1',
|
|
capabilities: { streaming: true, tools: true, vision: true, json_mode: true, reasoning: true },
|
|
}],
|
|
},
|
|
]
|
|
|
|
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
|
return {
|
|
aliases: [],
|
|
archived: false,
|
|
belongsTo: [],
|
|
color: null,
|
|
createdAt: 1700000000,
|
|
favorite: false,
|
|
favoriteIndex: null,
|
|
fileSize: 100,
|
|
filename: 'active.md',
|
|
hasH1: false,
|
|
icon: null,
|
|
isA: 'Note',
|
|
listPropertiesDisplay: [],
|
|
modifiedAt: 1700000000,
|
|
order: null,
|
|
organized: false,
|
|
outgoingLinks: [],
|
|
path: '/tmp/vault/active.md',
|
|
properties: {},
|
|
relatedTo: [],
|
|
relationships: {},
|
|
sidebarLabel: null,
|
|
snippet: '',
|
|
sort: null,
|
|
status: null,
|
|
template: null,
|
|
title: 'Active',
|
|
view: null,
|
|
visible: null,
|
|
wordCount: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function contextReadyControllerCalls(): unknown[] {
|
|
return controllerCalls.filter((args) => {
|
|
const call = args as { activeEntry?: unknown; entries?: unknown[] }
|
|
return call.activeEntry !== null && call.activeEntry !== undefined && Array.isArray(call.entries)
|
|
})
|
|
}
|
|
|
|
describe('AiWorkspace', () => {
|
|
beforeEach(() => {
|
|
mockedAgentStatus = 'idle'
|
|
mockMessages = []
|
|
controllerCalls = []
|
|
generateTitleMock.mockReset()
|
|
generateTitleMock.mockResolvedValue('Quarterly sponsor outreach')
|
|
localStorage.clear()
|
|
resetVaultConfigStore()
|
|
})
|
|
|
|
it('groups installed agents and configured local/API models', () => {
|
|
const groups = buildAiWorkspaceTargetGroups(installedStatuses(), providers)
|
|
|
|
expect(groups.localAgents.map((target) => target.agent)).toEqual(['claude_code', 'codex'])
|
|
expect(groups.localAgents.some((target) => target.agent === 'gemini')).toBe(false)
|
|
expect(groups.localModels.map((target) => target.shortLabel)).toEqual(['Llama 3.2'])
|
|
expect(groups.apiModels.map((target) => target.shortLabel)).toEqual(['GPT-4.1'])
|
|
})
|
|
|
|
it('creates chats from the sidebar and hides the legacy AI panel header', () => {
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
const workspace = screen.getByTestId('ai-workspace')
|
|
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked')
|
|
expect(workspace).toHaveStyle({ width: '560px' })
|
|
expect(workspace.className).toContain('bottom-[30px]')
|
|
expect(workspace.className).not.toContain('shadow')
|
|
expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false')
|
|
expect(screen.queryByText('Agents')).toBeNull()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
|
|
expect(screen.getByText('Agents')).toBeTruthy()
|
|
expect(screen.queryByText('AI Agent')).toBeNull()
|
|
expect(screen.queryByText('Idle')).toBeNull()
|
|
|
|
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
|
|
|
|
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
|
expect(screen.getAllByText('AI Chat 2').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('renders side mode as an in-editor tabbed panel and expands in place', () => {
|
|
const onClose = vi.fn()
|
|
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={onClose} />)
|
|
|
|
const workspace = screen.getByTestId('ai-workspace')
|
|
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'side')
|
|
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'false')
|
|
expect(workspace).not.toHaveClass('fixed')
|
|
expect(workspace).toHaveClass('bg-sidebar')
|
|
expect(workspace).toHaveStyle({ width: '320px', minWidth: '320px' })
|
|
const header = screen.getByTestId('ai-workspace-side-header')
|
|
const tabStrip = screen.getByTestId('ai-workspace-side-tabs')
|
|
expect(header).not.toHaveClass('border-b')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Mock history scroll' }))
|
|
expect(header).toHaveClass('border-b')
|
|
expect(tabStrip).toHaveClass('overflow-x-auto')
|
|
expect(screen.getByRole('button', { name: 'AI Chat' })).toBeTruthy()
|
|
expect(
|
|
within(header).getByRole('button', { name: 'Expand AI workspace' }).compareDocumentPosition(
|
|
within(header).getByRole('button', { name: 'Close AI workspace' }),
|
|
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
|
|
expect(screen.getByRole('button', { name: 'AI Chat 2' })).toBeTruthy()
|
|
expect(
|
|
within(tabStrip).getByRole('button', { name: 'AI Chat 2' }).compareDocumentPosition(
|
|
within(tabStrip).getByRole('button', { name: 'New chat' }),
|
|
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close AI Chat 2' }))
|
|
expect(screen.queryByRole('button', { name: 'AI Chat 2' })).toBeNull()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand AI workspace' }))
|
|
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'true')
|
|
expect(workspace).toHaveClass('absolute')
|
|
expect(screen.getByRole('button', { name: 'Restore AI workspace panel' })).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close AI workspace' }))
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('opens AI settings from the composer controls', () => {
|
|
const onOpenAiSettings = vi.fn()
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="side"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
onOpenAiSettings={onOpenAiSettings}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.click(screen.getByTestId('ai-workspace-composer-settings'))
|
|
|
|
expect(onOpenAiSettings).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('reports the active side chat so reopening can restore it', () => {
|
|
const onActiveConversationChange = vi.fn()
|
|
|
|
const { unmount } = render(
|
|
<AiWorkspace
|
|
open
|
|
mode="side"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
conversationSettings={[
|
|
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
|
|
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
|
|
]}
|
|
initialActiveConversationId="chat-a"
|
|
vaultPath="/tmp/vault"
|
|
onActiveConversationChange={onActiveConversationChange}
|
|
onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Follow-up draft' }))
|
|
expect(onActiveConversationChange).toHaveBeenLastCalledWith('chat-b')
|
|
unmount()
|
|
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="side"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
conversationSettings={[
|
|
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
|
|
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
|
|
]}
|
|
initialActiveConversationId="chat-b"
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByRole('button', { name: 'Follow-up draft' })).toHaveAttribute('aria-pressed', 'true')
|
|
})
|
|
|
|
it('renames a side chat from the tab on double-click', () => {
|
|
const onConversationSettingsChange = vi.fn()
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="side"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
conversationSettings={[{ id: 'chat-a', title: 'AI Chat', target_id: null, archived: false }]}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
onConversationSettingsChange={onConversationSettingsChange}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat' }))
|
|
const input = screen.getByLabelText('Rename chat')
|
|
fireEvent.change(input, { target: { value: 'Research thread' } })
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
expect(screen.getByRole('button', { name: 'Research thread' })).toBeTruthy()
|
|
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
|
expect.objectContaining({ id: 'chat-a', title: 'Research thread' }),
|
|
])
|
|
})
|
|
|
|
it('resizes the docked workspace from the left edge and the sidebar split', () => {
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
const workspace = screen.getByTestId('ai-workspace')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
|
|
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
|
|
fireEvent.mouseMove(window, { clientX: 60, clientY: 20 })
|
|
fireEvent.mouseUp(window)
|
|
expect(workspace).toHaveStyle({ width: '600px' })
|
|
|
|
const sidebar = screen.getByTestId('ai-workspace-sidebar-header').parentElement
|
|
const sidebarHandle = workspace.querySelector('.cursor-col-resize:not([data-testid])')
|
|
expect(sidebar).toHaveStyle({ width: '168px' })
|
|
fireEvent.mouseDown(sidebarHandle as Element, { clientX: 100, clientY: 20 })
|
|
fireEvent.mouseMove(document, { clientX: 120, clientY: 20 })
|
|
fireEvent.mouseUp(document)
|
|
expect(sidebar).toHaveStyle({ width: '188px' })
|
|
})
|
|
|
|
it('restores the last resized side panel width when reopened', () => {
|
|
const { unmount } = render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
const workspace = screen.getByTestId('ai-workspace')
|
|
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
|
|
fireEvent.mouseMove(window, { clientX: 40, clientY: 20 })
|
|
fireEvent.mouseUp(window)
|
|
expect(workspace).toHaveStyle({ width: '380px' })
|
|
|
|
unmount()
|
|
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
expect(screen.getByTestId('ai-workspace')).toHaveStyle({ width: '380px' })
|
|
})
|
|
|
|
it('separates the guidance warning from the header and uses a short restore action', () => {
|
|
const status: VaultAiGuidanceStatus = {
|
|
agentsState: 'missing',
|
|
claudeState: 'managed',
|
|
geminiState: 'managed',
|
|
canRestore: true,
|
|
}
|
|
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="side"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
vaultAiGuidanceStatus={status}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
onRestoreVaultAiGuidance={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken')).toHaveClass('min-w-0')
|
|
expect(screen.getByRole('button', { name: 'Restore' })).toBeTruthy()
|
|
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken').parentElement).toHaveClass('border-y')
|
|
})
|
|
|
|
it('does not archive an empty chat', () => {
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
const archiveButtons = screen.getAllByRole('button', { name: 'Archive chat' })
|
|
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
|
|
fireEvent.click(archiveButtons[0])
|
|
|
|
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
|
expect(screen.queryByText('AI Chat 2')).toBeNull()
|
|
})
|
|
|
|
it('activates a visible chat when persisted settings start with an archived chat', () => {
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="docked"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
conversationSettings={[
|
|
{ id: 'archived-chat', title: 'Old Chat', target_id: null, archived: true },
|
|
{ id: 'visible-chat', title: 'Live Chat', target_id: null, archived: false },
|
|
]}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByTestId('ai-workspace-session-visible-chat')).toHaveClass('flex')
|
|
expect(screen.getByTestId('ai-workspace-session-archived-chat')).toHaveClass('hidden')
|
|
})
|
|
|
|
it('passes vault context only to the active conversation controller', () => {
|
|
const entries = [
|
|
makeEntry({ path: '/tmp/vault/active.md', filename: 'active.md', title: 'Active' }),
|
|
makeEntry({ path: '/tmp/vault/related.md', filename: 'related.md', title: 'Related' }),
|
|
]
|
|
const singleConversation = render(
|
|
<AiWorkspace
|
|
open
|
|
mode="docked"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
activeEntry={entries[0]}
|
|
entries={entries}
|
|
conversationSettings={[
|
|
{ id: 'active-chat', title: 'Live Chat', target_id: null, archived: false },
|
|
]}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
const activeContextCallCount = contextReadyControllerCalls().length
|
|
singleConversation.unmount()
|
|
controllerCalls = []
|
|
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="docked"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
activeEntry={entries[0]}
|
|
entries={entries}
|
|
conversationSettings={[
|
|
{ id: 'active-chat', title: 'Live Chat', target_id: null, archived: false },
|
|
{ id: 'inactive-chat', title: 'Later Chat', target_id: null, archived: false },
|
|
]}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
|
|
expect(screen.getByTestId('ai-workspace-session-active-chat')).toHaveClass('flex')
|
|
expect(screen.getByTestId('ai-workspace-session-inactive-chat')).toHaveClass('hidden')
|
|
expect(contextReadyControllerCalls()).toHaveLength(activeContextCallCount)
|
|
})
|
|
|
|
it('shows grouped target choices without missing agents', async () => {
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
const trigger = screen.getByTestId('ai-workspace-target-trigger')
|
|
act(() => {
|
|
trigger.focus()
|
|
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
|
})
|
|
const menu = await screen.findByRole('menu')
|
|
|
|
expect(within(menu).getByText('Local agents')).toBeTruthy()
|
|
expect(within(menu).getByText('Local models')).toBeTruthy()
|
|
expect(within(menu).getByText('API models')).toBeTruthy()
|
|
expect(within(menu).getByText('Claude Code')).toBeTruthy()
|
|
expect(within(menu).getByText('Codex')).toBeTruthy()
|
|
expect(within(menu).queryByText('Gemini CLI')).toBeNull()
|
|
expect(within(menu).getByText('Ollama · Llama 3.2')).toBeTruthy()
|
|
expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy()
|
|
})
|
|
|
|
it('marks the first chat active when a prompt is submitted', () => {
|
|
const onConversationSettingsChange = vi.fn()
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
|
|
|
fireEvent.click(screen.getByText('Send mocked prompt'))
|
|
|
|
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
|
|
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
|
|
expect(generateTitleMock).not.toHaveBeenCalled()
|
|
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
|
expect.objectContaining({ title: 'AI Chat' }),
|
|
])
|
|
})
|
|
|
|
it('renames the first chat after the first assistant reply and stores conversation settings', async () => {
|
|
mockMessages = [{
|
|
userMessage: 'summarize quarterly sponsor outreach',
|
|
actions: [],
|
|
response: 'The next step is to follow up with the sponsor pipeline.',
|
|
id: 'msg-title',
|
|
}]
|
|
const onConversationSettingsChange = vi.fn()
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
|
|
})
|
|
expect(generateTitleMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
assistantResponse: 'The next step is to follow up with the sponsor pipeline.',
|
|
permissionMode: 'safe',
|
|
prompt: 'summarize quarterly sponsor outreach',
|
|
target: expect.objectContaining({ kind: 'agent', agent: 'claude_code' }),
|
|
targetReady: true,
|
|
vaultPath: '/tmp/vault',
|
|
}))
|
|
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
|
|
await waitFor(() => {
|
|
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
|
expect.objectContaining({ title: 'Quarterly sponsor outreach' }),
|
|
])
|
|
})
|
|
})
|
|
|
|
it('renames a persisted default chat title after the first assistant reply', async () => {
|
|
mockMessages = [{
|
|
userMessage: 'summarize quarterly sponsor outreach',
|
|
actions: [],
|
|
response: 'The next step is to follow up with the sponsor pipeline.',
|
|
id: 'msg-title',
|
|
}]
|
|
const onConversationSettingsChange = vi.fn()
|
|
render(
|
|
<AiWorkspace
|
|
open
|
|
mode="docked"
|
|
aiAgentsStatus={installedStatuses()}
|
|
aiModelProviders={providers}
|
|
conversationSettings={[{ id: 'stored-chat', title: 'Chat 1', target_id: null, archived: false }]}
|
|
vaultPath="/tmp/vault"
|
|
onClose={vi.fn()}
|
|
onConversationSettingsChange={onConversationSettingsChange}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
|
|
})
|
|
await waitFor(() => {
|
|
expect(onConversationSettingsChange.mock.calls.some(([settings]) => (
|
|
settings.some((setting) => (
|
|
setting.id === 'stored-chat'
|
|
&& setting.title === 'Quarterly sponsor outreach'
|
|
))
|
|
))).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('allows a chat title to be renamed from the sidebar', () => {
|
|
const onConversationSettingsChange = vi.fn()
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
|
|
fireEvent.doubleClick(screen.getByRole('button', { name: /^AI Chat$/i }))
|
|
const input = screen.getByLabelText('Rename chat')
|
|
fireEvent.change(input, { target: { value: 'Sponsor Plan' } })
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
expect(screen.getAllByText('Sponsor Plan').length).toBeGreaterThan(0)
|
|
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
|
expect.objectContaining({ title: 'Sponsor Plan' }),
|
|
])
|
|
})
|
|
|
|
it('opens with the workspace sidebar collapsed and expands from the sidebar header', () => {
|
|
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
|
|
|
|
expect(screen.queryByText('Agents')).toBeNull()
|
|
expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
|
|
|
|
expect(screen.getByText('Agents')).toBeTruthy()
|
|
expect(screen.getByRole('button', { name: 'Collapse AI chat list' })).toBeTruthy()
|
|
})
|
|
})
|