Files
safe-os-ui/src/pages/DevOpsAgent.tsx

1080 lines
50 KiB
TypeScript
Raw Normal View History

2026-03-12 18:15:48 +08:00
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2026-03-12 16:33:33 +08:00
import { API } from "../config";
/* ─── Types ─── */
type Step = 0 | 1 | 2 | 3 | 4;
type ClarifyMsg = { role: "user" | "assistant"; content: string };
2026-03-12 18:15:48 +08:00
type RequirementAnalysis = {
2026-03-12 16:33:33 +08:00
summary: string;
2026-03-12 18:15:48 +08:00
functional_requirements: string[];
non_functional_requirements: string[];
acceptance_criteria: string[];
2026-03-12 16:33:33 +08:00
edge_cases: string[];
};
type TestCase = {
2026-03-12 18:15:48 +08:00
test_id: string;
test_name: string;
2026-03-12 16:33:33 +08:00
precondition: string;
2026-03-12 18:15:48 +08:00
steps: string[] | string;
expected_result: string;
test_type?: string;
};
type CodeGeneration = {
java_code: string;
unit_tests: string;
implementation_notes: string;
2026-03-12 16:33:33 +08:00
};
2026-03-12 18:15:48 +08:00
type TestExecution = {
success: boolean;
2026-03-12 16:33:33 +08:00
passed: number;
failed: number;
errors: number;
total: number;
output: string;
};
/* ─── API helpers ─── */
const BASE = API.devops;
async function apiPost<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
2026-03-12 18:15:48 +08:00
// SSE 格式data: {"type":"chunk","text":"..."} / {"type":"done",...} / {"type":"error","message":"..."}
async function readSSE(
path: string,
onChunk: (text: string) => void,
onDone?: (data: any) => void,
signal?: AbortSignal,
) {
2026-03-12 16:33:33 +08:00
const res = await fetch(`${BASE}${path}`, { signal });
if (!res.ok) throw new Error(`${res.status}`);
if (!res.body) return;
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
2026-03-12 18:15:48 +08:00
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
try {
const evt = JSON.parse(payload);
if (evt.type === "chunk" && evt.text) {
onChunk(evt.text);
} else if (evt.type === "done") {
onDone?.(evt);
return;
} else if (evt.type === "error") {
throw new Error(evt.message || "Stream error");
} else if (evt.text) {
onChunk(evt.text);
} else if (evt.message) {
onChunk(evt.message);
2026-03-12 16:33:33 +08:00
}
2026-03-12 18:15:48 +08:00
} catch (e) {
if (e instanceof Error && !e.message.startsWith("Stream error")) onChunk(payload);
else throw e;
2026-03-12 16:33:33 +08:00
}
}
}
} finally {
reader.releaseLock();
}
}
2026-03-12 18:15:48 +08:00
/* ── 流式 JSON 增量解析工具 ── */
function parseStreamStr(text: string, fieldName: string): { value: string; active: boolean } {
const idx = text.indexOf(`"${fieldName}"`);
if (idx === -1) return { value: "", active: false };
let start = idx + fieldName.length + 2;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== '"') return { value: "", active: false };
start++;
let value = "", i = start, closed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { closed = true; break; }
else { value += text[i++]; }
}
return { value, active: !closed };
}
function parseStreamStrArray(text: string, fieldName: string): { items: { value: string; active: boolean }[]; active: boolean } {
const idx = text.indexOf(`"${fieldName}"`);
if (idx === -1) return { items: [], active: false };
let start = idx + fieldName.length + 2;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== "[") return { items: [], active: false };
start++;
const items: { value: string; active: boolean }[] = [];
let i = start, arrayClosed = false;
while (i < text.length) {
if (text[i] === "]") { arrayClosed = true; break; }
if (text[i] === '"') {
i++;
let value = "", itemClosed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { itemClosed = true; i++; break; }
else { value += text[i++]; }
}
if (value.trim()) items.push({ value, active: !itemClosed });
} else { i++; }
}
return { items, active: !arrayClosed };
}
function parseStreamTestCaseNames(text: string): { names: string[]; active: boolean } {
const idx = text.indexOf('"test_cases"');
if (idx === -1) return { names: [], active: false };
let start = idx + 12;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== "[") return { names: [], active: false };
let depth = 0, inStr = false, arrayClosed = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inStr) { if (ch === "\\") i++; else if (ch === '"') inStr = false; }
else { if (ch === '"') inStr = true; else if (ch === "[") depth++; else if (ch === "]") { depth--; if (depth === 0) { arrayClosed = true; break; } } }
}
const arrayText = text.slice(start);
const names: string[] = [];
const re = /"test_name"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(arrayText)) !== null) {
names.push(m[1].replace(/\\n/g, " ").replace(/\\"/g, '"'));
}
return { names, active: !arrayClosed };
}
type DevStreamResult = { java_code: string; unit_tests: string; implementation_notes: string; currentField: string | null };
function parseDevStream(text: string): DevStreamResult {
const result: DevStreamResult = {
java_code: "", unit_tests: "", implementation_notes: "", currentField: null,
};
for (const field of ["java_code", "unit_tests", "implementation_notes" ] as const) {
const key = `"${field}"`;
const idx = text.indexOf(key);
if (idx === -1) continue;
let start = idx + key.length;
while (start < text.length && (text[start] === " " || text[start] === ":")) start++;
if (start >= text.length || text[start] !== '"') continue;
start++;
let value = "", i = start, closed = false;
while (i < text.length) {
if (text[i] === "\\" && i + 1 < text.length) {
const e = text[i + 1];
value += e === "n" ? "\n" : e === "t" ? "\t" : e;
i += 2;
} else if (text[i] === '"') { closed = true; break; }
else { value += text[i++]; }
}
result[field] = value;
if (!closed) result.currentField = field;
}
return result;
}
/* ── 步骤标签 ── */
const STEPS = [
{ title: "Requirements", icon: "💬" },
2026-03-12 16:33:33 +08:00
{ title: "PM Analysis", icon: "📋" },
{ title: "QA Cases", icon: "🧪" },
2026-03-12 18:15:48 +08:00
{ title: "Dev Code", icon: "💻" },
2026-03-12 16:33:33 +08:00
{ title: "Test Run", icon: "▶" },
];
2026-03-12 18:15:48 +08:00
const STATUS_STEP: Record<string, number> = {
clarifying: 0, pm_ready: 0,
pm_done: 1, qa_ready: 1,
qa_done: 2, dev_ready: 2,
dev_done: 3, test_done: 4,
};
const STATUS_LABEL: Record<string, string> = {
clarifying: "Clarifying", pm_ready: "Ready",
pm_done: "PM Done", qa_ready: "PM Done",
qa_done: "QA Done", dev_ready: "QA Done",
dev_done: "Code Ready", test_done: "Tests Done",
};
const TYPE_EMOJI: Record<string, string> = {
"Functional": "🧩", "Performance": "⚡", "Security": "🔒",
"功能测试": "🧩", "性能测试": "⚡", "安全测试": "🔒",
};
const TYPE_LABEL: Record<string, string> = {
"功能测试": "Functional", "性能测试": "Performance",
"安全测试": "Security", "边界测试": "Boundary",
"异常测试": "Exception", "集成测试": "Integration",
};
2026-03-12 16:33:33 +08:00
/* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */
export default function DevOpsAgent() {
const [step, setStep] = useState<Step>(0);
const [sessionId, setSessionId] = useState("");
2026-03-12 18:15:48 +08:00
const [status, setStatus] = useState("");
2026-03-12 16:33:33 +08:00
const [loading, setLoading] = useState(false);
2026-03-12 18:15:48 +08:00
const [streaming, setStreaming] = useState(false);
2026-03-12 16:33:33 +08:00
const [error, setError] = useState("");
2026-03-12 18:15:48 +08:00
const [streamText, setStreamText] = useState("");
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
// Step 0
2026-03-12 16:33:33 +08:00
const [requirement, setRequirement] = useState("");
2026-03-12 18:15:48 +08:00
const [rawRequirement, setRawRequirement] = useState("");
2026-03-12 16:33:33 +08:00
const [clarifyHistory, setClarifyHistory] = useState<ClarifyMsg[]>([]);
const [clarifyInput, setClarifyInput] = useState("");
2026-03-12 18:15:48 +08:00
// Step 1
const [requirementAnalysis, setRequirementAnalysis] = useState<RequirementAnalysis | null>(null);
2026-03-12 16:33:33 +08:00
const [pmFeedback, setPmFeedback] = useState("");
2026-03-12 18:15:48 +08:00
// Step 2
2026-03-12 16:33:33 +08:00
const [testCases, setTestCases] = useState<TestCase[]>([]);
2026-03-12 18:15:48 +08:00
const [testStrategy, setTestStrategy] = useState("");
const [coveragePlan, setCoveragePlan] = useState("");
2026-03-12 16:33:33 +08:00
const [qaFeedback, setQaFeedback] = useState("");
2026-03-12 18:15:48 +08:00
// Step 3
const [codeGeneration, setCodeGeneration] = useState<CodeGeneration | null>(null);
const [codeTab, setCodeTab] = useState<"java_code" | "unit_tests" | "implementation_notes">("java_code");
const [devStreamTab, setDevStreamTab] = useState<"java_code" | "unit_tests" | "implementation_notes">("java_code");
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
// Step 4
const [testExecution, setTestExecution] = useState<TestExecution | null>(null);
2026-03-12 16:33:33 +08:00
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
2026-03-12 18:15:48 +08:00
const pmStreamRef = useRef<HTMLDivElement>(null);
const qaStreamRef = useRef<HTMLDivElement>(null);
const devStreamRef = useRef<HTMLPreElement>(null);
/* ── 流式增量解析 ── */
const pmStreamParsed = useMemo(() => {
if (!streaming || step !== 1) return null;
const t = streamText;
const fr = parseStreamStrArray(t, "functional_requirements");
const nfr = parseStreamStrArray(t, "non_functional_requirements");
const ac = parseStreamStrArray(t, "acceptance_criteria");
const ec = parseStreamStrArray(t, "edge_cases");
const sum = parseStreamStr(t, "summary");
let currentSection: string | null = null;
for (const [key, val] of [["functional_requirements", fr], ["non_functional_requirements", nfr], ["acceptance_criteria", ac], ["edge_cases", ec]] as const) {
if (val.active || val.items.some((i) => i.active)) { currentSection = key; break; }
}
if (!currentSection && sum.active) currentSection = "summary";
return { functional_requirements: fr, non_functional_requirements: nfr, acceptance_criteria: ac, edge_cases: ec, summary: sum, currentSection };
}, [streaming, step, streamText]);
const qaStreamParsed = useMemo(() => {
if (!streaming || step !== 2) return null;
const t = streamText;
const cases = parseStreamTestCaseNames(t);
const strategy = parseStreamStr(t, "test_strategy");
const coverage = parseStreamStr(t, "coverage_plan");
let currentSection: string | null = null;
if (cases.active) currentSection = "test_cases";
else if (strategy.active) currentSection = "test_strategy";
else if (coverage.active) currentSection = "coverage_plan";
else if (cases.names.length) currentSection = "test_cases";
return { testCases: cases, testStrategy: strategy, coveragePlan: coverage, currentSection };
}, [streaming, step, streamText]);
const devStreamParsed = useMemo(() => {
if (!streaming || step !== 3) return null;
return parseDevStream(streamText);
}, [streaming, step, streamText]);
// Dev 流式时自动切换 Tab
useEffect(() => {
if (devStreamParsed?.currentField) {
const f = devStreamParsed.currentField as typeof devStreamTab;
if (["java_code", "unit_tests", "implementation_notes"].includes(f)) setDevStreamTab(f);
}
}, [devStreamParsed?.currentField]);
2026-03-12 16:33:33 +08:00
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
2026-03-12 18:15:48 +08:00
// 同时滚动各流式容器到底部
if (pmStreamRef.current) pmStreamRef.current.scrollTop = pmStreamRef.current.scrollHeight;
if (qaStreamRef.current) qaStreamRef.current.scrollTop = qaStreamRef.current.scrollHeight;
if (devStreamRef.current) devStreamRef.current.scrollTop = devStreamRef.current.scrollHeight;
}, [streamText]);
2026-03-12 16:33:33 +08:00
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
2026-03-12 18:15:48 +08:00
function applySessionData(data: any) {
if (data.status) setStatus(data.status);
if (data.data?.requirement_analysis) setRequirementAnalysis(data.data.requirement_analysis);
if (data.data?.test_cases) {
const tc = data.data.test_cases;
if (tc.test_cases?.length) setTestCases(tc.test_cases);
if (tc.test_strategy) setTestStrategy(tc.test_strategy);
if (tc.coverage_plan) setCoveragePlan(tc.coverage_plan);
}
if (data.data?.code_generation) setCodeGeneration(data.data.code_generation);
if (data.data?.test_execution) setTestExecution(data.data.test_execution);
}
/* ── 流式通用封装 ── */
const withStreaming = useCallback(async (
streamPath: string,
targetStep: Step,
runPath?: string,
) => {
setStreamText("");
setStreaming(true);
setStep(targetStep);
setError("");
if (targetStep === 3) setDevStreamTab("java_code");
try {
if (runPath) await apiPost(runPath);
const ctrl = new AbortController();
abortRef.current = ctrl;
await readSSE(
streamPath,
(text) => setStreamText((prev) => prev + text),
(doneData) => applySessionData(doneData),
ctrl.signal,
);
} catch (e) {
setError((e as Error).message);
} finally {
setStreaming(false);
}
}, []);
/* ── Step 0: 创建会话 ── */
2026-03-12 16:33:33 +08:00
const handleStart = useCallback(async () => {
if (!requirement.trim() || loading) return;
setLoading(true);
setError("");
try {
const data: any = await apiPost("/session/start", { requirement: requirement.trim() });
setSessionId(data.session_id);
2026-03-12 18:15:48 +08:00
setRawRequirement(requirement.trim());
2026-03-12 16:33:33 +08:00
setStatus(data.status || "clarifying");
2026-03-12 18:15:48 +08:00
const q = data.clarify_questions || data.question;
if (q) setClarifyHistory([{ role: "assistant", content: q }]);
2026-03-12 16:33:33 +08:00
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [requirement, loading]);
2026-03-12 18:15:48 +08:00
/* ── Step 0: 需求澄清 ── */
2026-03-12 16:33:33 +08:00
const handleClarify = useCallback(async () => {
if (!clarifyInput.trim() || loading) return;
2026-03-12 18:15:48 +08:00
const msg = clarifyInput.trim();
setClarifyHistory((h) => [...h, { role: "user", content: msg }]);
setClarifyInput("");
2026-03-12 16:33:33 +08:00
setLoading(true);
setError("");
try {
2026-03-12 18:15:48 +08:00
const data: any = await apiPost(`/session/${sessionId}/clarify`, { message: msg });
2026-03-12 16:33:33 +08:00
setStatus(data.status || status);
2026-03-12 18:15:48 +08:00
const q = data.clarify_questions || data.question;
if (q) setClarifyHistory((h) => [...h, { role: "assistant", content: q }]);
2026-03-12 16:33:33 +08:00
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [clarifyInput, sessionId, loading, status]);
2026-03-12 18:15:48 +08:00
const handlePmRun = useCallback(() =>
withStreaming(`/session/${sessionId}/pm/stream`, 1),
[sessionId, withStreaming]);
2026-03-12 16:33:33 +08:00
const handlePmRefine = useCallback(async () => {
if (!pmFeedback.trim()) return;
2026-03-12 18:15:48 +08:00
const fb = pmFeedback.trim();
setPmFeedback("");
await withStreaming(`/session/${sessionId}/pm/refine/stream?feedback=${encodeURIComponent(fb)}`, 1);
}, [sessionId, pmFeedback, withStreaming]);
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
const handleQaRun = useCallback(() =>
withStreaming(`/session/${sessionId}/qa/stream`, 2),
[sessionId, withStreaming]);
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
const handleQaRefine = useCallback(async () => {
if (!qaFeedback.trim()) return;
const fb = qaFeedback.trim();
setQaFeedback("");
await withStreaming(`/session/${sessionId}/qa/refine/stream?feedback=${encodeURIComponent(fb)}`, 2);
}, [sessionId, qaFeedback, withStreaming]);
const handleDevRun = useCallback(() =>
withStreaming(`/session/${sessionId}/dev/stream`, 3),
[sessionId, withStreaming]);
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
/* ── Step 4: 测试执行 ── */
2026-03-12 16:33:33 +08:00
const handleTestRun = useCallback(async () => {
setStep(4);
setLoading(true);
setError("");
try {
const data: any = await apiPost(`/session/${sessionId}/test/run`);
2026-03-12 18:15:48 +08:00
const te = data?.data?.test_execution ?? data;
setTestExecution({
success: te.success ?? ((te.failed ?? 0) === 0 && (te.errors ?? 0) === 0),
passed: te.passed ?? 0,
failed: te.failed ?? 0,
errors: te.errors ?? 0,
total: te.total ?? 0,
output: te.output || data.detail || JSON.stringify(data, null, 2),
2026-03-12 16:33:33 +08:00
});
2026-03-12 18:15:48 +08:00
if (data.status) setStatus(data.status);
2026-03-12 16:33:33 +08:00
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [sessionId]);
2026-03-12 18:15:48 +08:00
/* ── AI 自动修复 ── */
2026-03-12 16:33:33 +08:00
const handleTestFix = useCallback(async () => {
2026-03-12 18:15:48 +08:00
setTestExecution(null);
await withStreaming(`/session/${sessionId}/test/fix/stream`, 3);
await handleTestRun();
}, [sessionId, withStreaming, handleTestRun]);
/* ── 用例分组 ── */
const groupedTestCases = useMemo(() => {
const groups: Record<string, TestCase[]> = {};
for (const tc of testCases) {
const t = tc.test_type || "Functional";
if (!groups[t]) groups[t] = [];
groups[t].push(tc);
2026-03-12 16:33:33 +08:00
}
2026-03-12 18:15:48 +08:00
return groups;
}, [testCases]);
function parseNotes(text: string): string[] {
if (!text) return [];
return text.split("\n").map((l) => l.replace(/^\d+\.\s*/, "").trim()).filter((l) => l.length > 0);
}
const currentStep = STATUS_STEP[status] ?? step;
const statusLabel = STATUS_LABEL[status] || status;
2026-03-12 16:33:33 +08:00
return (
2026-03-12 18:15:48 +08:00
<div className="flex flex-col h-full overflow-hidden bg-[#f7f7f9]">
{/* ── 步骤栏 ── */}
<div className="shrink-0 border-b border-border bg-white shadow-[0_1px_8px_rgba(0,0,0,0.04)]">
2026-03-13 16:08:29 +08:00
<div className="flex items-center gap-0 px-[60px] py-0">
2026-03-12 18:15:48 +08:00
{STEPS.map((s, i) => (
<div key={i} className="flex items-center">
<button
onClick={() => { if (sessionId && i <= step) setStep(i as Step); }}
className={`flex items-center gap-2.5 px-5 py-4 text-sm font-semibold border-b-2 transition-all duration-200 ${
i === step
? "border-magenta text-magenta"
: i < step
? "border-transparent text-green-600"
: "border-transparent text-txt-muted"
} ${i <= step ? "cursor-pointer" : "cursor-default"}`}
>
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors ${
i === step
? "bg-magenta text-white"
: i < step
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-400"
}`}>
{i < step ? "✓" : i + 1}
</span>
<span className="hidden sm:block">{s.title}</span>
</button>
{i < STEPS.length - 1 && (
<span className={`mx-1 text-sm ${ i < step ? "text-green-400" : "text-gray-300" }`}></span>
)}
</div>
))}
{sessionId && statusLabel && (
<div className={`ml-auto mr-2 px-3 py-1 text-xs font-bold rounded-full ${
status === "clarifying" ? "bg-amber-50 text-amber-600 border border-amber-200" :
status === "test_done" ? "bg-green-50 text-green-600 border border-green-200" :
"bg-magenta-50 text-magenta border border-magenta/20"
}`}>
{statusLabel}
</div>
)}
</div>
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
{/* Error */}
2026-03-12 16:33:33 +08:00
{error && (
2026-03-13 16:08:29 +08:00
<div className="flex items-center justify-between px-4 py-2.5 mx-10 mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-xl">
2026-03-12 18:15:48 +08:00
<span className="flex items-center gap-2"><span></span>{error}</span>
<button className="ml-4 font-bold text-red-300 hover:text-red-500" onClick={() => setError("")}></button>
2026-03-12 16:33:33 +08:00
</div>
)}
2026-03-12 18:15:48 +08:00
{/* ── 内容区 ── */}
2026-03-13 16:08:29 +08:00
<div className="flex-1 px-[60px] py-6 overflow-y-auto">
<div className="space-y-0">
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
{/* ═══ Step 0: Requirement Input ═══ */}
2026-03-12 16:33:33 +08:00
{step === 0 && !sessionId && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-start gap-4 mb-6">
<div className="flex items-center justify-center w-10 h-10 text-xl rounded-2xl bg-magenta/10 shrink-0">📋</div>
<div>
<h2 className="text-base font-bold text-txt mb-0.5">Enter Requirements</h2>
<p className="text-sm text-txt-muted">Describe your product requirements. AI will complete analysis test cases code generation automatically.</p>
</div>
</div>
2026-03-12 16:33:33 +08:00
<textarea
2026-03-12 18:15:48 +08:00
className="input-field min-h-[160px] resize-y mb-5"
2026-03-12 16:33:33 +08:00
value={requirement}
onChange={(e) => setRequirement(e.target.value)}
2026-03-12 18:15:48 +08:00
placeholder="e.g. Implement a user login feature supporting username/password, log all login events, target 1000 QPS concurrency…"
2026-03-12 16:33:33 +08:00
/>
2026-03-12 18:15:48 +08:00
<div className="flex items-center justify-between">
<span className="text-xs text-txt-muted">{requirement.length > 0 ? `${requirement.length} chars` : ""}</span>
2026-03-12 16:33:33 +08:00
<button className="btn-magenta" onClick={handleStart} disabled={loading || !requirement.trim()}>
2026-03-12 18:15:48 +08:00
{loading ? (
<span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Analyzing</span>
) : "Start AI Analysis →"}
2026-03-12 16:33:33 +08:00
</button>
</div>
</div>
)}
2026-03-12 18:15:48 +08:00
{/* ═══ Step 0: Clarification ═══ */}
2026-03-12 16:33:33 +08:00
{step === 0 && sessionId && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-center gap-3 mb-5">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-magenta/10">💬</div>
<h2 className="text-base font-bold">Requirement Clarification</h2>
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
<div className="p-4 mb-5 text-sm bg-[#f9f9f9] border border-border rounded-xl">
<span className="block mb-1.5 text-[11px] font-bold text-txt-muted uppercase tracking-wide">Original Requirement</span>
<p className="leading-relaxed text-txt">{rawRequirement}</p>
2026-03-12 16:33:33 +08:00
</div>
{clarifyHistory.length > 0 && (
2026-03-12 18:15:48 +08:00
<div className="flex flex-col gap-3 mb-5">
2026-03-12 16:33:33 +08:00
{clarifyHistory.map((msg, i) => (
<div
key={i}
2026-03-12 18:15:48 +08:00
className={`max-w-[85%] px-4 py-3 text-sm rounded-2xl ${
2026-03-12 16:33:33 +08:00
msg.role === "user"
2026-03-12 18:15:48 +08:00
? "self-end bg-gray-100 text-txt"
: "self-start bg-white border border-border shadow-sm"
2026-03-12 16:33:33 +08:00
}`}
>
2026-03-12 18:15:48 +08:00
<span className="block mb-1 text-[11px] font-bold text-txt-muted">
{msg.role === "assistant" ? "🤖 AI Assistant" : "👤 You"}
2026-03-12 16:33:33 +08:00
</span>
2026-03-12 18:15:48 +08:00
<span className="leading-relaxed whitespace-pre-wrap">{msg.content}</span>
2026-03-12 16:33:33 +08:00
</div>
))}
</div>
)}
{status === "clarifying" && (
2026-03-12 18:15:48 +08:00
<div className="pt-4 mt-2 border-t border-border">
<p className="mb-2 text-xs font-semibold tracking-wide uppercase text-txt-muted">Reply to AI</p>
2026-03-12 16:33:33 +08:00
<textarea
className="input-field min-h-[80px] resize-y mb-3"
value={clarifyInput}
onChange={(e) => setClarifyInput(e.target.value)}
2026-03-12 18:15:48 +08:00
placeholder="Enter your additional details…"
2026-03-12 16:33:33 +08:00
disabled={loading}
/>
<div className="flex justify-end">
<button className="btn-magenta" onClick={handleClarify} disabled={loading}>
2026-03-12 18:15:48 +08:00
{loading ? <span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Sending</span> : "Send"}
2026-03-12 16:33:33 +08:00
</button>
</div>
</div>
)}
2026-03-12 18:15:48 +08:00
{status !== "clarifying" && status !== "" && (
<div className="pt-4 mt-2 border-t border-border">
<div className="flex items-center gap-2 px-4 py-3 mb-4 text-sm text-green-700 border border-green-200 bg-green-50 rounded-xl">
<span></span><span>Requirements confirmed. Ready to start PM analysis.</span>
2026-03-12 16:33:33 +08:00
</div>
<div className="flex justify-end">
2026-03-12 18:15:48 +08:00
<button className="btn-magenta" onClick={handlePmRun} disabled={loading || streaming}>
{loading ? "Analyzing…" : "Start PM Analysis →"}
2026-03-12 16:33:33 +08:00
</button>
</div>
</div>
)}
</div>
)}
{/* ═══ Step 1: PM Analysis ═══ */}
{step === 1 && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-orange-50">📋</div>
<h2 className="text-base font-bold">PM Analysis</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
Analyzing {streamText.length} chars
</span>
)}
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
{/* Streaming preview */}
{streaming && pmStreamParsed && (
<div ref={pmStreamRef} className="rounded-xl border border-border bg-[#fafafa] p-4 mb-4 space-y-3 max-h-[55vh] overflow-y-auto">
{([
["functional_requirements", "🔧", "Functional Requirements", "bg-blue-50 text-blue-700"],
["non_functional_requirements", "⚙️", "Non-Functional Requirements", "bg-purple-50 text-purple-700"],
["acceptance_criteria", "✅", "Acceptance Criteria", "bg-green-50 text-green-700"],
["edge_cases", "🚧", "Edge Cases", "bg-amber-50 text-amber-700"],
] as const).map(([key, icon, label, color]) => {
const sec = pmStreamParsed[key];
if (!sec.items.length && pmStreamParsed.currentSection !== key) return null;
return (
<div key={key} className={`transition-opacity ${ pmStreamParsed.currentSection === key ? "opacity-100" : "opacity-50" }`}>
<div className={`inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 ${color}`}>{icon} {label}</div>
<ul className="pl-1 space-y-1">
{sec.items.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 w-1 h-1 rounded-full bg-current shrink-0 opacity-40"></span>
<span>{item.value}{item.active && <span className="text-magenta animate-pulse ml-0.5">|</span>}</span>
</li>
))}
</ul>
</div>
);
})}
{(pmStreamParsed.summary.value || pmStreamParsed.currentSection === "summary") && (
<div className={`transition-opacity ${ pmStreamParsed.currentSection === "summary" ? "opacity-100" : "opacity-50" }`}>
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 bg-gray-100 text-gray-600">📌 Summary</div>
<p className="text-sm leading-relaxed">{pmStreamParsed.summary.value}{pmStreamParsed.summary.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
2026-03-12 16:33:33 +08:00
)}
2026-03-12 18:15:48 +08:00
{!pmStreamParsed.currentSection && (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-magenta/30 border-t-magenta animate-spin"></span>
Waiting for AI output
</div>
)}
</div>
)}
{/* Final result — clean list layout */}
{!streaming && requirementAnalysis && (
<div className="mb-4 divide-y divide-border">
{requirementAnalysis.summary && (
<div className="pb-4 mb-1">
<div className="text-[11px] font-bold text-txt-muted uppercase tracking-wide mb-1.5">Summary</div>
<p className="text-sm leading-relaxed text-txt">{requirementAnalysis.summary}</p>
2026-03-12 16:33:33 +08:00
</div>
)}
2026-03-12 18:15:48 +08:00
{[
{ key: "functional_requirements" as const, label: "Functional Requirements" },
{ key: "non_functional_requirements" as const, label: "Non-Functional Requirements" },
{ key: "acceptance_criteria" as const, label: "Acceptance Criteria" },
{ key: "edge_cases" as const, label: "Edge Cases" },
].map(({ key, label }) => {
const items = requirementAnalysis[key];
if (!items?.length) return null;
return (
<div key={key} className="py-4">
<div className="text-[11px] font-bold text-txt-muted uppercase tracking-wide mb-2">{label}</div>
<ul className="space-y-1.5">
{items.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="w-1 h-1 mt-2 rounded-full bg-txt-muted shrink-0"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
{!streaming && !requirementAnalysis && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-border border-t-magenta animate-spin"></span>
Waiting for results
2026-03-12 16:33:33 +08:00
</div>
)}
2026-03-12 18:15:48 +08:00
{!streaming && (
<div className="flex gap-2 pt-4 mt-4 border-t border-border">
2026-03-12 16:33:33 +08:00
<input
2026-03-12 18:15:48 +08:00
className="flex-1 input-field"
2026-03-12 16:33:33 +08:00
value={pmFeedback}
onChange={(e) => setPmFeedback(e.target.value)}
2026-03-12 18:15:48 +08:00
placeholder="Have feedback? Enter here to regenerate…"
2026-03-12 16:33:33 +08:00
/>
2026-03-12 18:15:48 +08:00
<button className="btn-outline" onClick={handlePmRefine} disabled={!pmFeedback.trim() || loading}>Regenerate</button>
<button className="btn-magenta" onClick={handleQaRun} disabled={loading || !!pmFeedback.trim()}>Generate QA Cases </button>
2026-03-12 16:33:33 +08:00
</div>
)}
</div>
)}
{/* ═══ Step 2: QA Test Cases ═══ */}
{step === 2 && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-green-50">🧪</div>
<h2 className="text-base font-bold">QA Test Cases</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
{qaStreamParsed?.testCases.names.length ?? 0} cases generated
</span>
)}
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
{/* Streaming preview */}
{streaming && qaStreamParsed && (
<div ref={qaStreamRef} className="rounded-xl border border-border bg-[#fafafa] p-4 mb-4 max-h-[55vh] overflow-y-auto space-y-3">
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-1 bg-green-50 text-green-700">🧪 Test Cases</div>
<div className="space-y-1.5">
{qaStreamParsed.testCases.names.map((name, i) => (
<div key={i} className="flex items-center gap-2.5 py-1.5 px-3 bg-white border border-border rounded-lg text-sm">
<span className="font-mono text-[11px] font-bold text-magenta bg-magenta/10 px-1.5 py-0.5 rounded shrink-0">
TC{String(i + 1).padStart(3, "0")}
</span>
<span className="text-txt">{name}</span>
</div>
))}
{qaStreamParsed.testCases.active && (
<div className="flex items-center gap-2 text-sm text-txt-muted py-1.5 px-3">
<span className="w-3.5 h-3.5 border-2 border-green-200 border-t-green-500 rounded-full animate-spin inline-block"></span>
Generating next case
</div>
)}
{!qaStreamParsed.testCases.names.length && (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-magenta/30 border-t-magenta animate-spin"></span>
Waiting
</div>
)}
</div>
{qaStreamParsed.testStrategy.value && (
<div className="pt-3 border-t border-border">
<div className="text-[11px] font-bold text-txt-muted mb-1">📋 Test Strategy</div>
<p className="text-sm">{qaStreamParsed.testStrategy.value}{qaStreamParsed.testStrategy.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
)}
{qaStreamParsed.coveragePlan.value && (
<div className="pt-3 border-t border-border">
<div className="text-[11px] font-bold text-txt-muted mb-1">📊 Coverage Plan</div>
<p className="text-sm">{qaStreamParsed.coveragePlan.value}{qaStreamParsed.coveragePlan.active && <span className="text-magenta animate-pulse">|</span>}</p>
</div>
)}
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
)}
{/* Final result */}
{!streaming && testCases.length > 0 && (
<div className="mb-4 space-y-5">
{Object.entries(groupedTestCases).map(([type, cases]) => (
<div key={type}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold">{TYPE_EMOJI[type] || "🧪"} {TYPE_LABEL[type] || type}</span>
<span className="text-xs text-txt-muted bg-surface-muted px-2 py-0.5 rounded-full">{cases.length}</span>
</div>
<div className="overflow-x-auto border rounded-xl border-border">
<table className="w-full text-sm">
<thead>
<tr className="text-left bg-[#f9f9f9] border-b border-border">
{["ID", "Name", "Precondition", "Steps", "Expected Result"].map(h => (
<th key={h} className="px-3 py-2.5 font-semibold text-xs text-txt-muted whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{cases.map((tc, i) => (
<tr key={tc.test_id || i} className={`border-b border-border last:border-0 ${ i % 2 === 0 ? "bg-white" : "bg-[#fdfdfd]" }`}>
<td className="px-3 py-2.5 font-mono text-[11px] font-bold text-magenta whitespace-nowrap">{tc.test_id}</td>
<td className="px-3 py-2.5 font-medium">{tc.test_name}</td>
<td className="px-3 py-2.5 text-txt-muted text-xs">{tc.precondition}</td>
<td className="px-3 py-2.5">
{Array.isArray(tc.steps) ? (
<ol className="list-decimal list-inside space-y-0.5 text-xs">
{tc.steps.map((s, j) => <li key={j}>{s}</li>)}
</ol>
) : <span className="text-xs">{tc.steps}</span>}
</td>
<td className="px-3 py-2.5 text-xs">{tc.expected_result}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
{(testStrategy || coveragePlan) && (
<div className="grid grid-cols-2 gap-3 pt-1">
{testStrategy && (
<div className="p-4 bg-white border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">📋</span>
<span className="text-xs font-bold tracking-wide uppercase text-txt">Test Strategy</span>
</div>
<p className="text-sm leading-relaxed text-txt-muted">{testStrategy}</p>
</div>
)}
{coveragePlan && (
<div className="p-4 bg-white border border-border rounded-xl">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">📊</span>
<span className="text-xs font-bold tracking-wide uppercase text-txt">Coverage Plan</span>
</div>
<p className="text-sm leading-relaxed text-txt-muted">{coveragePlan}</p>
</div>
)}
</div>
)}
2026-03-12 16:33:33 +08:00
</div>
)}
2026-03-12 18:15:48 +08:00
{!streaming && testCases.length === 0 && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-txt-muted">
<span className="inline-block w-4 h-4 border-2 rounded-full border-border border-t-magenta animate-spin"></span>
Waiting for test cases
</div>
)}
{!streaming && (
<div className="flex gap-2 pt-4 mt-4 border-t border-border">
2026-03-12 16:33:33 +08:00
<input
2026-03-12 18:15:48 +08:00
className="flex-1 input-field"
2026-03-12 16:33:33 +08:00
value={qaFeedback}
onChange={(e) => setQaFeedback(e.target.value)}
2026-03-12 18:15:48 +08:00
placeholder="Have feedback? Enter here to regenerate…"
2026-03-12 16:33:33 +08:00
/>
2026-03-12 18:15:48 +08:00
<button className="btn-outline" onClick={handleQaRefine} disabled={!qaFeedback.trim() || loading}>Regenerate</button>
<button className="btn-magenta" onClick={handleDevRun} disabled={loading || !!qaFeedback.trim()}>Generate Dev Code </button>
2026-03-12 16:33:33 +08:00
</div>
)}
</div>
)}
2026-03-12 18:15:48 +08:00
{/* ═══ Step 3: Dev Code Generation ═══ */}
2026-03-12 16:33:33 +08:00
{step === 3 && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base bg-gray-100 rounded-xl">💻</div>
<h2 className="text-base font-bold">Dev Code Generation</h2>
</div>
{streaming && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
Generating {streamText.length} chars
</span>
)}
2026-03-12 16:33:33 +08:00
</div>
{/* Tabs */}
2026-03-12 18:15:48 +08:00
<div className="flex gap-1 p-1 bg-[#f3f3f3] rounded-xl mb-4">
{([
["java_code", "🐍 Business Code"],
["unit_tests", "🧪 Unit Tests"],
["implementation_notes", "📄 Implementation Notes"],
] as const).map(([key, label]) => (
2026-03-12 16:33:33 +08:00
<button
key={key}
2026-03-12 18:15:48 +08:00
className={`flex-1 px-3 py-1.5 text-sm font-semibold rounded-lg transition-all ${
(streaming ? devStreamTab : codeTab) === key
? "bg-white text-magenta shadow-sm"
: "text-txt-muted hover:text-txt"
2026-03-12 16:33:33 +08:00
}`}
2026-03-12 18:15:48 +08:00
onClick={() => streaming ? setDevStreamTab(key) : setCodeTab(key)}
2026-03-12 16:33:33 +08:00
>
{label}
</button>
))}
</div>
2026-03-12 18:15:48 +08:00
<div className="relative rounded-xl overflow-hidden border border-[#2a2a2a]">
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[#333]">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
<span className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
<span className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
</div>
<span className="text-[11px] text-gray-500 font-mono">
{(streaming ? devStreamTab : codeTab) === "implementation_notes" ? "notes.txt" :
(streaming ? devStreamTab : codeTab) === "unit_tests" ? "test_service.py" : "service.py"}
</span>
{!streaming && (
<button
className="text-[11px] text-gray-400 hover:text-gray-200 transition-colors"
onClick={() => {
const code = codeTab === "java_code" ? codeGeneration?.java_code
: codeTab === "unit_tests" ? codeGeneration?.unit_tests
: codeGeneration?.implementation_notes;
if (code) navigator.clipboard.writeText(code);
}}
>
Copy
</button>
)}
</div>
<pre ref={devStreamRef} className="bg-[#1a1a1a] text-gray-300 px-5 py-4 text-[0.82rem] font-mono leading-relaxed max-h-[50vh] overflow-y-auto whitespace-pre-wrap">
{streaming
? devStreamParsed?.[devStreamTab] || "Waiting for Dev Agent output…"
: codeTab === "java_code"
? codeGeneration?.java_code || "No code yet"
: codeTab === "unit_tests"
? codeGeneration?.unit_tests || "No tests yet"
: parseNotes(codeGeneration?.implementation_notes || "").join("\n") || "No notes yet"
}
</pre>
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
{!streaming && (
<div className="flex justify-end pt-4 mt-4 border-t border-border">
<button className="btn-magenta" onClick={handleTestRun} disabled={loading}>
Run Unit Tests
2026-03-12 16:33:33 +08:00
</button>
</div>
)}
</div>
)}
{/* ═══ Step 4: Test Execution ═══ */}
{step === 4 && (
<div className="card">
2026-03-12 18:15:48 +08:00
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-purple-50"></div>
<h2 className="text-base font-bold">Test Execution</h2>
</div>
{loading && (
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
<span className="w-3.5 h-3.5 border-2 border-magenta/30 border-t-magenta rounded-full animate-spin inline-block"></span>
Running
</span>
)}
2026-03-12 16:33:33 +08:00
</div>
2026-03-12 18:15:48 +08:00
{testExecution ? (
2026-03-12 16:33:33 +08:00
<>
2026-03-12 18:15:48 +08:00
{/* 状态横幅 */}
<div className={`flex items-center gap-3 px-5 py-4 rounded-xl mb-5 ${
testExecution.success
? "bg-green-50 border border-green-200"
: "bg-red-50 border border-red-200"
}`}>
<span className="text-2xl">{testExecution.success ? "✅" : "❌"}</span>
<div>
<div className={`text-sm font-bold ${ testExecution.success ? "text-green-800" : "text-red-800" }`}>
{testExecution.success
? `All ${testExecution.total} tests passed!`
: `${testExecution.failed + testExecution.errors} test(s) failed`
}
</div>
{!testExecution.success && (
<div className="text-xs text-red-600 mt-0.5">Click "AI Auto Fix" to automatically fix the failing tests.</div>
)}
2026-03-12 16:33:33 +08:00
</div>
</div>
2026-03-12 18:15:48 +08:00
{/* 数据统计 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{[
{ num: testExecution.passed, label: "Passed", color: "text-green-600", bg: "bg-green-50 border-green-100", icon: "✅" },
{ num: testExecution.failed, label: "Failed", color: "text-red-500", bg: "bg-red-50 border-red-100", icon: "❌" },
{ num: testExecution.errors, label: "Errors", color: "text-amber-500", bg: "bg-amber-50 border-amber-100", icon: "⚠️" },
{ num: testExecution.total, label: "Total", color: "text-txt", bg: "bg-[#f9f9f9] border-border", icon: "📊" },
].map(({ num, label, color, bg, icon }) => (
<div key={label} className={`border rounded-xl p-4 text-center ${bg}`}>
<div className={`text-3xl font-extrabold mb-1 ${color}`}>{num}</div>
<div className="text-[11px] font-semibold text-txt-muted">{icon} {label}</div>
</div>
))}
</div>
2026-03-12 16:33:33 +08:00
2026-03-12 18:15:48 +08:00
{/* 输出 */}
<div className="relative rounded-xl overflow-hidden border border-[#2a2a2a]">
<div className="flex items-center justify-between px-4 py-2 bg-[#252525] border-b border-[#333]">
<span className="text-[11px] text-gray-400 font-semibold">🖥 pytest output</span>
<button
className="text-[11px] text-gray-400 hover:text-gray-200 transition-colors"
onClick={() => navigator.clipboard.writeText(testExecution.output)}
>
Copy
</button>
</div>
<pre className="bg-[#1a1a1a] text-gray-300 px-5 py-4 text-[0.82rem] font-mono leading-relaxed max-h-[40vh] overflow-y-auto whitespace-pre-wrap">
{testExecution.output}
</pre>
</div>
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-border">
<button className="btn-outline" onClick={handleTestRun} disabled={loading}>🔄 Re-run</button>
{!testExecution.success && (
<button className="btn-magenta" onClick={handleTestFix} disabled={loading || streaming}>
🔧 AI Auto Fix
2026-03-12 16:33:33 +08:00
</button>
)}
</div>
</>
) : (
2026-03-12 18:15:48 +08:00
<div className="flex flex-col items-center justify-center gap-3 py-14">
{loading ? (
<>
<span className="w-8 h-8 border-2 rounded-full border-magenta/20 border-t-magenta animate-spin"></span>
<span className="text-sm text-txt-muted">Running pytest</span>
</>
) : (
<>
<span className="text-3xl">🧪</span>
<span className="text-sm text-txt-muted">Ready. Click the button below to run tests.</span>
<button className="mt-2 btn-magenta" onClick={handleTestRun}> Run Unit Tests</button>
</>
)}
2026-03-12 16:33:33 +08:00
</div>
)}
</div>
)}
<div ref={bottomRef} />
</div>
</div>
</div>
);
}