From daf8addb53824f75075eb1e25776edcb2aca7ace Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 19:19:31 +0100 Subject: [PATCH] fix: a2ui actions were not wired up at all --- src/renderer/a2ui/useA2UISurface.ts | 9 +- .../AssistantSidebar/AssistantSidebar.tsx | 3 +- .../components/ChatPanel/ChatPanel.tsx | 3 +- .../a2ui/surfaceActionWiring.test.tsx | 188 ++++++++++++++++++ 4 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 tests/renderer/a2ui/surfaceActionWiring.test.tsx diff --git a/src/renderer/a2ui/useA2UISurface.ts b/src/renderer/a2ui/useA2UISurface.ts index f23f47e..a7f4458 100644 --- a/src/renderer/a2ui/useA2UISurface.ts +++ b/src/renderer/a2ui/useA2UISurface.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { A2UISurfaceManager } from './A2UISurfaceManager'; import { replaySurfacesFromMessages } from './surfaceAssociation'; -import type { A2UIResolvedComponent, A2UIServerMessage, A2UIClientAction } from '../../main/a2ui/types'; +import type { A2UIResolvedComponent, A2UIServerMessage } from '../../main/a2ui/types'; import type { ChatMessage } from '../../main/shared/electronApi'; interface UseA2UISurfaceInput { @@ -31,8 +31,6 @@ interface UseA2UISurfaceResult { dismissedSurfaceIds: Set; /** Dismiss a surface by ID */ dismissSurface: (surfaceId: string) => void; - /** Dispatch an action back to the main process */ - dispatchAction: (action: A2UIClientAction) => void; /** Update a local data binding (for form inputs) */ updateLocalData: (surfaceId: string, path: string, value: unknown) => void; /** Get the data model for a surface */ @@ -148,10 +146,6 @@ export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult }); }, []); - const dispatchAction = useCallback((action: A2UIClientAction) => { - window.electronAPI?.chat.dispatchA2UIAction?.(action); - }, []); - const updateLocalData = useCallback((surfaceId: string, path: string, value: unknown) => { managerRef.current.updateLocalData(surfaceId, path, value); }, []); @@ -183,7 +177,6 @@ export function useA2UISurface(input: UseA2UISurfaceInput): UseA2UISurfaceResult latestSurfaceId, dismissedSurfaceIds, dismissSurface, - dispatchAction, updateLocalData, getDataModel, clearSurfaces, diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx index 3dac807..bed3ca7 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.tsx @@ -59,7 +59,6 @@ export const AssistantSidebar: React.FC = () => { latestSurfaceId, dismissedSurfaceIds, dismissSurface, - dispatchAction, updateLocalData, } = useA2UISurface({ conversationId }); @@ -281,7 +280,7 @@ export const AssistantSidebar: React.FC = () => { latestSurfaceId={latestSurfaceId} dismissedSurfaceIds={dismissedSurfaceIds} onSurfaceDismiss={dismissSurface} - onSurfaceAction={dispatchAction} + onSurfaceAction={(a) => handleAssistantAction(a.action, a.payload)} onSurfaceDataChange={updateLocalData} currentTurnIndex={currentTurnIndex} /> diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index e768d9b..99f1e65 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -64,7 +64,6 @@ export const ChatPanel: React.FC = ({ conversationId }) => { latestSurfaceId, dismissedSurfaceIds, dismissSurface, - dispatchAction, updateLocalData, replayFromMessages, } = useA2UISurface({ conversationId }); @@ -387,7 +386,7 @@ export const ChatPanel: React.FC = ({ conversationId }) => { latestSurfaceId={latestSurfaceId} dismissedSurfaceIds={dismissedSurfaceIds} onSurfaceDismiss={dismissSurface} - onSurfaceAction={dispatchAction} + onSurfaceAction={(a) => handleAssistantAction(a.action, a.payload)} onSurfaceDataChange={updateLocalData} currentTurnIndex={currentTurnIndex} /> diff --git a/tests/renderer/a2ui/surfaceActionWiring.test.tsx b/tests/renderer/a2ui/surfaceActionWiring.test.tsx new file mode 100644 index 0000000..7a92aeb --- /dev/null +++ b/tests/renderer/a2ui/surfaceActionWiring.test.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import type { A2UIClientAction } from '../../../src/main/a2ui/types'; +import { useAppStore } from '../../../src/renderer/store'; + +// Capture the onSurfaceAction prop passed to ChatTranscript +let capturedOnSurfaceAction: ((action: A2UIClientAction) => void) | undefined; + +vi.mock('../../../src/renderer/components/ChatSurface', () => ({ + ChatTranscript: (props: { onSurfaceAction?: (action: A2UIClientAction) => void }) => { + capturedOnSurfaceAction = props.onSurfaceAction; + return React.createElement('div', { 'data-testid': 'mock-chat-transcript' }); + }, +})); + +vi.mock('../../../src/renderer/navigation/useChatSurfaceState', () => ({ + useChatSurfaceState: () => ({ + messages: [{ role: 'user', content: 'test' }], + isStreaming: false, + streamingContent: '', + toolEvents: [], + beginUserTurn: vi.fn(), + appendStreamDelta: vi.fn(), + recordToolCall: vi.fn(), + recordToolResult: vi.fn(), + finalizeAssistantTurn: vi.fn(), + appendAssistantMessage: vi.fn(), + stopStreaming: vi.fn(), + abortStreaming: vi.fn(), + getStreamingContent: vi.fn(() => ''), + setMessages: vi.fn(), + }), +})); + +vi.mock('../../../src/renderer/navigation/useChatMessageSender', () => ({ + useChatMessageSender: () => ({ + sendMessage: vi.fn(), + }), +})); + +vi.mock('../../../src/renderer/a2ui/useA2UISurface', () => ({ + useA2UISurface: () => ({ + surfaces: [], + surfacesByTurn: new Map(), + latestSurfaceId: null, + dismissedSurfaceIds: new Set(), + dismissSurface: vi.fn(), + updateLocalData: vi.fn(), + getDataModel: vi.fn(() => ({})), + clearSurfaces: vi.fn(), + replayFromMessages: vi.fn(), + }), +})); + +function setupChatApi() { + window.electronAPI.chat = { + checkReady: vi.fn(), + validateApiKey: vi.fn(), + setApiKey: vi.fn(), + getApiKey: vi.fn(), + getAvailableModels: vi.fn(), + setDefaultModel: vi.fn(), + getSystemPrompt: vi.fn(), + setSystemPrompt: vi.fn(), + getConversations: vi.fn(), + createConversation: vi.fn(), + getConversation: vi.fn().mockResolvedValue({ id: 'conv-1', title: 'Test', model: 'm' }), + 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()), + onA2UIMessage: vi.fn(() => vi.fn()), + dispatchA2UIAction: vi.fn(), + } as never; +} + +describe('A2UI surface action wiring', () => { + beforeEach(() => { + capturedOnSurfaceAction = undefined; + setupChatApi(); + }); + + describe('AssistantSidebar', () => { + it('routes surface actions through dispatchAssistantAction, not IPC', async () => { + const { AssistantSidebar } = await import( + '../../../src/renderer/components/AssistantSidebar/AssistantSidebar' + ); + + const setSelectedPost = vi.fn(); + const openTab = vi.fn(); + const setActiveView = vi.fn(); + + useAppStore.setState({ + tabs: [], + activeTabId: null, + posts: [], + media: [], + setSelectedPost, + setSelectedMedia: vi.fn(), + openTab, + setActiveView, + toggleSidebar: vi.fn(), + togglePanel: vi.fn(), + toggleAssistantSidebar: vi.fn(), + }); + + render(React.createElement(AssistantSidebar)); + + expect(capturedOnSurfaceAction).toBeDefined(); + + capturedOnSurfaceAction!({ + surfaceId: 'surface-1', + componentId: 'card-1', + action: 'openPost', + payload: { postId: 'post-abc' }, + }); + + expect(setActiveView).toHaveBeenCalledWith('posts'); + expect(setSelectedPost).toHaveBeenCalledWith('post-abc'); + expect(openTab).toHaveBeenCalledWith({ + type: 'post', + id: 'post-abc', + isTransient: false, + }); + + // The IPC no-op handler must NOT be called + expect(window.electronAPI.chat.dispatchA2UIAction).not.toHaveBeenCalled(); + }); + }); + + describe('ChatPanel', () => { + it('routes surface actions through dispatchAssistantAction, not IPC', async () => { + const { ChatPanel } = await import( + '../../../src/renderer/components/ChatPanel/ChatPanel' + ); + + const setSelectedPost = vi.fn(); + const openTab = vi.fn(); + const setActiveView = vi.fn(); + + useAppStore.setState({ + tabs: [], + activeTabId: null, + posts: [], + media: [], + setSelectedPost, + setSelectedMedia: vi.fn(), + openTab, + setActiveView, + toggleSidebar: vi.fn(), + togglePanel: vi.fn(), + toggleAssistantSidebar: vi.fn(), + }); + + render(React.createElement(ChatPanel, { conversationId: 'conv-1' })); + + expect(capturedOnSurfaceAction).toBeDefined(); + + capturedOnSurfaceAction!({ + surfaceId: 'surface-2', + componentId: 'card-2', + action: 'openPost', + payload: { postId: 'post-xyz' }, + }); + + expect(setActiveView).toHaveBeenCalledWith('posts'); + expect(setSelectedPost).toHaveBeenCalledWith('post-xyz'); + expect(openTab).toHaveBeenCalledWith({ + type: 'post', + id: 'post-xyz', + isTransient: false, + }); + + expect(window.electronAPI.chat.dispatchA2UIAction).not.toHaveBeenCalled(); + }); + }); +});