fix: a2ui actions were not wired up at all

This commit is contained in:
2026-02-26 19:19:31 +01:00
parent 5152a4429f
commit daf8addb53
4 changed files with 191 additions and 12 deletions

View File

@@ -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<string>;
/** 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,

View File

@@ -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}
/>

View File

@@ -64,7 +64,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
latestSurfaceId,
dismissedSurfaceIds,
dismissSurface,
dispatchAction,
updateLocalData,
replayFromMessages,
} = useA2UISurface({ conversationId });
@@ -387,7 +386,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
latestSurfaceId={latestSurfaceId}
dismissedSurfaceIds={dismissedSurfaceIds}
onSurfaceDismiss={dismissSurface}
onSurfaceAction={dispatchAction}
onSurfaceAction={(a) => handleAssistantAction(a.action, a.payload)}
onSurfaceDataChange={updateLocalData}
currentTurnIndex={currentTurnIndex}
/>

View File

@@ -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();
});
});
});