import { getErrorMessage } from "../../lib/error-message.js"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Check, Loader2, TriangleAlert, X } from "lucide-react"; import { Button } from "@/components/button"; import { applyAiProposal, rejectAiProposal, type AiProposal, } from "@/api/ai-api"; import { fieldLabel, toolLabel } from "./labels"; interface ProposalCardProps { proposal: AiProposal; onResolved: ( id: number, status: "applied" | "rejected", resultSummary?: string, ) => void; } function isDestructive(kind: string): boolean { return kind.includes("delete"); } /** * Fields shown in their own block rather than the generic row list, so the * card does not print the same command twice. */ const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]); /** Renders the payload as readable field rows rather than raw JSON. */ function fieldRows(payload: Record): Array<[string, string]> { const rows: Array<[string, string]> = []; const push = (key: string, value: unknown) => { if (value === null || value === undefined || value === "") return; if (PROMOTED_FIELDS.has(key)) return; const text = typeof value === "object" ? JSON.stringify(value) : String(value); rows.push([key, text]); }; for (const [key, value] of Object.entries(payload)) { if (key === "changes" && value && typeof value === "object") { for (const [childKey, childValue] of Object.entries( value as Record, )) { push(childKey, childValue); } continue; } push(key, value); } return rows; } function asText(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } export function ProposalCard({ proposal, onResolved }: ProposalCardProps) { const { t } = useTranslation(); const [busy, setBusy] = useState<"apply" | "reject" | null>(null); let payload: Record = {}; try { payload = JSON.parse(proposal.payload); } catch { payload = {}; } const rows = fieldRows(payload); const destructive = isDestructive(proposal.kind); const resolved = proposal.status !== "pending"; const command = asText(payload.command); const explanation = asText(payload.explanation) ?? asText(payload.reason); // The stored summary repeats the command for run_command proposals, so a // short action label is used instead of printing it twice. const title = command !== null ? t("ai.runCommandTitle") : (proposal.summary ?? toolLabel(proposal.kind)); async function handleApply() { setBusy("apply"); try { const result = await applyAiProposal(proposal.id); // The result goes into the card, not a toast: command output can be // hundreds of lines, which covered the screen. onResolved(proposal.id, "applied", result.summary); } catch (error) { toast.error(getErrorMessage(error, t("ai.proposalApplyFailed"))); } finally { setBusy(null); } } async function handleReject() { setBusy("reject"); try { await rejectAiProposal(proposal.id); onResolved(proposal.id, "rejected"); } catch (error) { toast.error(getErrorMessage(error, t("ai.proposalRejectFailed"))); } finally { setBusy(null); } } return (
{destructive && ( )} {title} {resolved && ( {proposal.status === "applied" ? t("ai.statusApplied") : t("ai.statusRejected")} )}
{/* A command is the payload, not a field of it, so it gets its own block rather than a label column. In the sidebar a fixed label gutter left barely any room for the command itself. */} {command && (
          {command}
        
)} {explanation && (

{explanation}

)} {rows.length > 0 && (
{rows.map(([key, value]) => ( // Stacked, not a two-column row: the value is what matters and it // needs the full width to stay readable.
{fieldLabel(key)}
{value}
))}
)} {proposal.resultSummary && ( // Command output comes back here, so it keeps the monospace treatment.
          {proposal.resultSummary}
        
)} {!resolved && (
)}
); }