Story 43: Unified chat UI for Claude Code and regular chat
Integrate Claude Code provider into the chat UI alongside regular Ollama/Anthropic providers. Updates AgentPanel and Chat components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,23 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
import { workflowApi } from "../api/workflow";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
// Module-level store for the WebSocket handlers captured during connect().
|
||||
// Tests in the "message rendering" suite use this to simulate incoming messages.
|
||||
type WsHandlers = {
|
||||
onToken: (content: string) => void;
|
||||
onUpdate: (history: Message[]) => void;
|
||||
onSessionId: (sessionId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
let capturedWsHandlers: WsHandlers | null = null;
|
||||
|
||||
vi.mock("../api/client", () => {
|
||||
const api = {
|
||||
getOllamaModels: vi.fn(),
|
||||
@@ -18,7 +29,9 @@ vi.mock("../api/client", () => {
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect() {}
|
||||
connect(handlers: WsHandlers) {
|
||||
capturedWsHandlers = handlers;
|
||||
}
|
||||
close() {}
|
||||
sendChat() {}
|
||||
cancel() {}
|
||||
@@ -609,3 +622,151 @@ describe("Chat review panel", () => {
|
||||
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat message rendering — unified tool call UI", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
});
|
||||
|
||||
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Read src/main.rs" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I'll read that file.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "toolu_abc",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "Read",
|
||||
arguments: '{"file_path":"src/main.rs"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
act(() => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
|
||||
// Tool call badge should appear showing the function name
|
||||
expect(await screen.findByText(/Read/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders collapsible tool output for tool role messages (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Check the file" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "toolu_1",
|
||||
type: "function",
|
||||
function: { name: "Read", arguments: '{"file_path":"foo.rs"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: 'fn main() { println!("hello"); }',
|
||||
tool_call_id: "toolu_1",
|
||||
},
|
||||
{ role: "assistant", content: "The file contains a main function." },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
// Tool output section should be collapsible
|
||||
expect(await screen.findByText(/Tool Output/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("The file contains a main function."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plain assistant message without tool call badges (AC5)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there! How can I help?" },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Hi there! How can I help?"),
|
||||
).toBeInTheDocument();
|
||||
// No tool call badges should appear
|
||||
expect(screen.queryByText(/Tool Output/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders multiple tool calls in a single assistant turn (AC3)", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Do some work" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I'll do multiple things.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "id1",
|
||||
type: "function",
|
||||
function: { name: "Bash", arguments: '{"command":"cargo test"}' },
|
||||
},
|
||||
{
|
||||
id: "id2",
|
||||
type: "function",
|
||||
function: { name: "Read", arguments: '{"file_path":"Cargo.toml"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
act(() => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Bash/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user