From 1ecbbfb461baf54f52d346069037d7d91685e889 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:17:00 +0100 Subject: [PATCH 1/9] test(command-chat): specify stable public send controls Test-First: src/components/CommandChat.public.test.tsx::keeps sending named and announces errors without changing the submitted draft Co-Authored-By: GPT-6.1 Sol --- src/components/CommandChat.public.test.tsx | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/components/CommandChat.public.test.tsx diff --git a/src/components/CommandChat.public.test.tsx b/src/components/CommandChat.public.test.tsx new file mode 100644 index 00000000..dc69e0ed --- /dev/null +++ b/src/components/CommandChat.public.test.tsx @@ -0,0 +1,97 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; + +import type { OrchestratorChat } from "../lib/orchestratorChat"; +import CommandChat from "./CommandChat"; + +it("keeps sending named and announces errors without changing the submitted draft", async () => { + let respond: (accepted: boolean) => void = () => {}; + const send = vi.fn(() => new Promise((resolve) => { respond = resolve; })); + const clearError = vi.fn(); + const reload = vi.fn(); + const openConversation = vi.fn(); + const chat: OrchestratorChat = { + orchestratorId: null, + messages: [], + loading: false, + sending: false, + error: null, + disabled: false, + send, + clearError, + reload, + }; + const { rerender } = render( + , + ); + const input = screen.getByRole("textbox", { name: "Aufgabe an den Orchestrator" }); + const submit = screen.getByRole("button", { name: "Senden" }); + expect(input).toHaveAttribute("name", "command-chat-input"); + expect(input).toHaveAttribute("rows", "2"); + expect(submit).toBeDisabled(); + expect(send).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: " \n " } }); + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(send).not.toHaveBeenCalled(); + + const draft = " Prüfe die Aufgabe\nmit Kontext "; + fireEvent.change(input, { target: { value: draft } }); + fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); + expect(send).not.toHaveBeenCalled(); + expect(input).toHaveValue(draft); + fireEvent.keyDown(input, { key: "Enter" }); + expect(send).toHaveBeenCalledExactlyOnceWith(draft); + rerender( + , + ); + expect.soft(screen.queryByRole("button", { name: "Senden" })).toBe(submit); + expect(submit).toBeDisabled(); + expect(input).toBeDisabled(); + expect(input).toHaveValue(draft); + fireEvent.click(submit); + expect(send).toHaveBeenCalledTimes(1); + rerender( + , + ); + await act(async () => { respond(false); }); + expect.soft(screen.queryByRole("alert")).toHaveTextContent("Aufgabe wurde nicht angenommen."); + expect(input).toHaveValue(draft); + expect(input).toHaveFocus(); + const history = screen.getByRole("button", { name: /Verlauf/ }); + expect(history).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(history); + expect(history).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Noch keine Nachrichten. Schick dem Orchestrator eine Aufgabe.")).toBeVisible(); + expect.soft(screen.queryByRole("alert")).toHaveTextContent("Aufgabe wurde nicht angenommen."); + expect(clearError).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Im Dialog öffnen" })); + expect(openConversation).toHaveBeenCalledTimes(1); + + fireEvent.click(submit); + expect(send).toHaveBeenNthCalledWith(2, draft); + await act(async () => { respond(true); }); + expect(input).toHaveValue(""); + expect(input).toHaveFocus(); + rerender(); + fireEvent.change(input, { target: { value: "Gleicher Entwurf" } }); + fireEvent.click(submit); + expect(send).toHaveBeenNthCalledWith(3, "Gleicher Entwurf"); + rerender(); + expect(input).toHaveValue(""); + expect(send).toHaveBeenCalledTimes(3); + fireEvent.change(input, { target: { value: "Gleicher Entwurf" } }); + await act(async () => { respond(true); }); + expect(input).toHaveValue("Gleicher Entwurf"); + + rerender( + , + ); + expect(input).toHaveValue(""); + expect(input).toBeDisabled(); + expect(submit).toBeDisabled(); + expect(history).toBeDisabled(); + fireEvent.click(submit); + expect(send).toHaveBeenCalledTimes(3); +}); From b5d923332479c30469450ae2f8bd2ec4e58bfce9 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:21:56 +0100 Subject: [PATCH 2/9] feat(command-chat): port controls to Glass without changing send ownership Keep the named send button disabled while busy and announce the existing error message. Preserve the native textarea ref and the public chat callbacks. Test-First: src/components/CommandChat.public.test.tsx::keeps sending named and announces errors without changing the submitted draft Co-Authored-By: GPT-6.1 Sol --- src/components/CommandChat.tsx | 81 ++++++++++++++++++----------- src/components/commandchat/texts.ts | 10 ++++ 2 files changed, 60 insertions(+), 31 deletions(-) create mode 100644 src/components/commandchat/texts.ts diff --git a/src/components/CommandChat.tsx b/src/components/CommandChat.tsx index 634649cd..a6db2b9e 100644 --- a/src/components/CommandChat.tsx +++ b/src/components/CommandChat.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { Button } from "../design/controls/Button"; +import { Field } from "../design/forms/Field"; import { formatMessageTime, roleLabel, type OrchestratorChat } from "../lib/orchestratorChat"; +import { commandChatTexts as texts } from "./commandchat/texts"; +import "../design/glass.css"; +import "../design/controls/controls.css"; +import "../design/forms/forms.css"; +import "../design/inputs/inputs.css"; interface CommandChatProps { chat: OrchestratorChat; @@ -21,7 +28,7 @@ interface CommandChatProps { function EmptyChat() { return (
-

Noch keine Nachrichten. Schick dem Orchestrator eine Aufgabe.

+

{texts.empty}

); } @@ -99,7 +106,7 @@ export default function CommandChat({ chat, projectId, onOpenConversation }: Com const canSend = !disabled && !chat.sending && text.trim() !== ""; return ( -
+
{historyOpen ? (
{chat.messages.length === 0 ? ( @@ -124,51 +131,63 @@ export default function CommandChat({ chat, projectId, onOpenConversation }: Com )}
) : null} - {chat.error ?

{chat.error}

: null} + {chat.error ? ( +

{chat.error}

+ ) : null} {disabled ? ( -

Kein Projekt aktiv — wähle links ein Projekt.

+

{texts.noProject}

) : null}
- -