feat(web): polish master chat with model selector, params config, and empty state (#519)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #519.
This commit is contained in:
2026-02-26 03:17:23 +00:00
committed by jason.woltje
parent 417c6ab49c
commit 13aa52aa53
9 changed files with 1020 additions and 13 deletions

View File

@@ -0,0 +1,293 @@
/**
* @file ChatInput.test.tsx
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence
*/
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
ChatInput,
AVAILABLE_MODELS,
DEFAULT_MODEL,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
} from "./ChatInput";
// Mock fetch for version.json
beforeEach(() => {
global.fetch = vi.fn().mockRejectedValue(new Error("Not found"));
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
/** Get the first non-default model from the list */
function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] {
const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL);
if (!model) throw new Error("No non-default model found");
return model;
}
describe("ChatInput — model selector", () => {
it("should render the model selector chip showing the default model", () => {
render(<ChatInput onSend={vi.fn()} />);
const defaultLabel =
AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL;
expect(screen.getByText(defaultLabel)).toBeDefined();
});
it("should open the model dropdown when the chip is clicked", () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
// The dropdown (listbox role) should be visible
const listbox = screen.getByRole("listbox", { name: /available models/i });
expect(listbox).toBeDefined();
// All model options should appear in the dropdown
const options = within(listbox).getAllByRole("option");
expect(options.length).toBe(AVAILABLE_MODELS.length);
});
it("should call onModelChange when a model is selected", async () => {
const onModelChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onModelChange={onModelChange} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
await waitFor(() => {
const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(targetModel.id);
});
});
it("should persist the selected model in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
await waitFor(() => {
expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id);
});
});
it("should restore the model from localStorage on mount", async () => {
const targetModel = getNonDefaultModel();
localStorage.setItem("chat:selectedModel", targetModel.id);
render(<ChatInput onSend={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(targetModel.label)).toBeDefined();
});
});
it("should close the dropdown after selecting a model", async () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
fireEvent.click(chip);
const targetModel = getNonDefaultModel();
const listbox = screen.getByRole("listbox", { name: /available models/i });
const targetOption = within(listbox).getByText(targetModel.label);
fireEvent.click(targetOption);
// After selection, dropdown should close
await waitFor(() => {
expect(screen.queryByRole("listbox")).toBeNull();
});
});
it("should have aria-expanded on the model chip button", () => {
render(<ChatInput onSend={vi.fn()} />);
const chip = screen.getByLabelText(/model:/i);
expect(chip.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(chip);
expect(chip.getAttribute("aria-expanded")).toBe("true");
});
});
describe("ChatInput — temperature and max tokens", () => {
it("should render the settings/params button", () => {
render(<ChatInput onSend={vi.fn()} />);
const settingsBtn = screen.getByLabelText(/chat parameters/i);
expect(settingsBtn).toBeDefined();
});
it("should open the params popover when settings button is clicked", () => {
render(<ChatInput onSend={vi.fn()} />);
const settingsBtn = screen.getByLabelText(/chat parameters/i);
fireEvent.click(settingsBtn);
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined();
});
it("should show the default temperature value", () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE);
});
it("should call onTemperatureChange when the slider is moved", async () => {
const onTemperatureChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
fireEvent.change(slider, { target: { value: "1.2" } });
await waitFor(() => {
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(1.2);
});
});
it("should persist temperature in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const slider = screen.getByLabelText(/temperature/i);
fireEvent.change(slider, { target: { value: "0.5" } });
await waitFor(() => {
expect(localStorage.getItem("chat:temperature")).toBe("0.5");
});
});
it("should restore temperature from localStorage on mount", async () => {
localStorage.setItem("chat:temperature", "1.5");
const onTemperatureChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
await waitFor(() => {
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(1.5);
});
});
it("should show the default max tokens value", () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS);
});
it("should call onMaxTokensChange when the max tokens input changes", async () => {
const onMaxTokensChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
fireEvent.change(input, { target: { value: "8192" } });
await waitFor(() => {
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(8192);
});
});
it("should persist max tokens in localStorage", async () => {
render(<ChatInput onSend={vi.fn()} />);
fireEvent.click(screen.getByLabelText(/chat parameters/i));
const input = screen.getByLabelText(/maximum tokens/i);
fireEvent.change(input, { target: { value: "2000" } });
await waitFor(() => {
expect(localStorage.getItem("chat:maxTokens")).toBe("2000");
});
});
it("should restore max tokens from localStorage on mount", async () => {
localStorage.setItem("chat:maxTokens", "8000");
const onMaxTokensChange = vi.fn();
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
await waitFor(() => {
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
expect(calls).toContain(8000);
});
});
});
describe("ChatInput — externalValue (suggestion fill)", () => {
it("should update the textarea when externalValue is provided", async () => {
const { rerender } = render(<ChatInput onSend={vi.fn()} />);
const textarea = screen.getByLabelText(/message input/i);
expect((textarea as HTMLTextAreaElement).value).toBe("");
rerender(<ChatInput onSend={vi.fn()} externalValue="Hello suggestion" />);
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion");
});
});
});
describe("ChatInput — send behavior", () => {
it("should call onSend with the message when the send button is clicked", async () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "Hello world" } });
const sendButton = screen.getByLabelText(/send message/i);
fireEvent.click(sendButton);
await waitFor(() => {
expect(onSend).toHaveBeenCalledWith("Hello world");
});
});
it("should clear the textarea after sending", async () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
const textarea = screen.getByLabelText(/message input/i);
fireEvent.change(textarea, { target: { value: "Hello world" } });
fireEvent.click(screen.getByLabelText(/send message/i));
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
});
it("should show the stop button when streaming", () => {
render(<ChatInput onSend={vi.fn()} isStreaming={true} />);
expect(screen.getByLabelText(/stop generating/i)).toBeDefined();
});
it("should call onStopStreaming when stop button is clicked", () => {
const onStop = vi.fn();
render(<ChatInput onSend={vi.fn()} isStreaming={true} onStopStreaming={onStop} />);
fireEvent.click(screen.getByLabelText(/stop generating/i));
expect(onStop).toHaveBeenCalledOnce();
});
});