wip: agui integration
This commit is contained in:
@@ -639,6 +639,10 @@ describe('ChatEngine', () => {
|
||||
|
||||
expect(result).toContain('Blogging Desktop Server');
|
||||
expect(result).toContain('Available Tools');
|
||||
expect(result).toContain('Agentic UI Contract');
|
||||
expect(result).toContain('specVersion');
|
||||
expect(result).toContain('tabs');
|
||||
expect(result).toContain('openSettings');
|
||||
});
|
||||
|
||||
it('should return built-in prompt when saved prompt is empty', async () => {
|
||||
|
||||
@@ -1638,14 +1638,27 @@ describe('IPC Handlers', () => {
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
|
||||
const toggleDevTools = vi.fn();
|
||||
it('should open detached devtools on sender when action is toggleDevTools and devtools are closed', async () => {
|
||||
const openDevTools = vi.fn();
|
||||
const isDevToolsOpened = vi.fn(() => false);
|
||||
const send = vi.fn();
|
||||
const event = { sender: { toggleDevTools, send } };
|
||||
const event = { sender: { openDevTools, isDevToolsOpened, send } };
|
||||
|
||||
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
||||
|
||||
expect(toggleDevTools).toHaveBeenCalled();
|
||||
expect(openDevTools).toHaveBeenCalledWith({ mode: 'detach' });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close devtools on sender when action is toggleDevTools and devtools are open', async () => {
|
||||
const closeDevTools = vi.fn();
|
||||
const isDevToolsOpened = vi.fn(() => true);
|
||||
const send = vi.fn();
|
||||
const event = { sender: { closeDevTools, isDevToolsOpened, send } };
|
||||
|
||||
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
||||
|
||||
expect(closeDevTools).toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
16
tests/renderer/components/AssistantSidebar.styles.test.ts
Normal file
16
tests/renderer/components/AssistantSidebar.styles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
tests/renderer/components/ChatSurface.sharedStyles.test.ts
Normal file
41
tests/renderer/components/ChatSurface.sharedStyles.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
195
tests/renderer/navigation/assistantActionDispatcher.test.ts
Normal file
195
tests/renderer/navigation/assistantActionDispatcher.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
35
tests/renderer/navigation/assistantConversation.test.ts
Normal file
35
tests/renderer/navigation/assistantConversation.test.ts
Normal 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?');
|
||||
});
|
||||
});
|
||||
195
tests/renderer/navigation/assistantPanelSpec.test.ts
Normal file
195
tests/renderer/navigation/assistantPanelSpec.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
tests/renderer/navigation/assistantPromptContext.test.ts
Normal file
47
tests/renderer/navigation/assistantPromptContext.test.ts
Normal 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:');
|
||||
});
|
||||
});
|
||||
38
tests/renderer/navigation/assistantSidebarGuards.test.ts
Normal file
38
tests/renderer/navigation/assistantSidebarGuards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
98
tests/renderer/navigation/chatSession.test.ts
Normal file
98
tests/renderer/navigation/chatSession.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
25
tests/renderer/navigation/chatSurfaceMode.test.ts
Normal file
25
tests/renderer/navigation/chatSurfaceMode.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts
Normal file
24
tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
25
tests/renderer/navigation/chatSurfaceUsageGuards.test.ts
Normal file
25
tests/renderer/navigation/chatSurfaceUsageGuards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
58
tests/renderer/navigation/useChatMessageSender.test.tsx
Normal file
58
tests/renderer/navigation/useChatMessageSender.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
40
tests/renderer/navigation/useChatSurfaceState.test.tsx
Normal file
40
tests/renderer/navigation/useChatSurfaceState.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user