678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
|
import { API } from "../config";
|
|||
|
|
|
|||
|
|
/* ─── Types ─── */
|
|||
|
|
type Step = 0 | 1 | 2 | 3 | 4;
|
|||
|
|
|
|||
|
|
type ClarifyMsg = { role: "user" | "assistant"; content: string };
|
|||
|
|
|
|||
|
|
type PMResult = {
|
|||
|
|
summary: string;
|
|||
|
|
functional: string[];
|
|||
|
|
nonfunctional: string[];
|
|||
|
|
acceptance: string[];
|
|||
|
|
edge_cases: string[];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type TestCase = {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
precondition: string;
|
|||
|
|
steps: string;
|
|||
|
|
expected: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type TestResult = {
|
|||
|
|
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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function readSSE(path: string, onChunk: (text: string) => void, signal?: AbortSignal) {
|
|||
|
|
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 });
|
|||
|
|
// process SSE lines
|
|||
|
|
let idx = buf.indexOf("\n");
|
|||
|
|
while (idx >= 0) {
|
|||
|
|
const line = buf.slice(0, idx).trim();
|
|||
|
|
buf = buf.slice(idx + 1);
|
|||
|
|
if (line.startsWith("data:")) {
|
|||
|
|
const payload = line.slice(5).trim();
|
|||
|
|
if (payload === "[DONE]") return;
|
|||
|
|
try {
|
|||
|
|
const evt = JSON.parse(payload);
|
|||
|
|
if (evt.text) onChunk(evt.text);
|
|||
|
|
else if (evt.message) onChunk(evt.message);
|
|||
|
|
else if (typeof evt === "string") onChunk(evt);
|
|||
|
|
} catch {
|
|||
|
|
onChunk(payload);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
idx = buf.indexOf("\n");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
reader.releaseLock();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Step labels ── */
|
|||
|
|
const STEPS: { title: string; icon: string }[] = [
|
|||
|
|
{ title: "Clarification", icon: "💬" },
|
|||
|
|
{ title: "PM Analysis", icon: "📋" },
|
|||
|
|
{ title: "QA Cases", icon: "🧪" },
|
|||
|
|
{ title: "Dev Output", icon: "💻" },
|
|||
|
|
{ title: "Test Run", icon: "▶" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */
|
|||
|
|
export default function DevOpsAgent() {
|
|||
|
|
const [step, setStep] = useState<Step>(0);
|
|||
|
|
const [sessionId, setSessionId] = useState("");
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [error, setError] = useState("");
|
|||
|
|
|
|||
|
|
// Step 0 — Requirement
|
|||
|
|
const [requirement, setRequirement] = useState("");
|
|||
|
|
const [rawReq, setRawReq] = useState("");
|
|||
|
|
const [status, setStatus] = useState<string>("");
|
|||
|
|
const [clarifyHistory, setClarifyHistory] = useState<ClarifyMsg[]>([]);
|
|||
|
|
const [clarifyInput, setClarifyInput] = useState("");
|
|||
|
|
|
|||
|
|
// Step 1 — PM analysis
|
|||
|
|
const [pmStream, setPmStream] = useState("");
|
|||
|
|
const [pmResult, setPmResult] = useState<PMResult | null>(null);
|
|||
|
|
const [pmFeedback, setPmFeedback] = useState("");
|
|||
|
|
|
|||
|
|
// Step 2 — QA test cases
|
|||
|
|
const [qaStream, setQaStream] = useState("");
|
|||
|
|
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
|||
|
|
const [qaFeedback, setQaFeedback] = useState("");
|
|||
|
|
|
|||
|
|
// Step 3 — Dev code
|
|||
|
|
const [devStream, setDevStream] = useState("");
|
|||
|
|
const [codeTab, setCodeTab] = useState<"code" | "test" | "notes">("code");
|
|||
|
|
const [devCode, setDevCode] = useState("");
|
|||
|
|
const [devTest, setDevTest] = useState("");
|
|||
|
|
const [devNotes, setDevNotes] = useState("");
|
|||
|
|
|
|||
|
|
// Step 4 — Test results
|
|||
|
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|||
|
|
|
|||
|
|
const abortRef = useRef<AbortController | null>(null);
|
|||
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|||
|
|
}, [pmStream, qaStream, devStream]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => abortRef.current?.abort();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
/* ── Step 0: Start session ── */
|
|||
|
|
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);
|
|||
|
|
setRawReq(requirement.trim());
|
|||
|
|
setStatus(data.status || "clarifying");
|
|||
|
|
if (data.clarify_questions) {
|
|||
|
|
setClarifyHistory([{ role: "assistant", content: data.clarify_questions }]);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [requirement, loading]);
|
|||
|
|
|
|||
|
|
/* ── Step 0: Clarify ── */
|
|||
|
|
const handleClarify = useCallback(async () => {
|
|||
|
|
if (!clarifyInput.trim() || loading) return;
|
|||
|
|
setClarifyHistory((h) => [...h, { role: "user", content: clarifyInput.trim() }]);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
try {
|
|||
|
|
const data: any = await apiPost(`/session/${sessionId}/clarify`, { message: clarifyInput.trim() });
|
|||
|
|
setClarifyInput("");
|
|||
|
|
setStatus(data.status || status);
|
|||
|
|
if (data.clarify_questions) {
|
|||
|
|
setClarifyHistory((h) => [...h, { role: "assistant", content: data.clarify_questions }]);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [clarifyInput, sessionId, loading, status]);
|
|||
|
|
|
|||
|
|
/* ── Step 1: PM Run ── */
|
|||
|
|
const handlePmRun = useCallback(async () => {
|
|||
|
|
setStep(1);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
setPmStream("");
|
|||
|
|
try {
|
|||
|
|
await apiPost(`/session/${sessionId}/pm/run`);
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
abortRef.current = ctrl;
|
|||
|
|
let full = "";
|
|||
|
|
await readSSE(`/session/${sessionId}/pm/stream`, (text) => {
|
|||
|
|
full += text;
|
|||
|
|
setPmStream(full);
|
|||
|
|
}, ctrl.signal);
|
|||
|
|
// Try to parse structured result
|
|||
|
|
try {
|
|||
|
|
const json = JSON.parse(full);
|
|||
|
|
setPmResult(json);
|
|||
|
|
} catch {
|
|||
|
|
setPmResult({ summary: full, functional: [], nonfunctional: [], acceptance: [], edge_cases: [] });
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId]);
|
|||
|
|
|
|||
|
|
/* ── Step 1: PM Refine ── */
|
|||
|
|
const handlePmRefine = useCallback(async () => {
|
|||
|
|
if (!pmFeedback.trim()) return;
|
|||
|
|
setLoading(true);
|
|||
|
|
setPmStream("");
|
|||
|
|
try {
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
abortRef.current = ctrl;
|
|||
|
|
let full = "";
|
|||
|
|
await readSSE(
|
|||
|
|
`/session/${sessionId}/pm/refine/stream?feedback=${encodeURIComponent(pmFeedback.trim())}`,
|
|||
|
|
(text) => {
|
|||
|
|
full += text;
|
|||
|
|
setPmStream(full);
|
|||
|
|
},
|
|||
|
|
ctrl.signal,
|
|||
|
|
);
|
|||
|
|
setPmFeedback("");
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId, pmFeedback]);
|
|||
|
|
|
|||
|
|
/* ── Step 2: QA Run ── */
|
|||
|
|
const handleQaRun = useCallback(async () => {
|
|||
|
|
setStep(2);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
setQaStream("");
|
|||
|
|
try {
|
|||
|
|
await apiPost(`/session/${sessionId}/qa/run`);
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
abortRef.current = ctrl;
|
|||
|
|
let full = "";
|
|||
|
|
await readSSE(`/session/${sessionId}/qa/stream`, (text) => {
|
|||
|
|
full += text;
|
|||
|
|
setQaStream(full);
|
|||
|
|
}, ctrl.signal);
|
|||
|
|
// Try parse
|
|||
|
|
try {
|
|||
|
|
const json = JSON.parse(full);
|
|||
|
|
if (json.test_cases) setTestCases(json.test_cases);
|
|||
|
|
} catch {
|
|||
|
|
/* raw display */
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId]);
|
|||
|
|
|
|||
|
|
/* ── Step 3: Dev Run ── */
|
|||
|
|
const handleDevRun = useCallback(async () => {
|
|||
|
|
setStep(3);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
setDevStream("");
|
|||
|
|
try {
|
|||
|
|
await apiPost(`/session/${sessionId}/dev/run`);
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
abortRef.current = ctrl;
|
|||
|
|
let full = "";
|
|||
|
|
await readSSE(`/session/${sessionId}/dev/stream`, (text) => {
|
|||
|
|
full += text;
|
|||
|
|
setDevStream(full);
|
|||
|
|
}, ctrl.signal);
|
|||
|
|
// Parse code blocks
|
|||
|
|
try {
|
|||
|
|
const json = JSON.parse(full);
|
|||
|
|
setDevCode(json.code || json.implementation || full);
|
|||
|
|
setDevTest(json.tests || json.test_code || "");
|
|||
|
|
setDevNotes(json.notes || json.implementation_notes || "");
|
|||
|
|
} catch {
|
|||
|
|
setDevCode(full);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId]);
|
|||
|
|
|
|||
|
|
/* ── Step 4: Test Run ── */
|
|||
|
|
const handleTestRun = useCallback(async () => {
|
|||
|
|
setStep(4);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError("");
|
|||
|
|
try {
|
|||
|
|
const data: any = await apiPost(`/session/${sessionId}/test/run`);
|
|||
|
|
setTestResult({
|
|||
|
|
passed: data.passed ?? 0,
|
|||
|
|
failed: data.failed ?? 0,
|
|||
|
|
errors: data.errors ?? 0,
|
|||
|
|
total: data.total ?? 0,
|
|||
|
|
output: data.output || data.detail || JSON.stringify(data, null, 2),
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId]);
|
|||
|
|
|
|||
|
|
/* ── Test Fix ── */
|
|||
|
|
const handleTestFix = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const ctrl = new AbortController();
|
|||
|
|
abortRef.current = ctrl;
|
|||
|
|
let full = "";
|
|||
|
|
await readSSE(`/session/${sessionId}/test/fix/stream`, (text) => {
|
|||
|
|
full += text;
|
|||
|
|
setDevStream(full);
|
|||
|
|
}, ctrl.signal);
|
|||
|
|
// Re-run test
|
|||
|
|
await handleTestRun();
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [sessionId, handleTestRun]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|||
|
|
{/* ── Step bar ── */}
|
|||
|
|
<div className="flex items-center gap-1 px-10 py-4 border-b border-border bg-white/80 backdrop-blur shrink-0">
|
|||
|
|
{STEPS.map((s, i) => (
|
|||
|
|
<div key={i} className="flex items-center gap-1">
|
|||
|
|
<button
|
|||
|
|
onClick={() => { if (sessionId && i <= step) setStep(i as Step); }}
|
|||
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-bold transition-colors ${
|
|||
|
|
i === step
|
|||
|
|
? "text-magenta"
|
|||
|
|
: i < step
|
|||
|
|
? "text-green-600"
|
|||
|
|
: "text-txt-muted"
|
|||
|
|
} ${i <= step ? "cursor-pointer hover:text-magenta" : "cursor-default"}`}
|
|||
|
|
>
|
|||
|
|
<span>{i < step ? "✓" : s.icon}</span>
|
|||
|
|
<span>{s.title}</span>
|
|||
|
|
</button>
|
|||
|
|
{i < STEPS.length - 1 && <span className="text-gray-300 mx-1">→</span>}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Error banner */}
|
|||
|
|
{error && (
|
|||
|
|
<div className="mx-10 mt-4 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
|||
|
|
{error}
|
|||
|
|
<button className="font-bold text-red-400 hover:text-red-600" onClick={() => setError("")}>✕</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ── Content ── */}
|
|||
|
|
<div className="flex-1 overflow-y-auto px-10 py-8">
|
|||
|
|
<div className="max-w-4xl mx-auto">
|
|||
|
|
|
|||
|
|
{/* ═══ Step 0: Requirement ═══ */}
|
|||
|
|
{step === 0 && !sessionId && (
|
|||
|
|
<div className="card">
|
|||
|
|
<h2 className="text-lg font-extrabold mb-1">📋 Describe Product Requirement</h2>
|
|||
|
|
<p className="text-sm text-txt-muted mb-6">Enter your product requirement, the system will automatically perform requirement clarification and analysis</p>
|
|||
|
|
<textarea
|
|||
|
|
className="input-field min-h-[160px] resize-y mb-4"
|
|||
|
|
value={requirement}
|
|||
|
|
onChange={(e) => setRequirement(e.target.value)}
|
|||
|
|
placeholder="Example: Build a predictive battery maintenance system for 500k concurrent uploads with end-to-end latency < 3s..."
|
|||
|
|
/>
|
|||
|
|
<div className="flex justify-end">
|
|||
|
|
<button className="btn-magenta" onClick={handleStart} disabled={loading || !requirement.trim()}>
|
|||
|
|
{loading ? "Creating..." : "Start Analysis →"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{step === 0 && sessionId && (
|
|||
|
|
<div className="card">
|
|||
|
|
<div className="flex items-center gap-3 mb-4">
|
|||
|
|
<span className="text-lg">💬</span>
|
|||
|
|
<h2 className="text-lg font-extrabold">Requirement Clarification</h2>
|
|||
|
|
<span className={`badge ${status === "clarifying" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"}`}>
|
|||
|
|
{status === "clarifying" ? "Clarifying" : "Confirmed"}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Original req */}
|
|||
|
|
<div className="bg-surface-muted p-4 mb-4 text-sm">
|
|||
|
|
<span className="text-xs font-bold text-txt-muted block mb-1">📋 Original Requirement</span>
|
|||
|
|
{rawReq}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Chat history */}
|
|||
|
|
{clarifyHistory.length > 0 && (
|
|||
|
|
<div className="flex flex-col gap-3 mb-4">
|
|||
|
|
{clarifyHistory.map((msg, i) => (
|
|||
|
|
<div
|
|||
|
|
key={i}
|
|||
|
|
className={`max-w-[80%] px-4 py-3 text-sm ${
|
|||
|
|
msg.role === "user"
|
|||
|
|
? "self-end bg-surface-muted"
|
|||
|
|
: "self-start border border-border border-l-4 border-l-magenta"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span className="text-xs font-bold text-txt-muted block mb-1">
|
|||
|
|
{msg.role === "assistant" ? "🤖 AI" : "👤 You"}
|
|||
|
|
</span>
|
|||
|
|
{msg.content}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Clarify input */}
|
|||
|
|
{status === "clarifying" && (
|
|||
|
|
<div className="border-t border-border pt-4 mt-4">
|
|||
|
|
<p className="text-sm font-bold text-txt-muted mb-2">💬 Answer the AI follow-up:</p>
|
|||
|
|
<textarea
|
|||
|
|
className="input-field min-h-[80px] resize-y mb-3"
|
|||
|
|
value={clarifyInput}
|
|||
|
|
onChange={(e) => setClarifyInput(e.target.value)}
|
|||
|
|
placeholder="Add extra context..."
|
|||
|
|
disabled={loading}
|
|||
|
|
/>
|
|||
|
|
<div className="flex justify-end">
|
|||
|
|
<button className="btn-magenta" onClick={handleClarify} disabled={loading}>
|
|||
|
|
{loading ? "Sending..." : "Send"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Ready for PM */}
|
|||
|
|
{status !== "clarifying" && (
|
|||
|
|
<div className="border-t border-border pt-4 mt-4">
|
|||
|
|
<div className="bg-green-50 text-green-800 px-4 py-2 text-sm mb-4">
|
|||
|
|
✅ Requirement is confirmed and ready for PM analysis.
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-end">
|
|||
|
|
<button className="btn-magenta" onClick={handlePmRun} disabled={loading}>
|
|||
|
|
{loading ? "Analyzing..." : "Run PM Analysis →"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ═══ Step 1: PM Analysis ═══ */}
|
|||
|
|
{step === 1 && (
|
|||
|
|
<div className="card">
|
|||
|
|
<div className="flex items-center gap-3 mb-4">
|
|||
|
|
<span className="text-lg">📋</span>
|
|||
|
|
<h2 className="text-lg font-extrabold">PM Requirement Analysis</h2>
|
|||
|
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Streaming output...</span>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Streaming/result display */}
|
|||
|
|
<div className="bg-surface-muted p-6 text-sm leading-relaxed whitespace-pre-wrap mb-4 max-h-[50vh] overflow-y-auto">
|
|||
|
|
{pmStream || "Waiting for stream..."}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Structured result sections */}
|
|||
|
|
{pmResult && pmResult.functional?.length > 0 && (
|
|||
|
|
<div className="space-y-3 mb-4">
|
|||
|
|
{pmResult.summary && (
|
|||
|
|
<div><span className="text-xs font-bold text-txt-muted">📌 Summary</span><p className="text-sm mt-1">{pmResult.summary}</p></div>
|
|||
|
|
)}
|
|||
|
|
{pmResult.functional.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<span className="text-xs font-bold text-txt-muted">🔧 Functional Requirements</span>
|
|||
|
|
<ul className="text-sm mt-1 list-disc list-inside">{pmResult.functional.map((f, i) => <li key={i}>{f}</li>)}</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Feedback */}
|
|||
|
|
{!loading && (
|
|||
|
|
<div className="border-t border-border pt-4 mt-4 flex gap-3">
|
|||
|
|
<input
|
|||
|
|
className="input-field flex-1"
|
|||
|
|
value={pmFeedback}
|
|||
|
|
onChange={(e) => setPmFeedback(e.target.value)}
|
|||
|
|
placeholder="Provide feedback to refine PM output..."
|
|||
|
|
/>
|
|||
|
|
<button className="btn-outline" onClick={handlePmRefine} disabled={!pmFeedback.trim()}>Refine</button>
|
|||
|
|
<button className="btn-magenta" onClick={handleQaRun}>Generate QA Cases →</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ═══ Step 2: QA Test Cases ═══ */}
|
|||
|
|
{step === 2 && (
|
|||
|
|
<div className="card">
|
|||
|
|
<div className="flex items-center gap-3 mb-4">
|
|||
|
|
<span className="text-lg">🧪</span>
|
|||
|
|
<h2 className="text-lg font-extrabold">QA Test Cases</h2>
|
|||
|
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Generating...</span>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Test cases table */}
|
|||
|
|
{testCases.length > 0 ? (
|
|||
|
|
<div className="overflow-x-auto mb-4">
|
|||
|
|
<table className="w-full text-sm border border-border">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="bg-surface-muted text-left">
|
|||
|
|
<th className="px-3 py-2 border-b border-border font-bold">ID</th>
|
|||
|
|
<th className="px-3 py-2 border-b border-border font-bold">Case</th>
|
|||
|
|
<th className="px-3 py-2 border-b border-border font-bold">Precondition</th>
|
|||
|
|
<th className="px-3 py-2 border-b border-border font-bold">Steps</th>
|
|||
|
|
<th className="px-3 py-2 border-b border-border font-bold">Expected</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{testCases.map((tc, i) => (
|
|||
|
|
<tr key={tc.id || i} className="border-b border-border">
|
|||
|
|
<td className="px-3 py-2 font-mono text-xs">{tc.id}</td>
|
|||
|
|
<td className="px-3 py-2">{tc.name}</td>
|
|||
|
|
<td className="px-3 py-2 text-txt-muted">{tc.precondition}</td>
|
|||
|
|
<td className="px-3 py-2">{tc.steps}</td>
|
|||
|
|
<td className="px-3 py-2">{tc.expected}</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="bg-surface-muted p-6 text-sm leading-relaxed whitespace-pre-wrap mb-4 max-h-[50vh] overflow-y-auto">
|
|||
|
|
{qaStream || "Waiting for stream..."}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && (
|
|||
|
|
<div className="border-t border-border pt-4 mt-4 flex gap-3">
|
|||
|
|
<input
|
|||
|
|
className="input-field flex-1"
|
|||
|
|
value={qaFeedback}
|
|||
|
|
onChange={(e) => setQaFeedback(e.target.value)}
|
|||
|
|
placeholder="Provide feedback to refine QA output..."
|
|||
|
|
/>
|
|||
|
|
<button className="btn-outline" disabled={!qaFeedback.trim()}>Refine</button>
|
|||
|
|
<button className="btn-magenta" onClick={handleDevRun}>Generate Dev Output →</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ═══ Step 3: Dev Code ═══ */}
|
|||
|
|
{step === 3 && (
|
|||
|
|
<div className="card">
|
|||
|
|
<div className="flex items-center gap-3 mb-4">
|
|||
|
|
<span className="text-lg">💻</span>
|
|||
|
|
<h2 className="text-lg font-extrabold">Dev Code Generation</h2>
|
|||
|
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Generating...</span>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Tabs */}
|
|||
|
|
<div className="flex gap-1 mb-4 border-b border-border">
|
|||
|
|
{(
|
|||
|
|
[
|
|||
|
|
["code", "🐍 Service Code"],
|
|||
|
|
["test", "🧪 Unit Tests"],
|
|||
|
|
["notes", "📄 Notes"],
|
|||
|
|
] as const
|
|||
|
|
).map(([key, label]) => (
|
|||
|
|
<button
|
|||
|
|
key={key}
|
|||
|
|
className={`px-4 py-2 text-sm font-bold border-b-2 transition-colors ${
|
|||
|
|
codeTab === key ? "border-magenta text-magenta" : "border-transparent text-txt-muted hover:text-txt"
|
|||
|
|
}`}
|
|||
|
|
onClick={() => setCodeTab(key)}
|
|||
|
|
>
|
|||
|
|
{label}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-surface-dark text-gray-300 p-6 text-[0.85rem] font-mono leading-relaxed max-h-[50vh] overflow-y-auto whitespace-pre-wrap">
|
|||
|
|
{loading
|
|||
|
|
? devStream || "Waiting for stream..."
|
|||
|
|
: codeTab === "code"
|
|||
|
|
? devCode || "No service code"
|
|||
|
|
: codeTab === "test"
|
|||
|
|
? devTest || "No test code"
|
|||
|
|
: devNotes || "No notes"
|
|||
|
|
}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{!loading && (
|
|||
|
|
<div className="border-t border-border pt-4 mt-4 flex justify-end gap-3">
|
|||
|
|
<button
|
|||
|
|
className="btn-outline"
|
|||
|
|
onClick={() => {
|
|||
|
|
navigator.clipboard.writeText(codeTab === "code" ? devCode : codeTab === "test" ? devTest : devNotes);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
Copy
|
|||
|
|
</button>
|
|||
|
|
<button className="btn-magenta" onClick={handleTestRun}>Run Tests →</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ═══ Step 4: Test Execution ═══ */}
|
|||
|
|
{step === 4 && (
|
|||
|
|
<div className="card">
|
|||
|
|
<div className="flex items-center gap-3 mb-4">
|
|||
|
|
<span className="text-lg">▶</span>
|
|||
|
|
<h2 className="text-lg font-extrabold">Test Execution</h2>
|
|||
|
|
{loading && <span className="text-xs text-magenta font-bold animate-pulse">Running...</span>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{testResult ? (
|
|||
|
|
<>
|
|||
|
|
{/* Stats */}
|
|||
|
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
|||
|
|
<div className="bg-green-50 p-4 text-center">
|
|||
|
|
<div className="text-2xl font-extrabold text-green-600">{testResult.passed}</div>
|
|||
|
|
<div className="text-xs font-bold text-green-700 mt-1">✅ Passed</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-red-50 p-4 text-center">
|
|||
|
|
<div className="text-2xl font-extrabold text-red-600">{testResult.failed}</div>
|
|||
|
|
<div className="text-xs font-bold text-red-700 mt-1">❌ Failed</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-yellow-50 p-4 text-center">
|
|||
|
|
<div className="text-2xl font-extrabold text-yellow-600">{testResult.errors}</div>
|
|||
|
|
<div className="text-xs font-bold text-yellow-700 mt-1">⚠️ Errors</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-surface-muted p-4 text-center">
|
|||
|
|
<div className="text-2xl font-extrabold text-txt">{testResult.total}</div>
|
|||
|
|
<div className="text-xs font-bold text-txt-muted mt-1">Total</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Output */}
|
|||
|
|
<pre className="bg-surface-dark text-gray-300 p-6 text-[0.85rem] font-mono leading-relaxed max-h-[40vh] overflow-y-auto whitespace-pre-wrap">
|
|||
|
|
{testResult.output}
|
|||
|
|
</pre>
|
|||
|
|
|
|||
|
|
<div className="border-t border-border pt-4 mt-4 flex justify-end gap-3">
|
|||
|
|
<button className="btn-outline" onClick={handleTestRun} disabled={loading}>
|
|||
|
|
Re-run
|
|||
|
|
</button>
|
|||
|
|
{testResult.failed > 0 && (
|
|||
|
|
<button className="btn-magenta" onClick={handleTestFix} disabled={loading}>
|
|||
|
|
AI Auto-fix
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-12 text-txt-muted text-sm">
|
|||
|
|
{loading ? "Running pytest..." : "Waiting for test execution"}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div ref={bottomRef} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|