From 021cddefa366541e49aff01bc2895aaf73be20cb Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 25 Feb 2026 23:14:43 +0100 Subject: [PATCH] wip: still working on agentic UI --- .../AssistantSidebar/AssistantSidebar.tsx | 40 +++++- .../navigation/assistantActionDispatcher.ts | 9 +- .../AssistantSidebar.wiring.test.tsx | 51 ++++++++ .../assistantActionDispatcher.test.ts | 45 +++++++ .../navigation/assistantSidebarGuards.test.ts | 119 ++++++++++++++---- .../chatSurfaceModeUsageGuards.test.ts | 73 ++++++++--- .../navigation/chatSurfaceUsageGuards.test.ts | 92 +++++++++++--- 7 files changed, 362 insertions(+), 67 deletions(-) create mode 100644 tests/renderer/components/AssistantSidebar.wiring.test.tsx diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx index fefc154..30e22b9 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useAppStore } from '../../store'; import { resolveAssistantEditorContext } from '../../navigation/assistantPromptContext'; import { planAssistantRequest } from '../../navigation/assistantConversation'; @@ -48,6 +48,9 @@ export const AssistantSidebar: React.FC = () => { streamingContent, toolEvents, beginUserTurn, + appendStreamDelta, + recordToolCall, + recordToolResult, finalizeAssistantTurn, appendAssistantMessage, stopStreaming, @@ -81,6 +84,41 @@ export const AssistantSidebar: React.FC = () => { } }; + useEffect(() => { + const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => { + if (data.conversationId === conversationId) { + appendStreamDelta(data.delta); + } + }); + + const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => { + if (data.conversationId === conversationId) { + const toolCall = data.toolCall as { name: string; arguments: Record }; + recordToolCall(toolCall.name, toolCall.arguments); + } + }); + + const unsubToolResult = window.electronAPI?.chat.onToolResult((data) => { + if (data.conversationId === conversationId) { + const result = data.result as { name: string; result: unknown }; + recordToolResult(result.name); + } + }); + + const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { + if (data.conversationId === conversationId) { + return; + } + }); + + return () => { + unsubDelta?.(); + unsubToolCall?.(); + unsubToolResult?.(); + unsubTitle?.(); + }; + }, [conversationId, appendStreamDelta, recordToolCall, recordToolResult]); + const handleStart = async () => { const trimmed = prompt.trim(); if (!trimmed || isSubmitting) { diff --git a/src/renderer/navigation/assistantActionDispatcher.ts b/src/renderer/navigation/assistantActionDispatcher.ts index dfcd4cb..4a4c68f 100644 --- a/src/renderer/navigation/assistantActionDispatcher.ts +++ b/src/renderer/navigation/assistantActionDispatcher.ts @@ -82,10 +82,10 @@ export function dispatchAssistantAction( return { handled: true }; } - if (input.action === 'switchView') { + if (input.action === 'switchView' || input.action === 'setActiveView') { const parsed = switchViewPayloadSchema.safeParse(payload); if (!parsed.success) { - return invalidPayloadError('switchView'); + return invalidPayloadError(input.action); } const { view } = parsed.data; @@ -120,6 +120,11 @@ export function dispatchAssistantAction( return { handled: true }; } + if (input.action === 'openPanel') { + dependencies.togglePanel(); + return { handled: true }; + } + if (input.action === 'toggleAssistantSidebar') { dependencies.toggleAssistantSidebar(); return { handled: true }; diff --git a/tests/renderer/components/AssistantSidebar.wiring.test.tsx b/tests/renderer/components/AssistantSidebar.wiring.test.tsx new file mode 100644 index 0000000..583a6a4 --- /dev/null +++ b/tests/renderer/components/AssistantSidebar.wiring.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar'; + +describe('AssistantSidebar wiring', () => { + beforeEach(() => { + const onStreamDelta = vi.fn(() => vi.fn()); + const onToolCall = vi.fn(() => vi.fn()); + const onToolResult = vi.fn(() => vi.fn()); + const onTitleUpdated = vi.fn(() => vi.fn()); + + window.electronAPI.chat = { + checkReady: vi.fn(), + validateApiKey: vi.fn(), + setApiKey: vi.fn(), + getApiKey: vi.fn(), + getProtocolHealth: vi.fn(), + getAvailableModels: vi.fn(), + setDefaultModel: vi.fn(), + getSystemPrompt: vi.fn(), + setSystemPrompt: vi.fn(), + getConversations: vi.fn(), + createConversation: vi.fn(), + getConversation: vi.fn(), + updateConversation: vi.fn(), + deleteConversation: vi.fn(), + sendMessage: vi.fn(), + addSystemEvent: vi.fn(), + abortMessage: vi.fn(), + getHistory: vi.fn(), + clearMessages: vi.fn(), + setConversationModel: vi.fn(), + analyzeTaxonomy: vi.fn(), + analyzeMediaImage: vi.fn(), + onStreamDelta, + onToolCall, + onToolResult, + onTitleUpdated, + } as never; + }); + + it('subscribes to chat streaming events on mount', () => { + render(); + + expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/renderer/navigation/assistantActionDispatcher.test.ts b/tests/renderer/navigation/assistantActionDispatcher.test.ts index c8d3cc2..49c9624 100644 --- a/tests/renderer/navigation/assistantActionDispatcher.test.ts +++ b/tests/renderer/navigation/assistantActionDispatcher.test.ts @@ -79,6 +79,51 @@ describe('assistantActionDispatcher', () => { expect(setActiveView).toHaveBeenCalledWith('tags'); }); + it('supports setActiveView action alias for protocol compatibility', () => { + const setActiveView = vi.fn(); + + const result = dispatchAssistantAction( + { + action: 'setActiveView', + payload: { view: 'chat' }, + }, + { + 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('chat'); + }); + + it('supports openPanel action alias for protocol compatibility', () => { + const togglePanel = vi.fn(); + + const result = dispatchAssistantAction( + { + action: 'openPanel', + }, + { + setSelectedPost: vi.fn(), + setSelectedMedia: vi.fn(), + openTab: vi.fn(), + setActiveView: vi.fn(), + toggleSidebar: vi.fn(), + togglePanel, + toggleAssistantSidebar: vi.fn(), + }, + ); + + expect(result.handled).toBe(true); + expect(togglePanel).toHaveBeenCalledTimes(1); + }); + it('rejects switchView payload when view is invalid', () => { const setActiveView = vi.fn(); diff --git a/tests/renderer/navigation/assistantSidebarGuards.test.ts b/tests/renderer/navigation/assistantSidebarGuards.test.ts index 9241747..a81e874 100644 --- a/tests/renderer/navigation/assistantSidebarGuards.test.ts +++ b/tests/renderer/navigation/assistantSidebarGuards.test.ts @@ -1,38 +1,103 @@ -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 { - return readFile(path.join(root, relativePath), 'utf8'); -} +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar'; +import { AssistantPanelControls } from '../../../src/renderer/components/AssistantPanelControls'; +import { useAppStore } from '../../../src/renderer/store'; 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'); + beforeEach(() => { + useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts' }); - expect(sidebar).not.toContain('openChatTab('); - expect(sidebar).not.toContain("type: 'chat'"); + window.electronAPI.chat = { + checkReady: vi.fn().mockResolvedValue({ ready: true }), + validateApiKey: vi.fn(), + setApiKey: vi.fn(), + getApiKey: vi.fn(), + getProtocolHealth: vi.fn(), + getAvailableModels: vi.fn(), + setDefaultModel: vi.fn(), + getSystemPrompt: vi.fn(), + setSystemPrompt: vi.fn(), + getConversations: vi.fn(), + createConversation: vi.fn(), + getConversation: vi.fn(), + updateConversation: vi.fn(), + deleteConversation: vi.fn(), + sendMessage: vi.fn(), + addSystemEvent: vi.fn(), + abortMessage: vi.fn(), + getHistory: vi.fn().mockResolvedValue([]), + clearMessages: vi.fn(), + setConversationModel: vi.fn(), + analyzeTaxonomy: vi.fn(), + analyzeMediaImage: vi.fn(), + onStreamDelta: vi.fn(() => vi.fn()), + onToolCall: vi.fn(() => vi.fn()), + onToolResult: vi.fn(() => vi.fn()), + onTitleUpdated: vi.fn(() => vi.fn()), + } as never; }); - 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'); + it('keeps assistant sidebar self-contained and avoids opening chat tabs on mount', () => { + render(React.createElement(AssistantSidebar)); - 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(' tab.type === 'chat')).toBe(false); }); - it('persists assistant action feedback events to chat history', async () => { - const sidebar = await read('src/renderer/components/AssistantSidebar/AssistantSidebar.tsx'); + it('renders rich assistant panel widget branches at runtime', () => { + const onAction = vi.fn(); - expect(sidebar).toContain('chat.addSystemEvent'); + const { container } = render( + React.createElement(AssistantPanelControls, { + elements: [ + { type: 'chart', chartType: 'bar', title: 'Trend', series: [{ label: 'Jan', value: 10 }] }, + { + type: 'form', + formId: 'f1', + submitLabel: 'Submit', + action: 'submitNeedsInput', + fields: [{ key: 'name', label: 'Name', inputType: 'text', required: true }], + }, + { type: 'datePicker', key: 'date', label: 'Date', submitLabel: 'Pick', action: 'submitNeedsInput' }, + { type: 'card', title: 'Card', body: 'Body', actions: [{ label: 'Open', action: 'openSettings' }] }, + { type: 'image', src: 'https://example.com/a.png', caption: 'Image', action: 'openSettings' }, + { + type: 'tabs', + tabs: [{ id: 'tab-1', label: 'Tab 1', elements: [{ type: 'text', text: 'Inside tab' }] }], + }, + { type: 'input', key: 'query', label: 'Query', inputType: 'text', submitLabel: 'Run', action: 'openSettings' }, + ], + onAction, + }), + ); + + expect(container.querySelector('.assistant-panel-chart')).not.toBeNull(); + expect(container.querySelector('.assistant-panel-form')).not.toBeNull(); + expect(container.querySelector('.assistant-panel-card')).not.toBeNull(); + expect(container.querySelector('.assistant-panel-image')).not.toBeNull(); + expect(container.querySelector('.assistant-panel-tabs')).not.toBeNull(); + }); + + it('enforces action confirmation policy before dispatching assistant actions', () => { + const onAction = vi.fn(); + const confirmMock = vi.fn().mockReturnValue(true); + Object.defineProperty(window, 'confirm', { + value: confirmMock, + configurable: true, + }); + + const { getByText } = render( + React.createElement(AssistantPanelControls, { + elements: [{ type: 'action', label: 'Open Settings', action: 'openSettings' }], + actionPolicies: { openSettings: 'confirm' }, + onAction, + }), + ); + + fireEvent.click(getByText('Open Settings')); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('openSettings', undefined); }); }); diff --git a/tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts b/tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts index 7993bab..ff0d379 100644 --- a/tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts +++ b/tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts @@ -1,24 +1,61 @@ -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 { - return readFile(path.join(root, relativePath), 'utf8'); -} +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChatPanel } from '../../../src/renderer/components/ChatPanel/ChatPanel'; +import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar'; 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'); + beforeEach(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); + } - expect(chatPanel).toContain('getChatSurfaceMode('); - expect(assistantSidebar).toContain('getChatSurfaceMode('); + window.electronAPI.chat = { + checkReady: vi.fn().mockResolvedValue({ ready: true }), + validateApiKey: vi.fn(), + setApiKey: vi.fn(), + getApiKey: vi.fn(), + getProtocolHealth: vi.fn(), + getAvailableModels: vi.fn().mockResolvedValue({ + success: true, + models: [{ id: 'gpt-5', name: 'GPT-5' }], + }), + setDefaultModel: vi.fn(), + getSystemPrompt: vi.fn(), + setSystemPrompt: vi.fn(), + getConversations: vi.fn(), + createConversation: vi.fn(), + getConversation: vi.fn().mockResolvedValue({ + id: 'conv-tab', + title: 'Chat', + model: 'gpt-5', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + updateConversation: vi.fn(), + deleteConversation: vi.fn(), + sendMessage: vi.fn(), + addSystemEvent: vi.fn(), + abortMessage: vi.fn(), + getHistory: vi.fn().mockResolvedValue([]), + clearMessages: vi.fn(), + setConversationModel: vi.fn(), + analyzeTaxonomy: vi.fn(), + analyzeMediaImage: vi.fn(), + onStreamDelta: vi.fn(() => vi.fn()), + onToolCall: vi.fn(() => vi.fn()), + onToolResult: vi.fn(() => vi.fn()), + onTitleUpdated: vi.fn(() => vi.fn()), + } as never; + }); - expect(chatPanel).toContain('showModelSelector'); - expect(chatPanel).toContain('showWelcomeTips'); - expect(assistantSidebar).toContain('showWelcomeTips'); - expect(assistantSidebar).toContain('showToolMarkers'); + it('shows model selector in tab chat but not in assistant sidebar', async () => { + const { container: tabContainer } = render(React.createElement(ChatPanel, { conversationId: 'conv-tab' })); + + expect(tabContainer.querySelector('.model-selector-button')).not.toBeNull(); + + render(React.createElement(AssistantSidebar)); + + expect(screen.queryByText('gpt-5')).toBeNull(); }); }); diff --git a/tests/renderer/navigation/chatSurfaceUsageGuards.test.ts b/tests/renderer/navigation/chatSurfaceUsageGuards.test.ts index 72418d0..e776b49 100644 --- a/tests/renderer/navigation/chatSurfaceUsageGuards.test.ts +++ b/tests/renderer/navigation/chatSurfaceUsageGuards.test.ts @@ -1,25 +1,79 @@ -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 { - return readFile(path.join(root, relativePath), 'utf8'); -} +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { ChatPanel } from '../../../src/renderer/components/ChatPanel/ChatPanel'; +import { AssistantSidebar } from '../../../src/renderer/components/AssistantSidebar/AssistantSidebar'; +import { useAppStore } from '../../../src/renderer/store'; 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'); + beforeEach(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); + } - expect(chatPanel).toContain('useChatSurfaceState('); - expect(chatPanel).toContain(' vi.fn()), + onToolCall: vi.fn(() => vi.fn()), + onToolResult: vi.fn(() => vi.fn()), + onTitleUpdated: vi.fn(() => vi.fn()), + } as never; + }); + + it('wires chat panel to load data and subscribe to stream/tool/title events', () => { + render(React.createElement(ChatPanel, { conversationId: 'conv-tab' })); + + expect(window.electronAPI.chat.checkReady).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.getConversation).toHaveBeenCalledWith('conv-tab'); + expect(window.electronAPI.chat.getHistory).toHaveBeenCalledWith('conv-tab'); + expect(window.electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(1); + + expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1); + }); + + it('wires assistant sidebar stream subscriptions and does not auto-open chat tab', () => { + render(React.createElement(AssistantSidebar)); + + expect(window.electronAPI.chat.onStreamDelta).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolCall).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onToolResult).toHaveBeenCalledTimes(1); + expect(window.electronAPI.chat.onTitleUpdated).toHaveBeenCalledTimes(1); + + expect(useAppStore.getState().tabs.some((tab) => tab.type === 'chat')).toBe(false); }); });