From cbce70a50ad6c3172f5d5fe301fdfd6477a4b26d Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 16:47:12 +0000 Subject: [PATCH] feat(21-04): create ChatPanelContext, ChatInput, and ChatMessage components - ChatPanelContext with localStorage persistence (nexus:chat-panel-open) - ChatInput with Enter/Shift+Enter/Escape keyboard shortcuts and auto-resize - ChatMessage renders user bubbles (bg-secondary) and assistant markdown via ChatMarkdownMessage - ChatInput.test.tsx with 6 passing tests (keyboard shortcuts, max-height, submit state) --- ui/src/components/ChatInput.test.tsx | 163 +++++++++++++++++++++++++-- ui/src/components/ChatInput.tsx | 92 +++++++++++++++ ui/src/components/ChatMessage.tsx | 29 +++++ ui/src/context/ChatPanelContext.tsx | 62 ++++++++++ 4 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/ChatInput.tsx create mode 100644 ui/src/components/ChatMessage.tsx create mode 100644 ui/src/context/ChatPanelContext.tsx diff --git a/ui/src/components/ChatInput.test.tsx b/ui/src/components/ChatInput.test.tsx index 966897eb..dde7c957 100644 --- a/ui/src/components/ChatInput.test.tsx +++ b/ui/src/components/ChatInput.test.tsx @@ -1,19 +1,166 @@ -// @vitest-environment node -import { describe, it } from "vitest"; +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatInput } from "./ChatInput"; + +// Tell React this environment uses act() for event flushing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; describe("ChatInput", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) { + container.remove(); + } + }); + + function renderChatInput(props: { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean }) { + root = createRoot(container); + act(() => { + root!.render(); + }); + return { + getTextarea: () => container.querySelector("textarea")!, + getSendButton: () => container.querySelector("button[aria-label='Send message']") as HTMLButtonElement | null, + }; + } + describe("keyboard shortcuts (INPUT-07)", () => { - it.todo("calls onSend when Enter is pressed without Shift"); - it.todo("inserts newline when Shift+Enter is pressed"); - it.todo("clears input when Escape is pressed"); - it.todo("does not send when input is empty"); + it("calls onSend when Enter is pressed without Shift", () => { + const onSend = vi.fn(); + const { getTextarea } = renderChatInput({ onSend }); + const textarea = getTextarea(); + + act(() => { + // Set value + Object.defineProperty(textarea, "value", { + writable: true, + value: "Hello world", + }); + const inputEvent = new Event("input", { bubbles: true }); + textarea.dispatchEvent(inputEvent); + }); + + act(() => { + const keydown = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + shiftKey: false, + }); + textarea.dispatchEvent(keydown); + }); + + expect(onSend).toHaveBeenCalledWith("Hello world"); + }); + + it("inserts newline when Shift+Enter is pressed", () => { + const onSend = vi.fn(); + const { getTextarea } = renderChatInput({ onSend }); + const textarea = getTextarea(); + + act(() => { + Object.defineProperty(textarea, "value", { + writable: true, + value: "Hello", + }); + const inputEvent = new Event("input", { bubbles: true }); + textarea.dispatchEvent(inputEvent); + }); + + act(() => { + const keydown = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + shiftKey: true, + }); + textarea.dispatchEvent(keydown); + }); + + // onSend should NOT have been called + expect(onSend).not.toHaveBeenCalled(); + }); + + it("clears input when Escape is pressed", () => { + const onSend = vi.fn(); + const { getTextarea } = renderChatInput({ onSend }); + const textarea = getTextarea(); + + act(() => { + Object.defineProperty(textarea, "value", { + writable: true, + value: "Some text", + }); + const inputEvent = new Event("input", { bubbles: true }); + textarea.dispatchEvent(inputEvent); + }); + + act(() => { + const keydown = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(keydown); + }); + + expect(textarea.value).toBe(""); + }); + + it("does not send when input is empty", () => { + const onSend = vi.fn(); + const { getTextarea } = renderChatInput({ onSend }); + const textarea = getTextarea(); + + act(() => { + const keydown = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + shiftKey: false, + }); + textarea.dispatchEvent(keydown); + }); + + expect(onSend).not.toHaveBeenCalled(); + }); }); describe("auto-resize (INPUT-01)", () => { - it.todo("textarea has max-height constraint"); + it("textarea has max-height constraint", () => { + const onSend = vi.fn(); + const { getTextarea } = renderChatInput({ onSend }); + const textarea = getTextarea(); + // The max-h-[160px] class should be on the textarea + expect(textarea.className).toContain("max-h-[160px]"); + }); }); describe("submit state", () => { - it.todo("disables send button when isSubmitting is true"); + it("disables send button when isSubmitting is true", () => { + const onSend = vi.fn(); + const { getSendButton } = renderChatInput({ onSend, isSubmitting: true }); + const btn = getSendButton(); + expect(btn).not.toBeNull(); + expect(btn!.disabled).toBe(true); + }); }); }); diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx new file mode 100644 index 00000000..e0a13314 --- /dev/null +++ b/ui/src/components/ChatInput.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; +import { Send, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "../lib/utils"; + +interface ChatInputProps { + onSend: (content: string) => void; + isSubmitting?: boolean; + disabled?: boolean; +} + +export function ChatInput({ onSend, isSubmitting = false, disabled = false }: ChatInputProps) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + // Auto-resize fallback for browsers without field-sizing: content support + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [value]); + + function submit() { + const trimmed = value.trim(); + if (!trimmed || isSubmitting || disabled) return; + onSend(trimmed); + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + submit(); + } else if (e.key === "Escape") { + e.preventDefault(); + setValue(""); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + } + // Shift+Enter falls through to default behavior (inserts newline) + } + + const isEmpty = value.trim().length === 0; + + return ( +
{ + e.preventDefault(); + submit(); + }} + className="flex items-end gap-2" + > +