wip: agui integration

This commit is contained in:
2026-02-25 19:51:58 +01:00
parent 5efbcfe03a
commit fcdf869a7c
59 changed files with 3467 additions and 267 deletions

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('AssistantSidebar styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.css'
);
it('keeps the sidebar container scrollable for long assistant content', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.assistant-sidebar\s*\{[^}]*min-height:\s*0;[^}]*overflow-y:\s*auto;[^}]*\}/s);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('Chat surface shared styles', () => {
const sharedCssPath = path.resolve(
__dirname,
'../../../src/renderer/styles/chatSurface.css'
);
const chatPanelPath = path.resolve(
__dirname,
'../../../src/renderer/components/ChatPanel/ChatPanel.tsx'
);
const assistantSidebarPath = path.resolve(
__dirname,
'../../../src/renderer/components/AssistantSidebar/AssistantSidebar.tsx'
);
it('defines reusable surface primitives', () => {
const css = fs.readFileSync(sharedCssPath, 'utf8');
expect(css).toContain('.chat-surface');
expect(css).toContain('.chat-surface-scroll');
expect(css).toContain('.chat-surface-input');
expect(css).toContain('.chat-surface-error');
expect(css).toContain('.chat-surface-section');
});
it('applies shared surface class names in both chat renderers', () => {
const chatPanel = fs.readFileSync(chatPanelPath, 'utf8');
const assistantSidebar = fs.readFileSync(assistantSidebarPath, 'utf8');
expect(chatPanel).toContain('chat-surface');
expect(chatPanel).toContain('chat-surface-scroll');
expect(assistantSidebar).toContain('chat-surface');
expect(assistantSidebar).toContain('chat-surface-input');
expect(assistantSidebar).toContain('chat-surface-error');
expect(assistantSidebar).toContain('chat-surface-section');
});
});

View File

@@ -16,6 +16,7 @@ describe('WindowTitleBar', () => {
useAppStore.setState({
sidebarVisible: true,
panelVisible: false,
assistantSidebarVisible: false,
});
});
@@ -32,6 +33,7 @@ describe('WindowTitleBar', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
expect(screen.getByLabelText('Toggle Sidebar')).toBeInTheDocument();
expect(screen.getByLabelText('Toggle Panel')).toBeInTheDocument();
expect(screen.getByLabelText('Toggle Assistant Sidebar')).toBeInTheDocument();
});
it('does not request macOS title bar metrics when simulated title bar is disabled', async () => {
@@ -139,9 +141,23 @@ describe('WindowTitleBar', () => {
const actionButtons = Array.from(document.querySelectorAll('.window-titlebar-actions .window-titlebar-action-button'));
expect(actionButtons).toHaveLength(2);
expect(actionButtons).toHaveLength(3);
expect(actionButtons[0]).toHaveAttribute('aria-label', 'Toggle Sidebar');
expect(actionButtons[1]).toHaveAttribute('aria-label', 'Toggle Panel');
expect(actionButtons[2]).toHaveAttribute('aria-label', 'Toggle Assistant Sidebar');
});
it('renders a right-side assistant sidebar toggle button and toggles assistant sidebar visibility', () => {
render(<WindowTitleBar />);
const toggleButton = screen.getByLabelText('Toggle Assistant Sidebar');
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Show Assistant Sidebar (Ctrl+\\)');
fireEvent.click(toggleButton);
expect(useAppStore.getState().assistantSidebarVisible).toBe(true);
expect(toggleButton).toHaveAttribute('title', 'Hide Assistant Sidebar (Ctrl+\\)');
});
it('updates overlay inset CSS variables when window controls geometry changes', () => {
@@ -248,6 +264,7 @@ describe('WindowTitleBar', () => {
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Assistant Sidebar Ctrl+\\' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();

View File

@@ -0,0 +1,195 @@
import { describe, expect, it, vi } from 'vitest';
import { dispatchAssistantAction } from '../../../src/renderer/navigation/assistantActionDispatcher';
describe('assistantActionDispatcher', () => {
it('opens a post from action payload', () => {
const setSelectedPost = vi.fn();
const openTab = vi.fn();
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openPost',
payload: { postId: 'post-123' },
},
{
setSelectedPost,
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('posts');
expect(setSelectedPost).toHaveBeenCalledWith('post-123');
expect(openTab).toHaveBeenCalledWith({ type: 'post', id: 'post-123', isTransient: false });
});
it('opens media from action payload', () => {
const setSelectedMedia = vi.fn();
const openTab = vi.fn();
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openMedia',
payload: { mediaId: 'media-321' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia,
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('media');
expect(setSelectedMedia).toHaveBeenCalledWith('media-321');
expect(openTab).toHaveBeenCalledWith({ type: 'media', id: 'media-321', isTransient: false });
});
it('switches sidebar view', () => {
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'switchView',
payload: { view: 'tags' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('tags');
});
it('rejects switchView payload when view is invalid', () => {
const setActiveView = vi.fn();
const result = dispatchAssistantAction(
{
action: 'switchView',
payload: { view: 'not-a-view' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Invalid payload');
expect(setActiveView).not.toHaveBeenCalled();
});
it('opens chat tab for openChat action', () => {
const setActiveView = vi.fn();
const openTab = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openChat',
payload: { conversationId: 'conversation-42' },
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('chat');
expect(openTab).toHaveBeenCalledWith({ type: 'chat', id: 'conversation-42', isTransient: false });
});
it('opens settings tab for openSettings action', () => {
const setActiveView = vi.fn();
const openTab = vi.fn();
const result = dispatchAssistantAction(
{
action: 'openSettings',
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab,
setActiveView,
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(true);
expect(setActiveView).toHaveBeenCalledWith('settings');
expect(openTab).toHaveBeenCalledWith({ type: 'settings', id: 'settings', isTransient: false });
});
it('rejects invalid payload for openChat action', () => {
const result = dispatchAssistantAction(
{
action: 'openChat',
payload: {},
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView: vi.fn(),
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Invalid payload');
});
it('returns an error for unknown actions', () => {
const result = dispatchAssistantAction(
{
action: 'doesNotExist',
},
{
setSelectedPost: vi.fn(),
setSelectedMedia: vi.fn(),
openTab: vi.fn(),
setActiveView: vi.fn(),
toggleSidebar: vi.fn(),
togglePanel: vi.fn(),
toggleAssistantSidebar: vi.fn(),
},
);
expect(result.handled).toBe(false);
expect(result.error).toContain('Unsupported action');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { planAssistantRequest } from '../../../src/renderer/navigation/assistantConversation';
describe('assistantConversation', () => {
it('creates enriched first message when no conversation exists yet', () => {
const result = planAssistantRequest({
conversationId: null,
userPrompt: 'Find weak tags',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result.shouldCreateConversation).toBe(true);
expect(result.outboundMessage).toContain('User request: Find weak tags');
expect(result.outboundMessage).toContain('Current editor context type: post');
});
it('sends plain follow-up message when conversation already exists', () => {
const result = planAssistantRequest({
conversationId: 'conv-1',
userPrompt: 'What next?',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result.shouldCreateConversation).toBe(false);
expect(result.outboundMessage).toBe('What next?');
});
});

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import { extractAssistantPanelSpec, extractAssistantResponseContent } from '../../../src/renderer/navigation/assistantPanelSpec';
describe('assistantPanelSpec', () => {
it('extracts valid spec from fenced json block', () => {
const raw = [
'Here is the analysis summary.',
'```json',
'{"specVersion":"1","elements":[{"type":"metric","label":"Drafts","value":"12"}]}',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements).toHaveLength(1);
expect(result?.elements[0]).toEqual({ type: 'metric', label: 'Drafts', value: '12' });
});
it('returns null for invalid schema payload', () => {
const raw = '{"specVersion":"1","elements":[{"type":"table","columns":[]}]}';
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('ignores yaml payloads to keep the protocol JSON-only', () => {
const raw = [
'Here is your chart.',
'```yaml',
'specVersion: "1"',
'elements:',
' - type: chart',
' chartType: bar',
' title: Posts by Month',
' series:',
' - label: Jan',
' value: 10',
' - label: Feb',
' value: 20',
'```',
].join('\n');
const result = extractAssistantPanelSpec(raw);
expect(result).toBeNull();
});
it('extracts text plus ui payload from mixed assistant response', () => {
const raw = [
'I found two weak months. Please confirm how to proceed.',
'```json',
'{"specVersion":"1","elements":[{"type":"chart","chartType":"bar","title":"Posts by Month","series":[{"label":"Jan","value":10},{"label":"Feb","value":20}]}]}',
'```',
].join('\n\n');
const result = extractAssistantResponseContent(raw);
expect(result.displayText).toContain('I found two weak months');
expect(result.panelSpec).not.toBeNull();
expect(result.panelSpec?.elements[0]).toMatchObject({ type: 'chart', chartType: 'bar' });
});
it('normalizes tab-channel envelope payloads into canonical panel spec', () => {
const raw = JSON.stringify({
type: 'tab',
title: 'Posts mit Tag spielen',
id: 'spielen-tag-analysis',
content: {
type: 'tabs',
tabs: [
{
id: 'yearly-chart',
title: 'Jahresübersicht',
content: {
type: 'chart',
chartType: 'bar',
data: {
labels: ['2011', '2013'],
datasets: [{ data: [2, 8] }],
},
},
},
],
},
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.specVersion).toBe('1');
expect(result?.elements[0]).toMatchObject({ type: 'tabs' });
});
it('normalizes chartjs-like chart payloads to series format', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar'],
datasets: [{ data: [23, 10, 14] }],
},
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements[0]).toMatchObject({
type: 'chart',
chartType: 'bar',
series: [
{ label: 'Jan', value: 23 },
{ label: 'Feb', value: 10 },
{ label: 'Mar', value: 14 },
],
});
});
it('parses extended widgets including chart, form, datePicker, card, image, input and tabs', () => {
const raw = JSON.stringify({
specVersion: '1',
elements: [
{
type: 'chart',
chartType: 'bar',
title: 'Posts by Month',
series: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
],
},
{
type: 'input',
key: 'query',
label: 'Search Query',
inputType: 'text',
placeholder: 'Find post',
},
{
type: 'datePicker',
key: 'publishDate',
label: 'Publish Date',
},
{
type: 'form',
formId: 'meta-form',
title: 'Update Metadata',
submitLabel: 'Apply',
action: 'updatePostMetadata',
fields: [
{ key: 'title', label: 'Title', inputType: 'text' },
{ key: 'isDraft', label: 'Draft', inputType: 'checkbox' },
],
},
{
type: 'card',
title: 'Suggestion',
body: 'Consider adding tags.',
actions: [
{ label: 'Open Tags', action: 'switchView', payload: { view: 'tags' } },
],
},
{
type: 'image',
src: 'https://example.com/image.png',
alt: 'Preview',
caption: 'Generated preview',
},
{
type: 'tabs',
tabs: [
{
id: 'summary',
label: 'Summary',
elements: [{ type: 'text', text: 'Summary text' }],
},
{
id: 'details',
label: 'Details',
elements: [{ type: 'metric', label: 'Count', value: '42' }],
},
],
},
],
});
const result = extractAssistantPanelSpec(raw);
expect(result).not.toBeNull();
expect(result?.elements).toHaveLength(7);
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildAssistantStartPrompt } from '../../../src/renderer/navigation/assistantPromptContext';
describe('assistantPromptContext', () => {
it('enriches prompt with active post context', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Find weak tags',
context: {
tabType: 'post',
id: 'post-1',
title: 'Launch Notes',
},
});
expect(result).toContain('User request: Find weak tags');
expect(result).toContain('Current editor context type: post');
expect(result).toContain('Current editor context id: post-1');
expect(result).toContain('Current editor context title: Launch Notes');
});
it('enriches prompt with active media context', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Suggest alt text variants',
context: {
tabType: 'media',
id: 'media-4',
title: 'cover.jpg',
},
});
expect(result).toContain('User request: Suggest alt text variants');
expect(result).toContain('Current editor context type: media');
expect(result).toContain('Current editor context id: media-4');
expect(result).toContain('Current editor context title: cover.jpg');
});
it('falls back to none when no active editor context is available', () => {
const result = buildAssistantStartPrompt({
userPrompt: 'Summarize current project health',
context: null,
});
expect(result).toContain('User request: Summarize current project health');
expect(result).toContain('Current editor context type: none');
expect(result).not.toContain('Current editor context id:');
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('assistant sidebar guard rails', () => {
it('keeps assistant sidebar self-contained and avoids opening chat tabs directly', async () => {
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(sidebar).not.toContain('openChatTab(');
expect(sidebar).not.toContain("type: 'chat'");
});
it('renders extended widget branches for assistant panel', async () => {
const controls = await read('src/renderer/components/AssistantPanelControls/AssistantPanelControls.tsx');
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(controls).toContain("element.type === 'chart'");
expect(controls).toContain("element.type === 'form'");
expect(controls).toContain("element.type === 'datePicker'");
expect(controls).toContain("element.type === 'card'");
expect(controls).toContain("element.type === 'image'");
expect(controls).toContain("element.type === 'tabs'");
expect(controls).toContain("element.type === 'input'");
expect(sidebar).toContain('<AssistantPanelControls');
});
it('persists assistant action feedback events to chat history', async () => {
const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(sidebar).toContain('chat.addSystemEvent');
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import {
ensureConversationId,
sendConversationMessage,
type ChatService,
} from '../../../src/renderer/navigation/chatSession';
describe('chatSession', () => {
it('reuses existing conversation id when available', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn(),
};
const conversationId = await ensureConversationId({
currentConversationId: 'conv-existing',
createTitle: 'Ignored',
chatService,
});
expect(conversationId).toBe('conv-existing');
expect(chatService.createConversation).not.toHaveBeenCalled();
});
it('creates conversation when no id exists', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn().mockResolvedValue({ id: 'conv-created' }),
};
const conversationId = await ensureConversationId({
currentConversationId: null,
createTitle: 'Assistant Session',
chatService,
});
expect(conversationId).toBe('conv-created');
expect(chatService.createConversation).toHaveBeenCalledWith('Assistant Session');
});
it('throws when conversation creation returns no id', async () => {
const chatService: Pick<ChatService, 'createConversation'> = {
createConversation: vi.fn().mockResolvedValue(null),
};
await expect(
ensureConversationId({
currentConversationId: null,
createTitle: 'Assistant Session',
chatService,
}),
).rejects.toThrow('No conversation id returned');
});
it('normalizes successful send response', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'Response text' }),
};
const result = await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
chatService,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Response text');
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello');
});
it('normalizes error send response', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'Failed' }),
};
const result = await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
chatService,
});
expect(result.success).toBe(false);
expect(result.error).toBe('Failed');
});
it('forwards send metadata such as UI surface', async () => {
const chatService: Pick<ChatService, 'sendMessage'> = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
};
await sendConversationMessage({
conversationId: 'conv-1',
message: 'Hello',
metadata: { surface: 'sidebar' },
chatService,
});
expect(chatService.sendMessage).toHaveBeenCalledWith('conv-1', 'Hello', { surface: 'sidebar' });
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
getChatSurfaceMode,
type ChatSurfaceModeId,
} from '../../../src/renderer/navigation/chatSurfaceMode';
describe('chatSurfaceMode', () => {
it('returns mode flags for tab and sidebar surfaces', () => {
const tabMode = getChatSurfaceMode('tab');
const sidebarMode = getChatSurfaceMode('sidebar');
expect(tabMode.showModelSelector).toBe(true);
expect(tabMode.showWelcomeTips).toBe(true);
expect(tabMode.showToolMarkers).toBe(true);
expect(sidebarMode.showModelSelector).toBe(false);
expect(sidebarMode.showWelcomeTips).toBe(false);
expect(sidebarMode.showToolMarkers).toBe(true);
});
it('covers all declared mode ids', () => {
const modeIds: ChatSurfaceModeId[] = ['tab', 'sidebar'];
expect(() => modeIds.forEach((modeId) => getChatSurfaceMode(modeId))).not.toThrow();
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('chat surface mode usage guards', () => {
it('uses shared mode config in both chat surfaces', async () => {
const chatPanel = await read('src/renderer/components/ChatPanel/ChatPanel.tsx');
const assistantSidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(chatPanel).toContain('getChatSurfaceMode(');
expect(assistantSidebar).toContain('getChatSurfaceMode(');
expect(chatPanel).toContain('showModelSelector');
expect(chatPanel).toContain('showWelcomeTips');
expect(assistantSidebar).toContain('showWelcomeTips');
expect(assistantSidebar).toContain('showToolMarkers');
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const root = path.resolve(__dirname, '../../..');
async function read(relativePath: string): Promise<string> {
return readFile(path.join(root, relativePath), 'utf8');
}
describe('chat surface shared usage guards', () => {
it('uses shared chat surface state hook and transcript renderer in both surfaces', async () => {
const chatPanel = await read('src/renderer/components/ChatPanel/ChatPanel.tsx');
const assistantSidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx');
expect(chatPanel).toContain('useChatSurfaceState(');
expect(chatPanel).toContain('<ChatTranscript');
expect(chatPanel).toContain('<AssistantPanelControls');
expect(chatPanel).toContain('extractAssistantResponseContent(');
expect(assistantSidebar).toContain('useChatSurfaceState(');
expect(assistantSidebar).toContain('<ChatTranscript');
expect(assistantSidebar).toContain('<AssistantPanelControls');
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useChatMessageSender } from '../../../src/renderer/navigation/useChatMessageSender';
describe('useChatMessageSender', () => {
it('sends message and clears error on success', async () => {
const chatService = {
sendMessage: vi.fn().mockResolvedValue({ success: true, message: 'ok' }),
};
const { result } = renderHook(() => useChatMessageSender({ chatService }));
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
await act(async () => {
response = await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(response?.success).toBe(true);
expect(response?.message).toBe('ok');
expect(result.current.lastError).toBeNull();
});
it('stores normalized error when send fails', async () => {
const chatService = {
sendMessage: vi.fn().mockResolvedValue({ success: false, error: 'boom' }),
};
const { result } = renderHook(() => useChatMessageSender({ chatService }));
await act(async () => {
await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(result.current.lastError).toBe('boom');
});
it('returns default error when service is unavailable', async () => {
const { result } = renderHook(() => useChatMessageSender({ chatService: null }));
let response: Awaited<ReturnType<typeof result.current.sendMessage>> | null = null;
await act(async () => {
response = await result.current.sendMessage({
conversationId: 'conv-1',
message: 'hello',
});
});
expect(response?.success).toBe(false);
expect(response?.error).toContain('Chat service unavailable');
expect(result.current.lastError).toContain('Chat service unavailable');
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useChatSurfaceState } from '../../../src/renderer/navigation/useChatSurfaceState';
describe('useChatSurfaceState', () => {
it('tracks a full user-assistant turn including streaming and tool calls', () => {
const { result } = renderHook(() => useChatSurfaceState());
act(() => {
result.current.beginUserTurn('conv-1', 'hello');
result.current.appendStreamDelta('A');
result.current.appendStreamDelta('B');
result.current.recordToolCall('list_posts', { query: 'hello' });
result.current.recordToolResult('list_posts');
result.current.finalizeAssistantTurn('conv-1', 'AB');
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0].role).toBe('user');
expect(result.current.messages[1].role).toBe('assistant');
expect(result.current.messages[1].content).toBe('AB');
expect(result.current.messages[1].toolCalls).toContain('list_posts');
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingContent).toBe('');
});
it('aborts a stream into a partial assistant message', () => {
const { result } = renderHook(() => useChatSurfaceState());
act(() => {
result.current.beginUserTurn('conv-2', 'hello');
result.current.appendStreamDelta('partial content');
result.current.abortStreaming('conv-2', 'Cancelled');
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[1].content).toContain('partial content');
expect(result.current.messages[1].content).toContain('Cancelled');
});
});

View File

@@ -38,6 +38,7 @@ describe('AppStore', () => {
posts: [],
selectedPostId: null,
dirtyPosts: new Set(),
assistantSidebarVisible: false,
});
});
@@ -167,6 +168,16 @@ describe('AppStore', () => {
expect(getStore().preferredEditorMode).toBe('markdown');
});
it('should toggle assistant sidebar visibility', () => {
expect(getStore().assistantSidebarVisible).toBe(false);
getStore().toggleAssistantSidebar();
expect(getStore().assistantSidebarVisible).toBe(true);
getStore().toggleAssistantSidebar();
expect(getStore().assistantSidebarVisible).toBe(false);
});
it('should set active panel tab', () => {
getStore().setPanelActiveTab('output');