Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/loopover-miner-ui/src/chat-composer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof ChatComposer>[0]> = {}) {
const onSubmit = vi.fn();
render(<ChatComposer onSubmit={onSubmit} {...props} />);
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(<ChatComposer onSubmit={vi.fn()} placeholder="Ask anything" />);
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);
});
});
81 changes: 81 additions & 0 deletions apps/loopover-miner-ui/src/components/chat-composer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>(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 (
<div className="flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
// Shift+Enter is the newline escape hatch, so only a bare Enter submits. The other modifiers are
// checked too: Ctrl/Cmd/Alt+Enter is a submit chord in plenty of chat UIs, and silently treating it
// as a plain Enter here would send a half-written message.
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault(); // otherwise the newline lands in the box we're about to clear
submit();
}}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="max-h-[160px] resize-none"
/>
<Button type="button" onClick={submit} disabled={disabled} size="sm">
Send
</Button>
</div>
);
}
Loading