diff --git a/apps/web/src/components/chat/Chat.test.tsx b/apps/web/src/components/chat/Chat.test.tsx index 958888a..50c3eda 100644 --- a/apps/web/src/components/chat/Chat.test.tsx +++ b/apps/web/src/components/chat/Chat.test.tsx @@ -43,6 +43,15 @@ vi.mock("./ChatInput", () => ({ Send ), + DEFAULT_TEMPERATURE: 0.7, + DEFAULT_MAX_TOKENS: 4096, + DEFAULT_MODEL: "llama3.2", + AVAILABLE_MODELS: [ + { id: "llama3.2", label: "Llama 3.2" }, + { id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" }, + { id: "gpt-4o", label: "GPT-4o" }, + { id: "deepseek-r1", label: "DeepSeek R1" }, + ], })); const mockUseAuth = authModule.useAuth as MockedFunction; diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index a8edcbf..f969be1 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -5,7 +5,8 @@ import { useAuth } from "@/lib/auth/auth-context"; import { useChat } from "@/hooks/useChat"; import { useWebSocket } from "@/hooks/useWebSocket"; import { MessageList } from "./MessageList"; -import { ChatInput } from "./ChatInput"; +import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; +import { ChatEmptyState } from "./ChatEmptyState"; import type { Message } from "@/hooks/useChat"; export interface ChatRef { @@ -59,6 +60,14 @@ export const Chat = forwardRef(function Chat( const { user, isLoading: authLoading } = useAuth(); + // Model and params state — initialized from ChatInput's persisted values + const [selectedModel, setSelectedModel] = useState("llama3.2"); + const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE); + const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS); + + // Suggestion fill value: controls ChatInput's textarea content + const [suggestionValue, setSuggestionValue] = useState(undefined); + const { messages, isLoading: isChatLoading, @@ -72,7 +81,9 @@ export const Chat = forwardRef(function Chat( startNewConversation, clearError, } = useChat({ - model: "llama3.2", + model: selectedModel, + temperature, + maxTokens, ...(initialProjectId !== undefined && { projectId: initialProjectId }), }); @@ -88,6 +99,11 @@ export const Chat = forwardRef(function Chat( const streamingMessageId = isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined; + // Whether the conversation is empty (only welcome message or no messages) + const isEmptyConversation = + messages.length === 0 || + (messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming); + useImperativeHandle(ref, () => ({ loadConversation: async (cId: string): Promise => { await loadConversation(cId); @@ -122,16 +138,29 @@ export const Chat = forwardRef(function Chat( useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { + // Cmd/Ctrl + / : Focus input if ((e.ctrlKey || e.metaKey) && e.key === "/") { e.preventDefault(); inputRef.current?.focus(); } + // Cmd/Ctrl + N : Start new conversation + if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) { + e.preventDefault(); + startNewConversation(null); + inputRef.current?.focus(); + } + // Cmd/Ctrl + L : Clear / start new conversation + if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) { + e.preventDefault(); + startNewConversation(null); + inputRef.current?.focus(); + } }; document.addEventListener("keydown", handleKeyDown); return (): void => { document.removeEventListener("keydown", handleKeyDown); }; - }, []); + }, [startNewConversation]); // Show loading quips only during non-streaming load (initial fetch wait) useEffect(() => { @@ -168,6 +197,14 @@ export const Chat = forwardRef(function Chat( [sendMessage] ); + const handleSuggestionClick = useCallback((prompt: string): void => { + setSuggestionValue(prompt); + // Clear after a tick so input receives it, then focus + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }, []); + if (authLoading) { return (
(function Chat( {/* Messages Area */}
- + {isEmptyConversation ? ( + + ) : ( + + )}
@@ -288,6 +329,10 @@ export const Chat = forwardRef(function Chat( inputRef={inputRef} isStreaming={isStreaming} onStopStreaming={abortStream} + onModelChange={setSelectedModel} + onTemperatureChange={setTemperature} + onMaxTokensChange={setMaxTokens} + {...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})} />
diff --git a/apps/web/src/components/chat/ChatEmptyState.test.tsx b/apps/web/src/components/chat/ChatEmptyState.test.tsx new file mode 100644 index 0000000..1ab1fce --- /dev/null +++ b/apps/web/src/components/chat/ChatEmptyState.test.tsx @@ -0,0 +1,103 @@ +/** + * @file ChatEmptyState.test.tsx + * @description Tests for ChatEmptyState component: greeting, suggestions, click handling + */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { ChatEmptyState } from "./ChatEmptyState"; + +describe("ChatEmptyState", () => { + it("should render the greeting heading", () => { + render(); + expect(screen.getByRole("heading", { name: /how can i help/i })).toBeDefined(); + }); + + it("should render the empty state container", () => { + render(); + expect(screen.getByTestId("chat-empty-state")).toBeDefined(); + }); + + it("should render four suggestion buttons", () => { + render(); + // Four suggestions + const buttons = screen.getAllByRole("button"); + expect(buttons.length).toBe(4); + }); + + it("should render 'Explain this project' suggestion", () => { + render(); + expect(screen.getByText("Explain this project")).toBeDefined(); + }); + + it("should render 'Help me debug' suggestion", () => { + render(); + expect(screen.getByText("Help me debug")).toBeDefined(); + }); + + it("should render 'Write a test for' suggestion", () => { + render(); + expect(screen.getByText("Write a test for")).toBeDefined(); + }); + + it("should render 'Refactor this code' suggestion", () => { + render(); + expect(screen.getByText("Refactor this code")).toBeDefined(); + }); + + it("should call onSuggestionClick with the correct prompt when a suggestion is clicked", () => { + const onSuggestionClick = vi.fn(); + render(); + + const explainButton = screen.getByTestId("suggestion-explain-this-project"); + fireEvent.click(explainButton); + + expect(onSuggestionClick).toHaveBeenCalledOnce(); + const [calledWith] = onSuggestionClick.mock.calls[0] as [string]; + expect(calledWith).toContain("overview of this project"); + }); + + it("should call onSuggestionClick for 'Help me debug' prompt", () => { + const onSuggestionClick = vi.fn(); + render(); + + const debugButton = screen.getByTestId("suggestion-help-me-debug"); + fireEvent.click(debugButton); + + expect(onSuggestionClick).toHaveBeenCalledOnce(); + const [calledWith] = onSuggestionClick.mock.calls[0] as [string]; + expect(calledWith).toContain("debugging"); + }); + + it("should call onSuggestionClick for 'Write a test for' prompt", () => { + const onSuggestionClick = vi.fn(); + render(); + + const testButton = screen.getByTestId("suggestion-write-a-test-for"); + fireEvent.click(testButton); + + expect(onSuggestionClick).toHaveBeenCalledOnce(); + }); + + it("should call onSuggestionClick for 'Refactor this code' prompt", () => { + const onSuggestionClick = vi.fn(); + render(); + + const refactorButton = screen.getByTestId("suggestion-refactor-this-code"); + fireEvent.click(refactorButton); + + expect(onSuggestionClick).toHaveBeenCalledOnce(); + const [calledWith] = onSuggestionClick.mock.calls[0] as [string]; + expect(calledWith).toContain("refactor"); + }); + + it("should have accessible aria-label on each suggestion button", () => { + render(); + const buttons = screen.getAllByRole("button"); + for (const button of buttons) { + const label = button.getAttribute("aria-label"); + expect(label).toBeTruthy(); + expect(label).toMatch(/suggestion:/i); + } + }); +}); diff --git a/apps/web/src/components/chat/ChatEmptyState.tsx b/apps/web/src/components/chat/ChatEmptyState.tsx new file mode 100644 index 0000000..db1b005 --- /dev/null +++ b/apps/web/src/components/chat/ChatEmptyState.tsx @@ -0,0 +1,99 @@ +"use client"; + +interface Suggestion { + label: string; + prompt: string; +} + +const SUGGESTIONS: Suggestion[] = [ + { + label: "Explain this project", + prompt: "Can you give me an overview of this project and its key components?", + }, + { + label: "Help me debug", + prompt: "I have a bug I need help debugging. Can you walk me through the process?", + }, + { + label: "Write a test for", + prompt: "Can you help me write a test for the following function or component?", + }, + { + label: "Refactor this code", + prompt: "I have some code I'd like to refactor for better readability and maintainability.", + }, +]; + +interface ChatEmptyStateProps { + onSuggestionClick: (prompt: string) => void; +} + +export function ChatEmptyState({ onSuggestionClick }: ChatEmptyStateProps): React.JSX.Element { + return ( +
+ {/* Icon */} +
+ +
+ + {/* Greeting */} +
+

+ How can I help you today? +

+

+ Ask me anything — I can help with code, explanations, debugging, and more. +

+
+ + {/* Suggestions */} +
+ {SUGGESTIONS.map((suggestion) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/chat/ChatInput.test.tsx b/apps/web/src/components/chat/ChatInput.test.tsx new file mode 100644 index 0000000..a7c3ce6 --- /dev/null +++ b/apps/web/src/components/chat/ChatInput.test.tsx @@ -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(); + 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(); + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText(targetModel.label)).toBeDefined(); + }); + }); + + it("should close the dropdown after selecting a model", async () => { + render(); + + 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(); + 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(); + const settingsBtn = screen.getByLabelText(/chat parameters/i); + expect(settingsBtn).toBeDefined(); + }); + + it("should open the params popover when settings button is clicked", () => { + render(); + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + + 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(); + + const textarea = screen.getByLabelText(/message input/i); + expect((textarea as HTMLTextAreaElement).value).toBe(""); + + rerender(); + + 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(); + + 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(); + + 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(); + expect(screen.getByLabelText(/stop generating/i)).toBeDefined(); + }); + + it("should call onStopStreaming when stop button is clicked", () => { + const onStop = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/stop generating/i)); + expect(onStop).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx index 721f6b5..0c484f2 100644 --- a/apps/web/src/components/chat/ChatInput.tsx +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -1,7 +1,66 @@ "use client"; import type { KeyboardEvent, RefObject } from "react"; -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useState, useEffect, useRef } from "react"; + +export const AVAILABLE_MODELS = [ + { id: "llama3.2", label: "Llama 3.2" }, + { id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" }, + { id: "gpt-4o", label: "GPT-4o" }, + { id: "deepseek-r1", label: "DeepSeek R1" }, +] as const; + +export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"]; + +const STORAGE_KEY_MODEL = "chat:selectedModel"; +const STORAGE_KEY_TEMPERATURE = "chat:temperature"; +const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens"; + +export const DEFAULT_TEMPERATURE = 0.7; +export const DEFAULT_MAX_TOKENS = 4096; +export const DEFAULT_MODEL: ModelId = "llama3.2"; + +function loadStoredModel(): ModelId { + try { + const stored = localStorage.getItem(STORAGE_KEY_MODEL); + if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) { + return stored as ModelId; + } + } catch { + // localStorage not available + } + return DEFAULT_MODEL; +} + +function loadStoredTemperature(): number { + try { + const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE); + if (stored !== null) { + const parsed = parseFloat(stored); + if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) { + return parsed; + } + } + } catch { + // localStorage not available + } + return DEFAULT_TEMPERATURE; +} + +function loadStoredMaxTokens(): number { + try { + const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS); + if (stored !== null) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) { + return parsed; + } + } + } catch { + // localStorage not available + } + return DEFAULT_MAX_TOKENS; +} interface ChatInputProps { onSend: (message: string) => void; @@ -9,6 +68,11 @@ interface ChatInputProps { inputRef?: RefObject; isStreaming?: boolean; onStopStreaming?: () => void; + onModelChange?: (model: ModelId) => void; + onTemperatureChange?: (temperature: number) => void; + onMaxTokensChange?: (maxTokens: number) => void; + onSuggestionFill?: (text: string) => void; + externalValue?: string; } export function ChatInput({ @@ -17,9 +81,52 @@ export function ChatInput({ inputRef, isStreaming = false, onStopStreaming, + onModelChange, + onTemperatureChange, + onMaxTokensChange, + externalValue, }: ChatInputProps): React.JSX.Element { const [message, setMessage] = useState(""); const [version, setVersion] = useState(null); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); + const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE); + const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS); + const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); + const [isParamsOpen, setIsParamsOpen] = useState(false); + + const modelDropdownRef = useRef(null); + const paramsDropdownRef = useRef(null); + + // Stable refs for callbacks so the mount effect stays dependency-free + const onModelChangeRef = useRef(onModelChange); + onModelChangeRef.current = onModelChange; + const onTemperatureChangeRef = useRef(onTemperatureChange); + onTemperatureChangeRef.current = onTemperatureChange; + const onMaxTokensChangeRef = useRef(onMaxTokensChange); + onMaxTokensChangeRef.current = onMaxTokensChange; + + // Load persisted values from localStorage on mount only + useEffect(() => { + const storedModel = loadStoredModel(); + const storedTemperature = loadStoredTemperature(); + const storedMaxTokens = loadStoredMaxTokens(); + + setSelectedModel(storedModel); + setTemperature(storedTemperature); + setMaxTokens(storedMaxTokens); + + // Notify parent of initial values via refs to avoid stale closure + onModelChangeRef.current?.(storedModel); + onTemperatureChangeRef.current?.(storedTemperature); + onMaxTokensChangeRef.current?.(storedMaxTokens); + }, []); + + // Sync external value (e.g. from suggestion clicks) + useEffect(() => { + if (externalValue !== undefined) { + setMessage(externalValue); + } + }, [externalValue]); useEffect(() => { interface VersionData { @@ -40,6 +147,22 @@ export function ChatInput({ }); }, []); + // Close dropdowns on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent): void => { + if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) { + setIsModelDropdownOpen(false); + } + if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) { + setIsParamsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return (): void => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + const handleSubmit = useCallback(() => { if (message.trim() && !disabled && !isStreaming) { onSend(message); @@ -65,6 +188,49 @@ export function ChatInput({ [handleSubmit] ); + const handleModelSelect = useCallback( + (model: ModelId): void => { + setSelectedModel(model); + try { + localStorage.setItem(STORAGE_KEY_MODEL, model); + } catch { + // ignore + } + onModelChange?.(model); + setIsModelDropdownOpen(false); + }, + [onModelChange] + ); + + const handleTemperatureChange = useCallback( + (value: number): void => { + setTemperature(value); + try { + localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString()); + } catch { + // ignore + } + onTemperatureChange?.(value); + }, + [onTemperatureChange] + ); + + const handleMaxTokensChange = useCallback( + (value: number): void => { + setMaxTokens(value); + try { + localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString()); + } catch { + // ignore + } + onMaxTokensChange?.(value); + }, + [onMaxTokensChange] + ); + + const selectedModelLabel = + AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel; + const characterCount = message.length; const maxCharacters = 4000; const isNearLimit = characterCount > maxCharacters * 0.9; @@ -72,7 +238,230 @@ export function ChatInput({ const isInputDisabled = disabled ?? false; return ( -
+
+ {/* Model Selector + Params Row */} +
+ {/* Model Selector */} +
+ + + {/* Model Dropdown */} + {isModelDropdownOpen && ( +
+ {AVAILABLE_MODELS.map((model) => ( + + ))} +
+ )} +
+ + {/* Settings / Params Icon */} +
+ + + {/* Params Popover */} + {isParamsOpen && ( +
+

+ Parameters +

+ + {/* Temperature */} +
+
+ + + {temperature.toFixed(1)} + +
+ { + handleTemperatureChange(parseFloat(e.target.value)); + }} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer" + style={{ + accentColor: "rgb(var(--accent-primary))", + backgroundColor: "rgb(var(--surface-2))", + }} + aria-label={`Temperature: ${temperature.toFixed(1)}`} + /> +
+ Precise + Creative +
+
+ + {/* Max Tokens */} +
+ + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 100 && val <= 32000) { + handleMaxTokensChange(val); + } + }} + className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2" + style={{ + backgroundColor: "rgb(var(--surface-1))", + borderColor: "rgb(var(--border-default))", + color: "rgb(var(--text-primary))", + }} + aria-label="Maximum tokens" + /> +

+ 100 – 32,000 +

+
+
+ )} +
+
+ {/* Input Container */}
{ }); }); + describe("new conversation button", () => { + it("should render the new conversation button when chat is open", async () => { + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: true, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + + render(); + + const newConvBtn = screen.getByRole("button", { name: /new conversation/i }); + expect(newConvBtn).toBeDefined(); + }); + + it("should have a tooltip on the new conversation button", async () => { + const { useChatOverlay } = await import("../../hooks/useChatOverlay"); + vi.mocked(useChatOverlay).mockReturnValue({ + isOpen: true, + isMinimized: false, + open: mockOpen, + close: mockClose, + minimize: mockMinimize, + expand: mockExpand, + toggle: mockToggle, + toggleMinimize: mockToggleMinimize, + }); + + render(); + + const newConvBtn = screen.getByRole("button", { name: /new conversation/i }); + expect(newConvBtn.getAttribute("title")).toContain("Cmd+N"); + }); + }); + describe("responsive design", () => { it("should render as a sidebar on desktop", () => { render(); diff --git a/apps/web/src/components/chat/ChatOverlay.tsx b/apps/web/src/components/chat/ChatOverlay.tsx index d9dbac3..cd7cdaa 100644 --- a/apps/web/src/components/chat/ChatOverlay.tsx +++ b/apps/web/src/components/chat/ChatOverlay.tsx @@ -164,6 +164,27 @@ export function ChatOverlay(): React.JSX.Element { {/* Header Controls */}
+ {/* New Conversation Button */} + + {/* Minimize Button */}