stack/apps/internal-tool/src/components/CallLogDetail.tsx
aadesh18 5341371782
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
LLM MCP Flow (#1321)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automated AI QA review pipeline and human-verified knowledge base
consulted first
* Internal MCP review tool: call log viewer, conversation replay,
add/edit/publish Q&A, knowledge editor, and analytics
  * Docs search now preserves follow-up conversation context

* **Documentation**
  * Added “Ask DeepWiki” badge to README

* **Chores**
* Added local SpacetimeDB background service and internal-tool app
scaffolding
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mantrakp04 <mantrakp@gmail.com>
Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
2026-04-15 17:57:08 +00:00

689 lines
25 KiB
TypeScript

import { clsx } from "clsx";
import { format, formatDistanceToNow } from "date-fns";
import { useState, useEffect } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { McpCallLogRow } from "../types";
import { toDate } from "../utils";
import { ConversationReplay } from "./ConversationReplay";
import { markdownComponents } from "./markdown-components";
// ─── Shared ────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
className="text-xs text-blue-500 hover:text-blue-700 ml-2"
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, (err) => {
console.error("Clipboard write failed:", err);
});
}}
>
{copied ? "copied" : "copy"}
</button>
);
}
// ─── Main Component ────────────────────────────────────
export function CallLogDetail({ row, allRows, onClose, onSaveCorrection, onMarkReviewed }: {
row: McpCallLogRow;
allRows: McpCallLogRow[];
onClose: () => void;
onSaveCorrection?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => Promise<void> | void;
onMarkReviewed?: (correlationId: string) => Promise<void> | void;
}) {
const [showReplay, setShowReplay] = useState(false);
const isReviewed = row.humanReviewedAt != null;
return (
<div className="p-4 space-y-4">
{showReplay && (
<ConversationReplay row={row} allRows={allRows} onClose={() => setShowReplay(false)} />
)}
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-900">Call Detail</h2>
{isReviewed && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800"
title={`Reviewed ${row.humanReviewedAt ? format(toDate(row.humanReviewedAt), "PPpp") : ""}${row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}`}
>
&#10003; Reviewed{row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isReviewed && onMarkReviewed && (
<button
onClick={() => void onMarkReviewed(row.correlationId)}
className="px-2.5 py-1 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 border border-green-200"
>
Mark as reviewed
</button>
)}
<button
onClick={() => setShowReplay(true)}
className="px-2.5 py-1 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100"
>
Replay
</button>
<button className="text-gray-400 hover:text-gray-600 text-sm" onClick={onClose}>
close
</button>
</div>
</div>
{/* Card 1: MCP Call */}
<MpcCallCard row={row} />
{/* Card 2: AI QA Review */}
<QaReviewCard row={row} />
{/* Card 3: Human Correction */}
<HumanCorrectionCard row={row} onSave={onSaveCorrection} />
</div>
);
}
// ─── Card 1: MCP Call ──────────────────────────────────
function MpcCallCard({ row }: { row: McpCallLogRow }) {
const [toolsExpanded, setToolsExpanded] = useState(true);
let toolCalls: Array<{ type: string; toolName: string; toolCallId: string; args: unknown; result: unknown }> = [];
try {
toolCalls = JSON.parse(row.innerToolCallsJson) as typeof toolCalls;
} catch {
// ignore
}
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
{/* Metadata bar */}
<div className="px-4 py-2.5 border-b border-gray-100 flex items-center gap-2 flex-wrap text-xs text-gray-500">
<span className="inline-flex items-center px-2 py-0.5 rounded font-medium bg-purple-100 text-purple-800">
{row.toolName}
</span>
<span title={format(toDate(row.createdAt), "PPpp")}>
{formatDistanceToNow(toDate(row.createdAt), { addSuffix: true })}
</span>
<span>{Number(row.durationMs).toLocaleString()}ms</span>
<span>{row.stepCount} step{row.stepCount !== 1 ? "s" : ""}</span>
<span className="text-gray-400">{row.modelId}</span>
</div>
<div className="p-4 space-y-3">
{row.errorMessage && (
<div className="p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{row.errorMessage}
</div>
)}
{/* User Prompt */}
{row.userPrompt && (
<div>
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider mb-0.5">User Prompt</h4>
<p className="text-sm text-gray-700">{row.userPrompt}</p>
</div>
)}
{/* Reason */}
<p className="text-xs text-gray-500 italic">{row.reason}</p>
{/* Question */}
<div>
<div className="flex items-center mb-1">
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">Question</h4>
<CopyButton text={row.question} />
</div>
<p className="text-sm text-gray-900 whitespace-pre-wrap">{row.question}</p>
</div>
{/* Response */}
<div>
<div className="flex items-center mb-1">
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider">AI Response</h4>
<CopyButton text={row.response} />
</div>
<div className="bg-gray-50 p-3 rounded max-h-64 overflow-auto text-sm">
{row.response ? (
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{row.response}
</Markdown>
) : <span className="text-gray-400">(empty)</span>}
</div>
</div>
{/* Tool Calls */}
{toolCalls.length > 0 && (
<div>
<button
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
onClick={() => setToolsExpanded(prev => !prev)}
>
<span className="text-[10px]">{toolsExpanded ? "▾" : "▸"}</span>
Tool Calls ({toolCalls.length})
</button>
{toolsExpanded && (
<div className="mt-2 space-y-2">
{toolCalls.map((call, i) => (
<InnerToolCall key={call.toolCallId || String(i)} call={call} />
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function InnerToolCall({ call }: { call: { toolName: string; toolCallId: string; args: unknown; result: unknown } }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border border-gray-200 rounded">
<button
className="w-full text-left px-3 py-2 text-sm flex items-center justify-between hover:bg-gray-50"
onClick={() => setExpanded(prev => !prev)}
>
<span className="font-mono text-xs">
<span className="text-purple-600">{call.toolName}</span>
<span className="text-gray-400 ml-2">#{call.toolCallId.slice(0, 8)}</span>
</span>
<span className="text-gray-400 text-xs">{expanded ? "collapse" : "expand"}</span>
</button>
{expanded && (
<div className="px-3 pb-2 space-y-2">
<div>
<p className="text-xs text-gray-500 mb-1">Args:</p>
<pre className="text-xs bg-gray-50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(call.args, null, 2)}</pre>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Result:</p>
<pre className="text-xs bg-gray-50 p-2 rounded overflow-auto max-h-40">{JSON.stringify(call.result, null, 2)}</pre>
</div>
</div>
)}
</div>
);
}
// ─── Card 2: AI QA Review ──────────────────────────────
type QaFlag = { type: string; severity: string; explanation: string };
function QaReviewCard({ row }: { row: McpCallLogRow }) {
if (row.qaErrorMessage) {
return (
<div className="bg-red-50/50 border border-red-200 rounded-lg p-4">
<h3 className="text-xs font-semibold text-red-800 uppercase tracking-wider mb-2">AI QA Review</h3>
<p className="text-sm text-red-700">Error: {row.qaErrorMessage}</p>
</div>
);
}
if (row.qaOverallScore == null) {
return (
<div className="bg-indigo-50/30 border border-indigo-200 rounded-lg p-4">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold text-indigo-800 uppercase tracking-wider">AI QA Review</h3>
<span className="inline-block w-3 h-3 border-2 border-indigo-300 border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-indigo-400">Reviewing...</span>
</div>
</div>
);
}
let flags: QaFlag[] = [];
try {
flags = JSON.parse(row.qaFlagsJson ?? "[]") as QaFlag[];
} catch {
// ignore
}
const scoreColor = row.qaOverallScore >= 80
? "text-green-700 bg-green-100"
: row.qaOverallScore >= 50
? "text-yellow-700 bg-yellow-100"
: "text-red-700 bg-red-100";
const severityColors: Record<string, string> = {
critical: "border-red-400 bg-red-50",
high: "border-orange-400 bg-orange-50",
medium: "border-yellow-400 bg-yellow-50",
low: "border-gray-300 bg-gray-50",
};
return (
<div className="bg-indigo-50/30 border border-indigo-200 rounded-lg overflow-hidden">
{/* Header */}
<div className="px-4 py-2.5 border-b border-indigo-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold text-indigo-800 uppercase tracking-wider">AI QA Review</h3>
{row.qaNeedsHumanReview && !row.humanReviewedAt && (
<span className="text-[10px] font-medium text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">Needs Review</span>
)}
</div>
<span className={`text-lg font-bold px-2 py-0.5 rounded ${scoreColor}`}>
{row.qaOverallScore}
</span>
</div>
<div className="p-4 space-y-3">
{/* Badges */}
<div className="flex gap-2">
<span className={clsx(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
row.qaAnswerCorrect ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
)}>
{row.qaAnswerCorrect ? "correct" : "incorrect"}
</span>
<span className={clsx(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
row.qaAnswerRelevant ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
)}>
{row.qaAnswerRelevant ? "relevant" : "off-topic"}
</span>
</div>
{/* Flags */}
{flags.length > 0 && (
<div className="space-y-1.5">
{flags.map((flag, i) => (
<div key={i} className={`border-l-4 pl-3 py-1.5 rounded-r text-sm ${severityColors[flag.severity] ?? severityColors.low}`}>
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono text-xs text-gray-600">{flag.type}</span>
<span className="text-[10px] uppercase text-gray-400">{flag.severity}</span>
</div>
<p className="text-gray-700 text-xs">{flag.explanation}</p>
</div>
))}
</div>
)}
{/* Suggestions */}
{row.qaImprovementSuggestions && (
<div>
<h4 className="text-[10px] uppercase text-gray-400 font-medium tracking-wider mb-1">Suggestions</h4>
<p className="text-xs text-gray-600 whitespace-pre-wrap">{row.qaImprovementSuggestions}</p>
</div>
)}
{/* Conversation timeline */}
{row.qaConversationJson && (
<QaConversationTimeline json={row.qaConversationJson} />
)}
{/* Model */}
{row.qaReviewModelId && (
<p className="text-[10px] text-gray-400">by {row.qaReviewModelId}</p>
)}
</div>
</div>
);
}
// ─── Card 3: Human Correction ──────────────────────────
async function fetchDeepWikiAnswer(questionText: string): Promise<string> {
const res = await fetch("https://mcp.deepwiki.com/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "ask_question",
arguments: {
repoName: "stack-auth/stack-auth",
question: questionText,
},
},
}),
});
if (!res.ok) {
throw new Error(`DeepWiki error: ${res.status}`);
}
const rawText = await res.text();
const dataLine = rawText.split("\n").find(line => line.startsWith("data: "));
if (!dataLine) {
throw new Error("No data in DeepWiki response");
}
const data = JSON.parse(dataLine.slice(6)) as {
result?: { content?: Array<{ text?: string }> };
};
return data.result?.content
?.filter((c): c is { text: string } => typeof c.text === "string")
.map(c => c.text)
.join("\n\n") ?? "(no response)";
}
function HumanCorrectionCard({ row, onSave }: {
row: McpCallLogRow;
onSave?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => Promise<void> | void;
}) {
const [question, setQuestion] = useState(row.humanCorrectedQuestion ?? "");
const [answer, setAnswer] = useState(row.humanCorrectedAnswer ?? "");
const [lastAction, setLastAction] = useState<"published" | "saved" | "deepwiki-error" | "error" | null>(null);
const [deepWikiLoading, setDeepWikiLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setQuestion(row.humanCorrectedQuestion ?? "");
setAnswer(row.humanCorrectedAnswer ?? "");
}, [row.humanCorrectedQuestion, row.humanCorrectedAnswer, row.correlationId]);
const handleSave = async (publish: boolean) => {
if (isSaving) return;
setIsSaving(true);
try {
await onSave?.(row.correlationId, question, answer, publish);
setLastAction(publish ? "published" : "saved");
setTimeout(() => setLastAction(null), 3000);
} catch {
setLastAction("error");
setTimeout(() => setLastAction(null), 3000);
} finally {
setIsSaving(false);
}
};
const hasUnsavedChanges =
question !== (row.humanCorrectedQuestion ?? "") ||
answer !== (row.humanCorrectedAnswer ?? "");
const cardStyle = row.publishedToQa
? "bg-green-50/50 border-green-200"
: row.humanCorrectedAnswer
? "bg-amber-50/50 border-amber-200"
: "bg-white border-gray-200";
return (
<div className={`border rounded-lg overflow-hidden ${cardStyle}`}>
{/* Header */}
<div className="px-4 py-2.5 border-b border-inherit flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold text-gray-700 uppercase tracking-wider">Human Correction</h3>
{row.publishedToQa ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800">
&#10003; Published
</span>
) : row.humanCorrectedAnswer ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-100 text-yellow-800">
Draft
</span>
) : null}
</div>
<div className="flex items-center gap-2 text-[10px] text-gray-400">
{row.publishedAt && (
<span>{format(toDate(row.publishedAt), "MMM d, yyyy")}</span>
)}
{row.humanReviewedBy && (
<span>by {row.humanReviewedBy}</span>
)}
{row.publishedToQa && (
<button
onClick={() => void handleSave(false)}
className="text-red-500 hover:text-red-700"
>
Unpublish
</button>
)}
</div>
</div>
<div className="p-4 space-y-3">
{/* Feedback toast */}
{lastAction && (
<div className={clsx(
"px-3 py-1.5 rounded text-xs font-medium",
lastAction === "published" ? "bg-green-100 text-green-700" :
lastAction === "deepwiki-error" || lastAction === "error" ? "bg-red-100 text-red-700" :
"bg-blue-100 text-blue-700"
)}>
{lastAction === "published" ? "Published to /questions" :
lastAction === "deepwiki-error" ? "Failed to fetch from DeepWiki" :
lastAction === "error" ? "Failed to save" :
"Draft saved"}
</div>
)}
{/* Question */}
<div>
<label className="text-[10px] uppercase text-gray-400 font-medium mb-1 block tracking-wider">Question</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="The question..."
/>
</div>
{/* Answer */}
<div>
<label className="text-[10px] uppercase text-gray-400 font-medium mb-1 block tracking-wider">Answer</label>
<textarea
className="w-full h-40 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y bg-white"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Write the corrected answer..."
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => {
setQuestion(row.question);
setAnswer(row.response);
}}
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded bg-white"
>
Pre-fill from AI
</button>
<button
disabled={deepWikiLoading}
onClick={() => {
const q = question || row.question;
setDeepWikiLoading(true);
fetchDeepWikiAnswer(q)
.then(a => {
setAnswer(a);
if (!question) {
setQuestion(q);
}
})
.catch(() => setLastAction("deepwiki-error"))
.finally(() => setDeepWikiLoading(false));
}}
className={clsx(
"px-2 py-1 text-xs border rounded bg-white",
deepWikiLoading ? "text-gray-400 border-gray-200" : "text-indigo-500 hover:text-indigo-700 border-indigo-300"
)}
>
{deepWikiLoading ? "Fetching..." : "Pre-fill from DeepWiki"}
</button>
{hasUnsavedChanges && (
<span className="text-[10px] text-amber-500">unsaved changes</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => void handleSave(false)}
className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Save Draft
</button>
<button
onClick={() => void handleSave(true)}
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
{row.publishedToQa ? "Update & Publish" : "Save & Publish"}
</button>
</div>
</div>
</div>
</div>
);
}
// ─── QA Conversation Timeline ──────────────────────────
type QaStep = {
step: number;
text?: string;
toolCalls?: Array<{ toolName: string; args: unknown }>;
toolResults?: Array<{ toolName: string; toolCallId: string; result: unknown }>;
};
function formatByteSize(value: unknown): string {
const str = typeof value === "string" ? value : JSON.stringify(value, null, 2);
const bytes = new Blob([str]).size;
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
function QaConversationTimeline({ json }: { json: string }) {
const [expanded, setExpanded] = useState(false);
let steps: QaStep[];
try {
steps = JSON.parse(json) as QaStep[];
} catch {
return null;
}
if (steps.length === 0) return null;
return (
<div>
<button
className="text-xs text-indigo-500 hover:text-indigo-700 flex items-center gap-1"
onClick={() => setExpanded(prev => !prev)}
>
<span className="text-[10px]">{expanded ? "▾" : "▸"}</span>
Reviewer Conversation ({steps.length} step{steps.length !== 1 ? "s" : ""})
</button>
{expanded && (
<div className="mt-3 relative border-l-2 border-indigo-200 ml-1 space-y-4">
{steps.map((step) => {
const hasTools = step.toolCalls && step.toolCalls.length > 0;
return hasTools
? <QaToolStep key={step.step} step={step} />
: step.text ? <QaConclusionStep key={step.step} step={step} /> : null;
})}
</div>
)}
</div>
);
}
function QaToolStep({ step }: { step: QaStep }) {
const pairs = (step.toolCalls ?? []).map((tc, i) => ({
toolName: tc.toolName,
args: tc.args,
result: step.toolResults?.[i]?.result ?? null,
}));
return (
<div className="relative pl-5">
<div className="absolute -left-[5px] top-2 w-2 h-2 rounded-full bg-indigo-400" />
<p className="text-[10px] uppercase text-gray-400 font-medium mb-1.5">
Step {step.step} Verification
</p>
<div className="space-y-2">
{pairs.map((pair, i) => (
<QaToolCard key={i} pair={pair} />
))}
</div>
{step.text && (
<div className="mt-2 bg-white/50 rounded p-2">
<p className="text-xs text-gray-700 whitespace-pre-wrap">{step.text}</p>
</div>
)}
</div>
);
}
function QaToolCard({ pair }: { pair: { toolName: string; args: unknown; result: unknown } }) {
const [resultExpanded, setResultExpanded] = useState(false);
const resultStr = pair.result == null
? null
: typeof pair.result === "string" ? pair.result : JSON.stringify(pair.result, null, 2);
return (
<div className="border border-indigo-200 rounded-lg overflow-hidden bg-white/50">
<div className="px-3 py-1.5 bg-indigo-50">
<span className="font-mono text-xs text-indigo-700">{pair.toolName}</span>
</div>
<div className="px-3 py-2 border-t border-indigo-100">
<div className="flex items-center justify-between mb-1">
<p className="text-[10px] uppercase text-gray-400 font-medium">Args</p>
<CopyButton text={JSON.stringify(pair.args, null, 2)} />
</div>
<pre className="text-xs text-gray-600 overflow-auto max-h-24">{JSON.stringify(pair.args, null, 2)}</pre>
</div>
{resultStr != null && (
<div className="px-3 py-2 border-t border-indigo-100">
<div className="w-full flex items-center justify-between text-[10px] uppercase text-gray-400 font-medium">
<button
className="hover:text-gray-600"
onClick={() => setResultExpanded(prev => !prev)}
>
Result ({formatByteSize(pair.result)}) {resultExpanded ? "collapse" : "expand"}
</button>
{resultExpanded && <CopyButton text={resultStr} />}
</div>
{resultExpanded && (
<pre className="mt-1 text-xs text-gray-600 overflow-auto max-h-64 whitespace-pre-wrap">{resultStr}</pre>
)}
</div>
)}
</div>
);
}
function QaConclusionStep({ step }: { step: QaStep }) {
const [expanded, setExpanded] = useState(false);
const text = step.text ?? "";
const truncated = text.length > 150 ? text.slice(0, 150) + "..." : text;
return (
<div className="relative pl-5">
<div className="absolute -left-[5px] top-2 w-2 h-2 rounded-full bg-gray-400" />
<p className="text-[10px] uppercase text-gray-400 font-medium mb-1.5">
Step {step.step} Conclusion
</p>
<div className="bg-white/50 rounded-lg p-3">
<p className="text-xs text-gray-600 whitespace-pre-wrap">
{expanded ? text : truncated}
</p>
{text.length > 150 && (
<button
className="text-xs text-indigo-500 hover:text-indigo-700 mt-1"
onClick={() => setExpanded(prev => !prev)}
>
{expanded ? "show less" : "show full"}
</button>
)}
</div>
</div>
);
}