From b1669d229d7105e20bf27e89320ec7e627107bba Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:12:08 +0800 Subject: [PATCH] feat(miner-ui): add the chat rail's message composer The chat rail needs a message input and there wasn't one to reuse: the #6244 audit found no submit-on-Enter composer anywhere, and the ui-kit's Textarea/Button are deliberately behavior-less primitives with no submit/auto-grow logic of their own. Add ChatComposer, built from those primitives, with the behavior living app-side. Enter submits the trimmed draft and clears; Shift+Enter inserts a newline and doesn't. Clicking Send goes through the same path, so the two can never disagree about what counts as empty -- whitespace-only is trimmed first and blocked exactly like "". A modifier chord (Ctrl/Cmd/Alt+Enter) doesn't submit either: those are submit chords in plenty of chat UIs, and treating one as a bare Enter would send a half-written message. The textarea auto-grows to fit typed or pasted content up to a cap, then stops and scrolls internally. Height is re-measured on every value change rather than on keystrokes, so a paste and the post-submit clear settle too, and the box is collapsed before measuring -- scrollHeight can't shrink back while the element still holds the taller height from the previous change, so deleting a line would otherwise never shrink it. useLayoutEffect, so the grown box paints in the same frame as the text instead of the caret outrunning it. Ships unwired and self-contained per the issue: it owns its own draft state, takes only onSubmit plus presentational props, and never calls an API, MCP tool, or action endpoint. No file under packages/loopover-ui-kit/** or apps/loopover-miner-ui/src/routes/** is touched. Tests are fixture-driven off a mock onSubmit and cover both sides of every branch: Enter vs Shift+Enter, non-empty vs whitespace-only through both the keyboard and the button, clear-after-submit, and auto-grow below vs at the cap. The auto-grow tests stub scrollHeight, since jsdom computes no layout and would otherwise report 0 for every box. Closes #6514 --- .../src/chat-composer.test.tsx | 130 ++++++++++++++++++ .../src/components/chat-composer.tsx | 81 +++++++++++ 2 files changed, 211 insertions(+) create mode 100644 apps/loopover-miner-ui/src/chat-composer.test.tsx create mode 100644 apps/loopover-miner-ui/src/components/chat-composer.tsx diff --git a/apps/loopover-miner-ui/src/chat-composer.test.tsx b/apps/loopover-miner-ui/src/chat-composer.test.tsx new file mode 100644 index 0000000000..863ed2d5e3 --- /dev/null +++ b/apps/loopover-miner-ui/src/chat-composer.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ChatComposer } from "./components/chat-composer"; + +/** The cap the component enforces (chat-composer.tsx's MAX_COMPOSER_HEIGHT_PX). */ +const MAX_HEIGHT_PX = 160; + +function setup(props: Partial[0]> = {}) { + const onSubmit = vi.fn(); + render(); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + return { onSubmit, textarea, button: screen.getByRole("button", { name: /send/i }) }; +} + +/** jsdom computes no layout, so a rendered textarea's scrollHeight is always 0. Stub it, then fire the input + * event the component measures on -- asserting a real pixel height here would be asserting nothing. */ +function typeWithScrollHeight(textarea: HTMLTextAreaElement, value: string, scrollHeight: number) { + Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: scrollHeight }); + fireEvent.change(textarea, { target: { value } }); +} + +describe("ChatComposer submit paths (#6514)", () => { + it("Enter with no modifier submits the trimmed message and clears the box", () => { + const { onSubmit, textarea } = setup(); + fireEvent.change(textarea, { target: { value: " how is the queue? " } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + expect(onSubmit).toHaveBeenCalledWith("how is the queue?"); + expect(textarea.value).toBe(""); + }); + + it("Shift+Enter inserts a newline and does NOT submit", () => { + const { onSubmit, textarea } = setup(); + fireEvent.change(textarea, { target: { value: "first line" } }); + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }); + expect(onSubmit).not.toHaveBeenCalled(); + // The default isn't prevented, so the browser's own newline insertion still happens. + expect(textarea.value).toBe("first line"); + }); + + it("clicking Send submits identically to Enter", () => { + const { onSubmit, textarea, button } = setup(); + fireEvent.change(textarea, { target: { value: " release the queue " } }); + fireEvent.click(button); + expect(onSubmit).toHaveBeenCalledWith("release the queue"); + expect(textarea.value).toBe(""); + }); + + it("blocks an empty and a whitespace-only message on BOTH the Enter and the click path", () => { + const { onSubmit, textarea, button } = setup(); + // Nothing typed at all. + fireEvent.keyDown(textarea, { key: "Enter" }); + fireEvent.click(button); + // Whitespace only -- must be blocked exactly like "" (the guard trims first). + fireEvent.change(textarea, { target: { value: " " } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + fireEvent.click(button); + expect(onSubmit).not.toHaveBeenCalled(); + // A blocked submit must not silently eat the draft either. + expect(textarea.value).toBe(" "); + }); + + it("a non-Enter key never submits", () => { + const { onSubmit, textarea } = setup(); + fireEvent.change(textarea, { target: { value: "typing" } }); + fireEvent.keyDown(textarea, { key: "a" }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("a modifier+Enter chord does not submit a half-written message", () => { + const { onSubmit, textarea } = setup(); + fireEvent.change(textarea, { target: { value: "half written" } }); + for (const modifier of [{ ctrlKey: true }, { metaKey: true }, { altKey: true }]) { + fireEvent.keyDown(textarea, { key: "Enter", ...modifier }); + } + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); + +describe("ChatComposer auto-grow (#6514)", () => { + it("grows to fit content while below the cap", () => { + const { textarea } = setup(); + typeWithScrollHeight(textarea, "one\ntwo\nthree", 90); + expect(textarea.style.height).toBe("90px"); + // Below the cap there is nothing to scroll, so no scrollbar is offered. + expect(textarea.style.overflowY).toBe("hidden"); + }); + + it("stops growing at the cap and scrolls internally instead", () => { + const { textarea } = setup(); + typeWithScrollHeight(textarea, "a very tall pasted block", 400); + expect(textarea.style.height).toBe(`${MAX_HEIGHT_PX}px`); + expect(textarea.style.overflowY).toBe("auto"); + }); + + it("shrinks back when content is deleted", () => { + const { textarea } = setup(); + typeWithScrollHeight(textarea, "one\ntwo\nthree", 90); + expect(textarea.style.height).toBe("90px"); + // Deleting lines must shrink the box -- it only can because the component collapses the height before + // re-measuring, otherwise scrollHeight would still report the taller previous box. + typeWithScrollHeight(textarea, "one", 30); + expect(textarea.style.height).toBe("30px"); + }); + + it("settles the height back down after a submit clears the box", () => { + const { textarea } = setup(); + typeWithScrollHeight(textarea, "a\nb\nc", 90); + Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 30 }); + fireEvent.keyDown(textarea, { key: "Enter" }); + expect(textarea.value).toBe(""); + expect(textarea.style.height).toBe("30px"); + }); +}); + +describe("ChatComposer presentational props (#6514)", () => { + it("renders the default placeholder and accepts an override", () => { + const { textarea } = setup(); + expect(textarea.placeholder).toBe("Ask about this miner…"); + screen.getByRole("textbox"); + render(); + expect(screen.getAllByRole("textbox")[1]!.getAttribute("placeholder")).toBe("Ask anything"); + }); + + it("disables both controls when disabled", () => { + const { textarea, button } = setup({ disabled: true }); + expect(textarea.disabled).toBe(true); + expect((button as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/apps/loopover-miner-ui/src/components/chat-composer.tsx b/apps/loopover-miner-ui/src/components/chat-composer.tsx new file mode 100644 index 0000000000..3509b4b28a --- /dev/null +++ b/apps/loopover-miner-ui/src/components/chat-composer.tsx @@ -0,0 +1,81 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import { Button } from "@loopover/ui-kit/components/button"; +import { Textarea } from "@loopover/ui-kit/components/textarea"; + +/** Tallest the textarea grows before it stops and scrolls internally instead (#6514). Roughly six lines at the + * primitive's own type scale — enough to read a pasted multi-line question back without the composer eating + * the rail it lives in. */ +const MAX_COMPOSER_HEIGHT_PX = 160; + +/** + * Message input for the miner dashboard's chat rail (#6514). The ui-kit's `Textarea`/`Button` are deliberately + * behavior-less primitives, so the submit-on-Enter, Shift+Enter-newline, and auto-grow logic lives here rather + * than being pushed down into that package. + * + * Self-contained by design: it owns its own draft state and never calls an API, MCP tool, or action endpoint — + * the caller gets the finished message through `onSubmit` and decides what to do with it. That is what lets it + * ship unwired, ahead of the rail that will eventually mount it. + */ +export function ChatComposer({ + onSubmit, + placeholder = "Ask about this miner…", + disabled = false, +}: { + onSubmit: (message: string) => void; + placeholder?: string; + disabled?: boolean; +}) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + // Re-measure on every value change, not just on keystrokes: a paste, a programmatic clear, and the reset + // after submit all have to settle the height too. useLayoutEffect so the browser paints the grown box in the + // same frame as the text -- with useEffect the caret can visibly outrun the box for a frame on a fast paste. + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + // Collapse first, then measure: scrollHeight only shrinks back if the element isn't already holding the + // taller inline height from the previous keystroke, so deleting a line would otherwise never shrink it. + textarea.style.height = "auto"; + const next = Math.min(textarea.scrollHeight, MAX_COMPOSER_HEIGHT_PX); + textarea.style.height = `${next}px`; + // At the cap the content is taller than the box, so hand scrolling back to the textarea; below it, keep + // the overflow hidden so no scrollbar flickers in while the box is still growing. + textarea.style.overflowY = textarea.scrollHeight > MAX_COMPOSER_HEIGHT_PX ? "auto" : "hidden"; + }, [value]); + + /** Emit the trimmed draft and clear, or do nothing when there is nothing real to send. Shared by the Enter + * path and the button so the two can never disagree about what counts as empty. */ + const submit = useCallback(() => { + const message = value.trim(); + if (!message) return; + onSubmit(message); + setValue(""); + }, [onSubmit, value]); + + return ( +
+