diff --git a/docs/PLAN.md b/docs/PLAN.md index 9a7d053c..a9fb0b3f 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -255,6 +255,7 @@ Hook-Code/PR-Berichtskorrekturen bleiben bei ihren Ownern. | KR-03a-1-STATUS | Kernverbindung und Kontingent im bestehenden Tresorpanel getrennt vom Schlüsselzugang anzeigen | srv-kr03a1-status; Stabs pflegt, Root nimmt ab | 01a (#1547) gemergt; eigener serieller Metadatennachtrag nach P1/#1918 | Server | B: zwei fremde Modellfamilien; Metadaten C | Erledigt mit Merge von PR #1922: lesende Verbindungs-/Kontingentanzeige im bestehenden Tresorpanel | Keine neuen Zugangsdaten, API oder Abfrageschleife; Quellbelege an `448629da`; finale Metadaten-/Review-/Root-Abnahme getrennt, native V2-ACC1b/UAT und Glass-Gesamtabnahme offen; Elternziel KR-03a bleibt teilweise erfüllt/offen | 7364e03a-2d7a-4528-9ecb-551d3dbdf3d2 | | KR-03a-2-PROVIDER-FACTS | Show recorded provider detail and usage/source/reset facts in the existing Glass vault overview | srv-kr03a2-facts; Root accepts and integrates | 01a (#1547) and KR-03a-1-STATUS (#1922) merged | Server | B: accepted source reviews; metadata C | Delivered after eventual merge of PR #1966: bounded readonly provider facts; final Root acceptance pending | Source `441ade96` accepted by Root with Kimi/GLM reviews and the 21:58Z design decision; no new keys, reader, polling or model-access claim. Full KR-03a, native runtime, installed/effective behavior, Glass and UAT remain open; Root checks this metadata delta separately | #332; 9c2bb5c3-8dd1-4fe0-a1a4-243f839d1304 | | KR-04b | Projektbezogene Chatentwürfe und echten Verlauf erhalten | srv-kr-04b-chat-state; Root nahm die gemergte Lieferung ab | #1569 gemergt | Server | B | Erledigt: #1894 am 25.10.2026 00:38:13Z gemergt (`ae9abb5f0b1388d317f7dfb81dd86e387bebf780`); Ticket DONE | Entwurf je Projekt nur im laufenden UI-Dokument; echter Verlauf neu gelesen. Browser-/Tauri-Mock-Belege, kein Neustart-/Datenträger-/nativer Nachweis, keine installierte/wirksame Lieferung oder Glass-Gesamt-/UAT-Abnahme. #1894-Nachläufer bleiben; kein neuer Folgeauftrag | 54a480e5-5c04-4387-b15b-ae60d7e62e6c | +| KR-04c-1-COMMANDCHAT-CONTROLS | Bestehende CommandChat-Hülle und native Bedienelemente im begrenzten Split erhalten | srv-kr04c1-commandchat; Root prüft und integriert | 01a (#1547) und KR-04b (#1894, `ae9abb5f`) gemergt; eigener begrenzter Schnitt | Server | A: Quellreviews; eigener Zeilennachtrag C | Nach Merge geliefert: nur begrenzter nativer Bedienelement-Split; finale Root-/Metadatenabnahme offen | #1965, unveränderlicher Quellkopf `e2af21107ed79e8277e267b5bbc80f06ccd7bd87`: bestehender Host mit Rahmen/Hintergrund, Textextraktion und role=alert ohne Inlinefarbe; ursprüngliche native Controls/Klassen, öffentliche Identität und Entwürfe erhalten. GLM-5.2 und Kimi APPROVE, Quellstand von Root angenommen; D1 geschlossen, D2 von Root abgelehnt. KR-04c-2 wartet auf Roots Ghost-Option-A-Paket; voller KR-04c-/Glass-Button-/Field-Umbau, native Laufzeit, installiert/wirksam, Tastatur/AT, UAT und Glass-Gesamtabnahme bleiben offen | c07e5bd2-4395-4ae9-bb01-dbc3b96d3f51 | | KR-04d-1-SHARPEN-CONTROLS | Preserve explicit sharpening with named Glass controls in the existing ConversationView | srv-kr04d1-sharpen; Root accepts and integrates | KR-04b/#1894 merged (ae9abb5f) | Server | B: two source review families accepted; metadata C | Delivered after merge of PR #1967: bounded sharpening controls; Root final-head acceptance pending | Root accepted Kimi/GLM source reviews at `0445aa1a` and design under the 21:58Z aggregate/untouched-route rule. Stable action name, separate phase feedback, questions/cancel and editable final draft; no autosend, new hook owner, IPC or native change. Full KR-04d, native, installed/effective, Glass and UAT remain open | #334; e4464eaa-b33a-4136-9a2a-49a5ce8ce412 | | KR-07a-0a-MAIN-HEADROOM | Sieben bestehende Projektkonfigurationsbefehle unverändert aus main.rs verschieben und Platz für den Bridge-Schnitt schaffen | srv-kr07a0a-main-headroom; Root nimmt ab und integriert | Quellkopf `f9cb0e5a631f8ec817fdf39ee4e7b4016e0b4105`, PR #1934; beide Fremdreviews von Root bestätigt | Server | A: Quellreviews angenommen; Zeilennachtrag C | Erledigt mit Merge von PR #1934 (ceb309fa, 26.10.2026 12:54:42Z; Root-Abnahme 12:34Z) | Sieben Wrapper rein verschoben; main.rs 3976 Zeilen, netto 83 frei. Nur eigene Lieferzeile; Elternziel KR-07a, voller 1b-Schnitt, native Laufzeit, installiert/wirksam und UAT offen; Bridge: siehe KR-07a-0-BRIDGE (PR #1939) | 1341ad4b-2c13-441a-b6dc-7637c854f946 | | KR-07a-0-BRIDGE | Nur lesender Adapter für Projekt-Entwicklungsdatensätze mit synthetischem Browser-Mock | srv-kr07a0-bridge; Root nimmt ab und integriert | KR-07a-0a-MAIN-HEADROOM (#1934 gemergt); PR #1939, Quellkopf `f2892ce0` | Server | A: zwei Quellreviews durch Root disponiert; Nachbesserung C+ | Erledigt nach Merge von PR #1939 (89838eec, 26.10.2026 15:47:21Z); Root-Abnahme offen | Öffentliche Reader-Core-Tests und synthetischer Tauri-Mock, kein echter Tauri-invoke-Beleg. K1 offen: Allowlist-DTO-Projektion ohne Pfade, policy_json oder Evidence-Payload-Durchleitung vor dem ersten Produktionskonsumenten und vor jeder Darstellung. Keine Ausführung, Freigabe oder Vertrauensaufwertung; native Laufzeit, installiert/wirksam, UAT, voller 07a-1b-Schnitt und Elternziel KR-07a offen | 6546cd4e-d037-4b3c-8fd3-2ebfb6dc0802 | diff --git a/scripts/dev/design-gate.test.mjs b/scripts/dev/design-gate.test.mjs index 9e949f9d..d0e433c1 100644 --- a/scripts/dev/design-gate.test.mjs +++ b/scripts/dev/design-gate.test.mjs @@ -1,7 +1,7 @@ import { after, before, test } from 'node:test'; import assert from 'node:assert/strict'; import { chromium } from '@playwright/test'; -import { measure, ratchet } from './design-gate.mjs'; +import { fixture, measure, ratchet } from './design-gate.mjs'; let browser, page; before(async () => { @@ -14,6 +14,45 @@ async function inspect(html) { return page.evaluate(measure); } +test('mounted CommandChat preserves visible actions without increasing original route nesting', async () => { + const { createServer } = await import('vite'); + const previousMocks = process.env.VITE_TAURI_MOCKS; + process.env.VITE_TAURI_MOCKS = 'true'; + const server = await createServer({ server: { host: '127.0.0.1', port: 0, hmr: false }, logLevel: 'error' }); + try { + await server.listen(); + for (const theme of ['light', 'dark']) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce', colorScheme: theme }); + try { + await context.addInitScript(fixture, { items: 0, theme }); + const mounted = await context.newPage(); + await mounted.clock.setFixedTime(new Date('2026-10-19T00:00:00Z')); + // Literal counts from the original 86401bd8 route captures, not a recomputed ceiling. + for (const [route, ceiling] of [['/beweise/diff', 14], ['/einstellungen/klassisch', 42]]) { + await mounted.goto(`http://127.0.0.1:${server.httpServer.address().port}/#${route}`, { waitUntil: 'networkidle' }); + await mounted.getByRole('textbox', { name: 'Aufgabe an den Orchestrator' }).waitFor(); + await mounted.evaluate(() => document.fonts.ready); + for (const name of ['Verlauf', 'Im Dialog öffnen', 'Senden']) { + const action = mounted.getByRole('button', { name, exact: true }); + assert.equal(await action.isVisible(), true, `${route} ${name}`); + if (await action.isEnabled()) { + await action.focus(); + assert.equal(await action.evaluate(el => el === document.activeElement), true); + } + } + const result = await mounted.evaluate(measure); + assert.ok(result.nesting.length <= ceiling, `${route} ${theme}: ${result.nesting.length} > ${ceiling}`); + } + } finally { await context.close(); } + } + } finally { + try { await server.close(); } finally { + if (previousMocks === undefined) delete process.env.VITE_TAURI_MOCKS; + else process.env.VITE_TAURI_MOCKS = previousMocks; + } + } +}); + test('600px panel with two 40px rows has 520px slack', async () => { const result = await inspect('
'); assert.equal(result.slack.length, 1); diff --git a/src/components/CommandChat.layout.browser.mjs b/src/components/CommandChat.layout.browser.mjs new file mode 100644 index 00000000..9c3f4a7c --- /dev/null +++ b/src/components/CommandChat.layout.browser.mjs @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import process from 'node:process'; +import console from 'node:console'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { chromium } from '@playwright/test'; +import { createServer } from 'vite'; +import { fixture } from '../../scripts/dev/design-gate.mjs'; + +test('keeps command history above the prompt on its existing bordered surface', async () => { + const previousMocks = process.env.VITE_TAURI_MOCKS; + process.env.VITE_TAURI_MOCKS = 'true'; + const server = await createServer({ server: { host: '127.0.0.1', port: 0, hmr: false }, logLevel: 'error' }); + const observations = []; + let browser; + try { + await server.listen(); + browser = await chromium.launch({ args: ['--disable-gpu', '--disable-dev-shm-usage'] }); + for (const theme of ['light', 'dark']) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: theme, reducedMotion: 'reduce' }); + try { + await context.addInitScript(fixture, { items: 2, theme }); + const page = await context.newPage(); + await page.clock.setFixedTime(new Date('2026-10-19T00:00:00Z')); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/#/beweise/diff`, { waitUntil: 'networkidle' }); + await page.getByRole('textbox', { name: 'Aufgabe an den Orchestrator' }).waitFor(); + await page.getByRole('button', { name: 'Verlauf', exact: true }).click(); + await page.locator('.command-chat-history').waitFor({ state: 'visible' }); + await page.evaluate(() => globalThis.document.fonts.ready); + const layout = await page.getByRole('textbox', { name: 'Aufgabe an den Orchestrator' }).evaluate(input => { + const bar = input.closest('.command-chat-bar'); + const host = bar.parentElement; + const style = globalThis.getComputedStyle(host); + const history = host.querySelector('.command-chat-history').getBoundingClientRect(); + const prompt = bar.getBoundingClientRect(); + return { display: style.display, direction: style.flexDirection, border: style.borderTopWidth, + borderStyle: style.borderTopStyle, background: style.backgroundColor, + historyBottom: history.bottom, promptTop: prompt.top, historyHeight: history.height }; + }); + observations.push({ theme, ...layout }); + if (process.env.COMMAND_CHAT_LAYOUT_OUT) { + mkdirSync(process.env.COMMAND_CHAT_LAYOUT_OUT, { recursive: true }); + await page.screenshot({ path: resolve(process.env.COMMAND_CHAT_LAYOUT_OUT, `${theme}.png`) }); + } + } finally { await context.close(); } + } + if (process.env.COMMAND_CHAT_LAYOUT_OUT) writeFileSync(resolve(process.env.COMMAND_CHAT_LAYOUT_OUT, 'layout.json'), JSON.stringify(observations, null, 2)); + console.log(JSON.stringify(observations)); + for (const layout of observations) { + assert.equal(layout.display, 'flex', layout.theme); + assert.equal(layout.direction, 'column', layout.theme); + assert.equal(layout.border, '1px', layout.theme); + assert.equal(layout.borderStyle, 'solid', layout.theme); + assert.notEqual(layout.background, 'rgba(0, 0, 0, 0)', layout.theme); + assert.ok(layout.historyHeight > 0 && layout.historyBottom <= layout.promptTop, layout.theme); + } + } finally { + try { await browser?.close(); } finally { await server.close(); } + if (previousMocks === undefined) delete process.env.VITE_TAURI_MOCKS; + else process.env.VITE_TAURI_MOCKS = previousMocks; + } +}); 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); +}); diff --git a/src/components/CommandChat.tsx b/src/components/CommandChat.tsx index 634649cd..02509eea 100644 --- a/src/components/CommandChat.tsx +++ b/src/components/CommandChat.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { formatMessageTime, roleLabel, type OrchestratorChat } from "../lib/orchestratorChat"; +import { commandChatTexts as texts } from "./commandchat/texts"; interface CommandChatProps { chat: OrchestratorChat; @@ -21,7 +22,7 @@ interface CommandChatProps { function EmptyChat() { return (
-

Noch keine Nachrichten. Schick dem Orchestrator eine Aufgabe.

+

{texts.empty}

); } @@ -124,9 +125,11 @@ 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}