Skip to content

Commit 199f5fb

Browse files
authored
feat(web-ui): AgentChatPanel component — messages, tool calls, thinking blocks, input bar (#505)
## Summary - New `AgentChatPanel` component for interactive agent chat sessions - Renders all 7 message roles: user, assistant, tool_use, tool_result, thinking, system, error - Tool use cards collapsible (collapsed by default); tool result cards truncate + expand - Streaming cursor indicator on last assistant message during live streaming - Auto-scroll tracks both new messages and in-place content updates (streaming deltas) - Input bar: Enter sends, Shift+Enter newline, auto-grow textarea, disabled while busy - Interrupt button shown only during thinking/streaming status - Header: live cost counter, model badge, status dot (green/yellow/red) - Empty state with AI icon + prompt text - Full ARIA accessibility: role=log, aria-live, aria-expanded, aria-labels throughout - 31 unit tests covering all acceptance criteria ## Validation - Review feedback: CodeRabbit Major issue fixed (auto-scroll missed in-place streaming updates) + regression test added - Demo: All 10 acceptance criteria verified via conducting-demo skill - Tests: 31/31 passing - CI: All checks green (Backend Unit Tests, Code Quality, claude-review, CodeRabbit, GitGuardian) - Linting: Clean Closes #505
1 parent 1cdf4d3 commit 199f5fb

5 files changed

Lines changed: 664 additions & 0 deletions

File tree

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,7 @@ module.exports = {
5555
ArrowDown01Icon: createIconMock('ArrowDown01Icon'),
5656
ArrowUp01Icon: createIconMock('ArrowUp01Icon'),
5757
StopIcon: createIconMock('StopIcon'),
58+
// AgentChatPanel
59+
ArrowRight01Icon: createIconMock('ArrowRight01Icon'),
60+
Alert01Icon: createIconMock('Alert01Icon'),
5861
};

web-ui/jest.setup.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import '@testing-library/jest-dom';
22

3+
// jsdom does not implement scrollIntoView
4+
window.HTMLElement.prototype.scrollIntoView = jest.fn();
5+
36
// Mock next/navigation
47
jest.mock('next/navigation', () => ({
58
useRouter() {
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { AgentChatPanel } from '@/components/sessions/AgentChatPanel';
4+
import { useAgentChat } from '@/hooks/useAgentChat';
5+
import type { AgentChatState, ChatMessage } from '@/types';
6+
7+
jest.mock('@/hooks/useAgentChat');
8+
9+
const mockUseAgentChat = useAgentChat as jest.MockedFunction<typeof useAgentChat>;
10+
11+
// ── Helpers ─────────────────────────────────────────────────────────────
12+
13+
function makeMessage(overrides: Partial<ChatMessage>): ChatMessage {
14+
return {
15+
id: Math.random().toString(36).slice(2),
16+
role: 'assistant',
17+
content: 'Hello',
18+
createdAt: new Date().toISOString(),
19+
...overrides,
20+
};
21+
}
22+
23+
function makeState(overrides: Partial<AgentChatState> = {}): AgentChatState {
24+
return {
25+
messages: [],
26+
status: 'idle',
27+
costUsd: 0,
28+
inputTokens: 0,
29+
outputTokens: 0,
30+
error: null,
31+
connected: true,
32+
...overrides,
33+
};
34+
}
35+
36+
const mockSendMessage = jest.fn();
37+
const mockInterrupt = jest.fn();
38+
const mockClearMessages = jest.fn();
39+
40+
function setupMock(state: AgentChatState) {
41+
mockUseAgentChat.mockReturnValue({
42+
state,
43+
sendMessage: mockSendMessage,
44+
interrupt: mockInterrupt,
45+
clearMessages: mockClearMessages,
46+
});
47+
}
48+
49+
// ── Tests ────────────────────────────────────────────────────────────────
50+
51+
describe('AgentChatPanel', () => {
52+
beforeEach(() => {
53+
jest.clearAllMocks();
54+
});
55+
56+
// ── Empty state ──────────────────────────────────────────────────────
57+
58+
it('shows empty state when there are no messages', () => {
59+
setupMock(makeState());
60+
render(<AgentChatPanel sessionId="sess-1" />);
61+
expect(screen.getByText('Start a conversation with your agent')).toBeInTheDocument();
62+
});
63+
64+
it('does not show empty state when messages exist', () => {
65+
setupMock(makeState({ messages: [makeMessage({ role: 'user', content: 'Hi' })] }));
66+
render(<AgentChatPanel sessionId="sess-1" />);
67+
expect(screen.queryByText('Start a conversation with your agent')).not.toBeInTheDocument();
68+
expect(screen.getByText('Hi')).toBeInTheDocument();
69+
});
70+
71+
// ── All 7 message roles ──────────────────────────────────────────────
72+
73+
it('renders user message with correct role styling', () => {
74+
setupMock(makeState({ messages: [makeMessage({ role: 'user', content: 'User msg' })] }));
75+
render(<AgentChatPanel sessionId="sess-1" />);
76+
expect(screen.getByText('User msg')).toBeInTheDocument();
77+
});
78+
79+
it('renders assistant message', () => {
80+
setupMock(makeState({ messages: [makeMessage({ role: 'assistant', content: 'Assistant msg' })] }));
81+
render(<AgentChatPanel sessionId="sess-1" />);
82+
expect(screen.getByText('Assistant msg')).toBeInTheDocument();
83+
});
84+
85+
it('renders tool_use card collapsed by default with tool name', () => {
86+
setupMock(makeState({
87+
messages: [makeMessage({
88+
role: 'tool_use',
89+
content: '',
90+
toolName: 'read_file',
91+
toolInput: { path: 'src/index.ts' },
92+
})],
93+
}));
94+
render(<AgentChatPanel sessionId="sess-1" />);
95+
expect(screen.getByText(/read_file/)).toBeInTheDocument();
96+
// JSON body should not be visible when collapsed
97+
expect(screen.queryByText(/"path"/)).not.toBeInTheDocument();
98+
});
99+
100+
it('expands tool_use card when clicked', () => {
101+
setupMock(makeState({
102+
messages: [makeMessage({
103+
role: 'tool_use',
104+
content: '',
105+
toolName: 'read_file',
106+
toolInput: { path: 'src/index.ts' },
107+
})],
108+
}));
109+
render(<AgentChatPanel sessionId="sess-1" />);
110+
const toggle = screen.getByRole('button', { name: /expand/i });
111+
fireEvent.click(toggle);
112+
expect(screen.getByText(/"path"/)).toBeInTheDocument();
113+
});
114+
115+
it('renders tool_result with first 200 chars visible', () => {
116+
const longContent = 'x'.repeat(300);
117+
setupMock(makeState({
118+
messages: [makeMessage({ role: 'tool_result', content: longContent })],
119+
}));
120+
render(<AgentChatPanel sessionId="sess-1" />);
121+
expect(screen.getByText(/^x{200}$/)).toBeInTheDocument();
122+
expect(screen.getByRole('button', { name: /show more/i })).toBeInTheDocument();
123+
});
124+
125+
it('expands tool_result when Show more clicked', () => {
126+
const longContent = 'y'.repeat(300);
127+
setupMock(makeState({
128+
messages: [makeMessage({ role: 'tool_result', content: longContent })],
129+
}));
130+
render(<AgentChatPanel sessionId="sess-1" />);
131+
fireEvent.click(screen.getByRole('button', { name: /show more/i }));
132+
expect(screen.getByText(new RegExp(`y{300}`))).toBeInTheDocument();
133+
expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument();
134+
});
135+
136+
it('renders thinking block with content', () => {
137+
setupMock(makeState({
138+
messages: [makeMessage({ role: 'thinking', content: 'I need to check this' })],
139+
}));
140+
render(<AgentChatPanel sessionId="sess-1" />);
141+
expect(screen.getByText('I need to check this')).toBeInTheDocument();
142+
});
143+
144+
it('renders system message', () => {
145+
setupMock(makeState({
146+
messages: [makeMessage({ role: 'system', content: 'Session started' })],
147+
}));
148+
render(<AgentChatPanel sessionId="sess-1" />);
149+
expect(screen.getByText('Session started')).toBeInTheDocument();
150+
});
151+
152+
it('renders error card', () => {
153+
setupMock(makeState({
154+
messages: [makeMessage({ role: 'error', content: 'Something went wrong' })],
155+
}));
156+
render(<AgentChatPanel sessionId="sess-1" />);
157+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
158+
});
159+
160+
// ── Streaming cursor ─────────────────────────────────────────────────
161+
162+
it('shows streaming cursor on last assistant message when status is streaming', () => {
163+
const msgs = [makeMessage({ role: 'assistant', content: 'Typing...' })];
164+
setupMock(makeState({ messages: msgs, status: 'streaming' }));
165+
render(<AgentChatPanel sessionId="sess-1" />);
166+
expect(screen.getByTestId('streaming-cursor')).toBeInTheDocument();
167+
});
168+
169+
it('does not show streaming cursor when status is idle', () => {
170+
const msgs = [makeMessage({ role: 'assistant', content: 'Done' })];
171+
setupMock(makeState({ messages: msgs, status: 'idle' }));
172+
render(<AgentChatPanel sessionId="sess-1" />);
173+
expect(screen.queryByTestId('streaming-cursor')).not.toBeInTheDocument();
174+
});
175+
176+
it('scrollIntoView called when last message content updates (streaming in-place)', () => {
177+
const msg = makeMessage({ role: 'assistant', content: 'Hello' });
178+
const { rerender } = render(
179+
<AgentChatPanel sessionId="sess-1" />
180+
);
181+
// Initial render with one message
182+
setupMock(makeState({ messages: [msg], status: 'streaming' }));
183+
rerender(<AgentChatPanel sessionId="sess-1" />);
184+
185+
// Simulate in-place content update (same id, different content)
186+
const updatedMsg = { ...msg, content: 'Hello world' };
187+
setupMock(makeState({ messages: [updatedMsg], status: 'streaming' }));
188+
rerender(<AgentChatPanel sessionId="sess-1" />);
189+
190+
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
191+
});
192+
193+
// ── Input bar ────────────────────────────────────────────────────────
194+
195+
it('input textarea is enabled when status is idle', () => {
196+
setupMock(makeState({ status: 'idle' }));
197+
render(<AgentChatPanel sessionId="sess-1" />);
198+
expect(screen.getByRole('textbox', { name: /message/i })).not.toBeDisabled();
199+
});
200+
201+
it('input textarea is disabled while thinking', () => {
202+
setupMock(makeState({ status: 'thinking' }));
203+
render(<AgentChatPanel sessionId="sess-1" />);
204+
expect(screen.getByRole('textbox', { name: /message/i })).toBeDisabled();
205+
});
206+
207+
it('input textarea is disabled while streaming', () => {
208+
setupMock(makeState({ status: 'streaming' }));
209+
render(<AgentChatPanel sessionId="sess-1" />);
210+
expect(screen.getByRole('textbox', { name: /message/i })).toBeDisabled();
211+
});
212+
213+
it('calls sendMessage and clears input on Enter', async () => {
214+
setupMock(makeState({ status: 'idle' }));
215+
render(<AgentChatPanel sessionId="sess-1" />);
216+
const textarea = screen.getByRole('textbox', { name: /message/i });
217+
await userEvent.type(textarea, 'Hello agent');
218+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
219+
expect(mockSendMessage).toHaveBeenCalledWith('Hello agent');
220+
expect(textarea).toHaveValue('');
221+
});
222+
223+
it('does not send on Shift+Enter', async () => {
224+
setupMock(makeState({ status: 'idle' }));
225+
render(<AgentChatPanel sessionId="sess-1" />);
226+
const textarea = screen.getByRole('textbox', { name: /message/i });
227+
await userEvent.type(textarea, 'Hello');
228+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
229+
expect(mockSendMessage).not.toHaveBeenCalled();
230+
});
231+
232+
it('calls sendMessage when Send button clicked', async () => {
233+
setupMock(makeState({ status: 'idle' }));
234+
render(<AgentChatPanel sessionId="sess-1" />);
235+
const textarea = screen.getByRole('textbox', { name: /message/i });
236+
await userEvent.type(textarea, 'Send this');
237+
fireEvent.click(screen.getByRole('button', { name: /send message/i }));
238+
expect(mockSendMessage).toHaveBeenCalledWith('Send this');
239+
});
240+
241+
it('does not call sendMessage when input is empty', () => {
242+
setupMock(makeState({ status: 'idle' }));
243+
render(<AgentChatPanel sessionId="sess-1" />);
244+
fireEvent.click(screen.getByRole('button', { name: /send message/i }));
245+
expect(mockSendMessage).not.toHaveBeenCalled();
246+
});
247+
248+
// ── Interrupt button ─────────────────────────────────────────────────
249+
250+
it('shows interrupt button during thinking', () => {
251+
setupMock(makeState({ status: 'thinking' }));
252+
render(<AgentChatPanel sessionId="sess-1" />);
253+
expect(screen.getByRole('button', { name: /interrupt agent/i })).toBeInTheDocument();
254+
});
255+
256+
it('shows interrupt button during streaming', () => {
257+
setupMock(makeState({ status: 'streaming' }));
258+
render(<AgentChatPanel sessionId="sess-1" />);
259+
expect(screen.getByRole('button', { name: /interrupt agent/i })).toBeInTheDocument();
260+
});
261+
262+
it('hides interrupt button when idle', () => {
263+
setupMock(makeState({ status: 'idle' }));
264+
render(<AgentChatPanel sessionId="sess-1" />);
265+
expect(screen.queryByRole('button', { name: /interrupt agent/i })).not.toBeInTheDocument();
266+
});
267+
268+
it('calls interrupt when interrupt button clicked', () => {
269+
setupMock(makeState({ status: 'thinking' }));
270+
render(<AgentChatPanel sessionId="sess-1" />);
271+
fireEvent.click(screen.getByRole('button', { name: /interrupt agent/i }));
272+
expect(mockInterrupt).toHaveBeenCalled();
273+
});
274+
275+
// ── Header ───────────────────────────────────────────────────────────
276+
277+
it('shows cost in header', () => {
278+
setupMock(makeState({ costUsd: 0.0031 }));
279+
render(<AgentChatPanel sessionId="sess-1" />);
280+
expect(screen.getByText('$0.0031')).toBeInTheDocument();
281+
});
282+
283+
it('shows green status dot when connected and idle', () => {
284+
setupMock(makeState({ status: 'idle', connected: true }));
285+
render(<AgentChatPanel sessionId="sess-1" />);
286+
const dot = screen.getByRole('status', { hidden: true });
287+
expect(dot).toHaveClass('bg-green-500');
288+
});
289+
290+
it('shows yellow status dot when connecting', () => {
291+
setupMock(makeState({ status: 'connecting', connected: false }));
292+
render(<AgentChatPanel sessionId="sess-1" />);
293+
const dot = screen.getByRole('status', { hidden: true });
294+
expect(dot).toHaveClass('bg-yellow-400');
295+
});
296+
297+
it('shows red status dot when disconnected', () => {
298+
setupMock(makeState({ status: 'disconnected', connected: false }));
299+
render(<AgentChatPanel sessionId="sess-1" />);
300+
const dot = screen.getByRole('status', { hidden: true });
301+
expect(dot).toHaveClass('bg-red-500');
302+
});
303+
304+
// ── Accessibility ────────────────────────────────────────────────────
305+
306+
it('message log has role=log and aria-live=polite', () => {
307+
setupMock(makeState());
308+
render(<AgentChatPanel sessionId="sess-1" />);
309+
const log = screen.getByRole('log');
310+
expect(log).toHaveAttribute('aria-live', 'polite');
311+
});
312+
313+
it('tool_use toggle button has aria-expanded', () => {
314+
setupMock(makeState({
315+
messages: [makeMessage({ role: 'tool_use', content: '', toolName: 'read_file', toolInput: {} })],
316+
}));
317+
render(<AgentChatPanel sessionId="sess-1" />);
318+
const toggle = screen.getByRole('button', { name: /expand/i });
319+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
320+
fireEvent.click(toggle);
321+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
322+
});
323+
});

0 commit comments

Comments
 (0)