feat: Shift+Enter inserts newline in chat input (story 82)

Change chat input from <input> to <textarea> so multi-line messages
are supported. Enter (without Shift) still sends; Shift+Enter inserts
a newline via the native textarea behavior.

- Replace <input> with <textarea rows={1}> in Chat.tsx
- Update onKeyDown: guard sendMessage() with !e.shiftKey check
  and call e.preventDefault() to suppress the default newline on Enter
- Update inputRef type from HTMLInputElement to HTMLTextAreaElement
- Add style props: resize:none, overflowY:auto, fontFamily:inherit
- Add 3 new Vitest tests covering all 3 acceptance criteria (AC1-AC3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 15:56:44 +00:00
parent 3a0004d212
commit 17e9599334
2 changed files with 58 additions and 4 deletions

View File

@@ -1,4 +1,10 @@
import { act, render, screen, waitFor } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../api/client"; import { api } from "../api/client";
@@ -261,3 +267,46 @@ describe("Chat two-column layout", () => {
}); });
}); });
}); });
describe("Chat input Shift+Enter behavior", () => {
beforeEach(() => {
capturedWsHandlers = null;
setupMocks();
});
it("renders a textarea element for the chat input (AC3)", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const input = screen.getByPlaceholderText("Send a message...");
expect(input.tagName.toLowerCase()).toBe("textarea");
});
it("sends message on Enter key press without Shift (AC2)", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Hello" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe("");
});
});
it("does not send message on Shift+Enter (AC1)", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Hello" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
});
expect((input as HTMLTextAreaElement).value).toBe("Hello");
});
});

View File

@@ -44,7 +44,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const wsRef = useRef<ChatWebSocket | null>(null); const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true); const shouldAutoScrollRef = useRef(true);
const lastScrollTopRef = useRef(0); const lastScrollTopRef = useRef(0);
@@ -652,16 +652,18 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
alignItems: "center", alignItems: "center",
}} }}
> >
<input <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage(); sendMessage();
} }
}} }}
placeholder="Send a message..." placeholder="Send a message..."
rows={1}
style={{ style={{
flex: 1, flex: 1,
padding: "14px 20px", padding: "14px 20px",
@@ -673,6 +675,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
background: "#2f2f2f", background: "#2f2f2f",
color: "#ececec", color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)", boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
resize: "none",
overflowY: "auto",
fontFamily: "inherit",
}} }}
/> />
<button <button