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
1 change: 1 addition & 0 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
41 changes: 40 additions & 1 deletion scripts/dev/design-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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('<section class="box" style="height:600px"><div class="row"><button style="height:40px">One</button></div><div class="row"><button style="height:40px">Two</button></div></section>');
assert.equal(result.slack.length, 1);
Expand Down
63 changes: 63 additions & 0 deletions src/components/CommandChat.layout.browser.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});
97 changes: 97 additions & 0 deletions src/components/CommandChat.public.test.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>((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(
<CommandChat chat={chat} projectId="project-a" onOpenConversation={openConversation} />,
);
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(
<CommandChat chat={{ ...chat, sending: true }} projectId="project-a" onOpenConversation={openConversation} />,
);
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(
<CommandChat chat={{ ...chat, error: "Aufgabe wurde nicht angenommen." }} projectId="project-a" onOpenConversation={openConversation} />,
);
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(<CommandChat chat={chat} projectId="project-a" onOpenConversation={openConversation} />);
fireEvent.change(input, { target: { value: "Gleicher Entwurf" } });
fireEvent.click(submit);
expect(send).toHaveBeenNthCalledWith(3, "Gleicher Entwurf");
rerender(<CommandChat chat={chat} projectId="project-b" onOpenConversation={openConversation} />);
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(
<CommandChat chat={{ ...chat, disabled: true }} projectId={null} onOpenConversation={openConversation} />,
);
expect(input).toHaveValue("");
expect(input).toBeDisabled();
expect(submit).toBeDisabled();
expect(history).toBeDisabled();
fireEvent.click(submit);
expect(send).toHaveBeenCalledTimes(3);
});
26 changes: 15 additions & 11 deletions src/components/CommandChat.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,7 +22,7 @@ interface CommandChatProps {
function EmptyChat() {
return (
<div className="command-chat-empty">
<p>Noch keine Nachrichten. Schick dem Orchestrator eine Aufgabe.</p>
<p>{texts.empty}</p>
</div>
);
}
Expand Down Expand Up @@ -124,9 +125,11 @@ export default function CommandChat({ chat, projectId, onOpenConversation }: Com
)}
</div>
) : null}
{chat.error ? <p className="command-chat-error">{chat.error}</p> : null}
{chat.error ? (
<p className="command-chat-error" role="alert">{chat.error}</p>
) : null}
{disabled ? (
<p className="command-chat-hint">Kein Projekt aktiv — wähle links ein Projekt.</p>
<p className="command-chat-hint">{texts.noProject}</p>
) : null}
<div className="command-chat-bar">
<button
Expand All @@ -135,19 +138,19 @@ export default function CommandChat({ chat, projectId, onOpenConversation }: Com
onClick={() => setHistoryOpen((open) => !open)}
disabled={disabled}
aria-expanded={historyOpen}
title="Verlauf des Orchestrators"
title={texts.historyTitle}
>
<span className="command-chat-chevron">{historyOpen ? "▾" : "▸"}</span>
Verlauf
<span className="command-chat-chevron" aria-hidden="true">{historyOpen ? "▾" : "▸"}</span>
{texts.history}
</button>
<textarea
ref={inputRef}
name="command-chat-input"
className="command-chat-input"
aria-label="Aufgabe an den Orchestrator"
aria-label={texts.input}
rows={2}
value={text}
placeholder="Aufgabe an den Orchestrator … (Enter sendet, Shift+Enter neue Zeile)"
placeholder={texts.placeholder}
disabled={disabled || chat.sending}
onChange={(event) => setText(event.target.value)}
onKeyDown={handleKeyDown}
Expand All @@ -156,18 +159,19 @@ export default function CommandChat({ chat, projectId, onOpenConversation }: Com
type="button"
className="command-chat-expand"
onClick={onOpenConversation}
title="Im Dialog öffnen"
aria-label="Im Dialog öffnen"
title={texts.openConversation}
aria-label={texts.openConversation}
>
⤢
</button>
<button
type="button"
className="command-chat-send"
aria-label={texts.send}
disabled={!canSend}
onClick={() => void handleSend()}
>
{chat.sending ? "Sendet …" : "Senden"}
{chat.sending ? texts.sending : texts.send}
</button>
</div>
</div>
Expand Down
11 changes: 11 additions & 0 deletions src/components/commandchat/texts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const commandChatTexts = {
empty: "Noch keine Nachrichten. Schick dem Orchestrator eine Aufgabe.",
noProject: "Kein Projekt aktiv — wähle links ein Projekt.",
history: "Verlauf",
historyTitle: "Verlauf des Orchestrators",
input: "Aufgabe an den Orchestrator",
placeholder: "Aufgabe an den Orchestrator … (Enter sendet, Shift+Enter neue Zeile)",
openConversation: "Im Dialog öffnen",
send: "Senden",
sending: "Sendet …",
} as const;
Loading