orchai/src/components/tickets/TicketDetail.tsx
2026-04-14 15:27:29 +02:00

333 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
applyFixToBranch,
cancelTicket,
deleteWorktreeCmd,
getTicketResult,
getWorktreeDiff,
retryTicket,
} from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { ProcessedTicket, Worktree } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
function statusBadgeClass(status: string): string {
switch (status) {
case "Pending":
return "bg-yellow-100 text-yellow-700";
case "Analyzing":
return "bg-blue-100 text-blue-700";
case "Developing":
return "bg-purple-100 text-purple-700";
case "Done":
return "bg-green-100 text-green-700";
case "Error":
return "bg-red-100 text-red-700";
case "Cancelled":
return "bg-gray-100 text-gray-500";
default:
return "bg-gray-100 text-gray-700";
}
}
function DiffViewer({ diff }: { diff: string }) {
if (!diff) {
return <div className="py-4 text-center text-sm text-gray-400">No changes detected.</div>;
}
const lines = diff.split("\n");
return (
<pre className="max-h-[600px] overflow-auto rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
{lines.map((line, i) => {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "text-gray-400";
else if (line.startsWith("+")) cls = "bg-green-900/20 text-green-400";
else if (line.startsWith("-")) cls = "bg-red-900/20 text-red-400";
else if (line.startsWith("@@")) cls = "text-blue-400";
else if (line.startsWith("diff ")) cls = "font-bold text-yellow-400";
return (
<div key={i} className={cls}>
{line}
</div>
);
})}
</pre>
);
}
export default function TicketDetail() {
const { ticketId } = useParams();
const navigate = useNavigate();
const [ticket, setTicket] = useState<ProcessedTicket | null>(null);
const [worktree, setWorktree] = useState<Worktree | null>(null);
const [diff, setDiff] = useState<string | null>(null);
const [targetBranch, setTargetBranch] = useState("");
const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false);
async function loadData() {
if (!ticketId) return;
try {
const result = await getTicketResult(ticketId);
setTicket(result.ticket);
setWorktree(result.worktree);
if (result.ticket.developer_report) setTab("developer");
else if (result.ticket.analyst_report) setTab("analyst");
if (result.worktree && result.worktree.status === "Active") {
try {
const d = await getWorktreeDiff(result.worktree.id);
setDiff(d);
} catch {
setDiff(null);
}
} else {
setDiff(null);
}
} catch (err) {
setError(getErrorMessage(err));
}
}
useEffect(() => {
loadData();
}, [ticketId]);
async function handleRetry() {
if (!ticketId) return;
setLoading(true);
try {
await retryTicket(ticketId);
await loadData();
} catch (err) {
setError(getErrorMessage(err));
}
setLoading(false);
}
async function handleCancel() {
if (!ticketId) return;
setLoading(true);
try {
await cancelTicket(ticketId);
await loadData();
} catch (err) {
setError(getErrorMessage(err));
}
setLoading(false);
}
async function handleApplyFix() {
if (!worktree || !targetBranch) return;
setLoading(true);
setError("");
try {
await applyFixToBranch(worktree.id, targetBranch);
await loadData();
} catch (err) {
setError(getErrorMessage(err));
}
setLoading(false);
}
async function handleDeleteWorktree() {
if (!worktree) return;
setIsDeleteWorktreeModalOpen(false);
setLoading(true);
try {
await deleteWorktreeCmd(worktree.id);
setWorktree(null);
setDiff(null);
} catch (err) {
setError(getErrorMessage(err));
}
setLoading(false);
}
if (!ticket) {
return <div className="p-8 text-gray-400">Loading...</div>;
}
const tabs = [
{ key: "info" as const, label: "Info" },
{
key: "analyst" as const,
label: "Analyst Report",
disabled: !ticket.analyst_report,
},
{
key: "developer" as const,
label: "Developer Report",
disabled: !ticket.developer_report,
},
{ key: "diff" as const, label: "Diff", disabled: !diff && !worktree },
];
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<button onClick={() => navigate(-1)} className="mb-1 text-sm text-blue-600 hover:underline">
Back
</button>
<h2 className="flex items-center gap-3 text-xl font-bold">
<span className="font-mono text-base text-gray-400">#{ticket.artifact_id}</span>
{ticket.artifact_title}
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(
ticket.status
)}`}
>
{ticket.status}
</span>
</h2>
</div>
<div className="flex gap-2">
{(ticket.status === "Error" || ticket.status === "Done" || ticket.status === "Cancelled") && (
<button
onClick={handleRetry}
disabled={loading}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Retry
</button>
)}
{(ticket.status === "Pending" ||
ticket.status === "Analyzing" ||
ticket.status === "Developing") && (
<button
onClick={handleCancel}
disabled={loading}
className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
</div>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>
)}
<div className="mb-6 flex gap-1 border-b border-gray-200">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
disabled={t.disabled}
className={`-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? "border-blue-600 text-blue-600"
: t.disabled
? "cursor-not-allowed border-transparent text-gray-300"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "info" && (
<div className="space-y-4">
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
<div>
<span className="text-sm text-gray-500">Status:</span>
<span className="ml-2 text-sm">{ticket.status}</span>
</div>
<div>
<span className="text-sm text-gray-500">Detected:</span>
<span className="ml-2 text-sm">{new Date(ticket.detected_at).toLocaleString()}</span>
</div>
{ticket.processed_at && (
<div>
<span className="text-sm text-gray-500">Processed:</span>
<span className="ml-2 text-sm">{new Date(ticket.processed_at).toLocaleString()}</span>
</div>
)}
{worktree && (
<div>
<span className="text-sm text-gray-500">Worktree:</span>
<span className="ml-2 font-mono text-sm">{worktree.branch_name}</span>
<span
className={`ml-2 rounded-full px-2 py-0.5 text-xs ${
worktree.status === "Active"
? "bg-green-100 text-green-700"
: worktree.status === "Merged"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{worktree.status}
</span>
</div>
)}
</div>
{worktree && worktree.status === "Active" && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
<div className="mb-3 flex items-center gap-2">
<input
type="text"
placeholder="Target branch (e.g. feature/login)"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleApplyFix}
disabled={loading || !targetBranch}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
Apply fix
</button>
</div>
<button
onClick={() => setIsDeleteWorktreeModalOpen(true)}
disabled={loading}
className="text-sm text-red-600 hover:underline"
>
Delete worktree
</button>
</div>
)}
{worktree && worktree.status === "Merged" && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
Fix applied to branch: {worktree.merged_into}
</div>
)}
</div>
)}
{tab === "analyst" && ticket.analyst_report && (
<div className="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6">
<Markdown remarkPlugins={[remarkGfm]}>{ticket.analyst_report}</Markdown>
</div>
)}
{tab === "developer" && ticket.developer_report && (
<div className="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6">
<Markdown remarkPlugins={[remarkGfm]}>{ticket.developer_report}</Markdown>
</div>
)}
{tab === "diff" && <DiffViewer diff={diff || ""} />}
<ConfirmModal
isOpen={isDeleteWorktreeModalOpen}
confirmDisabled={loading}
onCancel={() => setIsDeleteWorktreeModalOpen(false)}
onConfirm={() => void handleDeleteWorktree()}
/>
</div>
);
}