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)]">
|
|
|
|
|
|
<div className="flex items-center gap-0 px-8 py-0">
|
|
|
|
|
|
{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-12 18:15:48 +08:00
|
|
|
|
<div className="flex items-center justify-between px-4 py-2.5 mx-8 mt-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-xl">
|
|
|
|
|
|
<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
|
|
|
|
{/* ── 内容区 ── */}
|
|
|
|
|
|
<div className="flex-1 px-8 py-6 overflow-y-auto">
|
|
|
|
|
|
<div className="max-w-5xl mx-auto 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|