diff --git a/client/src/__tests__/agentProposalCards.test.js b/client/src/__tests__/agentProposalCards.test.js
new file mode 100644
index 0000000..2355794
--- /dev/null
+++ b/client/src/__tests__/agentProposalCards.test.js
@@ -0,0 +1,59 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import AgentProposalCard from "../components/chat/AgentProposalCard";
+import { renderAgentTimelineItem } from "../components/chat/renderAgentTimelineItem";
+
+describe("Agent proposal cards", () => {
+ it("renders prioritize_failure_hotspots cards with compact rationale and fields", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy();
+ expect(screen.getByText(/Rationale:/)).toBeTruthy();
+ expect(screen.getByText(/Focus metric:/)).toBeTruthy();
+ expect(screen.getByText(/Max hotspots:/)).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Approve" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Reject" })).toBeTruthy();
+ });
+
+ it("renders preview_correction_impact cards and wires action buttons", () => {
+ const onApprove = jest.fn();
+ const onReject = jest.fn();
+ const proposal = {
+ type: "preview_correction_impact",
+ rationale:
+ "Estimate if the correction can reduce merges before applying edits to the shared model state.",
+ target_slice_id: "z-0142",
+ correction_mode: "merge_split_fix",
+ estimated_impact: "-18% merge errors",
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve" }));
+ fireEvent.click(screen.getByRole("button", { name: "Reject" }));
+
+ expect(screen.getByText("Preview Correction Impact")).toBeTruthy();
+ expect(onApprove).toHaveBeenCalledWith(proposal);
+ expect(onReject).toHaveBeenCalledWith(proposal);
+ });
+
+ it("keeps existing non-proposal timeline rendering unaffected", () => {
+ render(renderAgentTimelineItem({ kind: "assistant", content: "No proposal here." }));
+
+ expect(screen.getByText("No proposal here.")).toBeTruthy();
+ });
+});
diff --git a/client/src/components/chat/AgentProposalCard.js b/client/src/components/chat/AgentProposalCard.js
new file mode 100644
index 0000000..ac629f9
--- /dev/null
+++ b/client/src/components/chat/AgentProposalCard.js
@@ -0,0 +1,61 @@
+import React from "react";
+import { buildProposalSummary, getProposalCardConfig } from "../../contexts/workflow/proposalCardConfig";
+
+const CARD_STYLE = {
+ border: "1px solid #d9d9d9",
+ borderRadius: 8,
+ padding: 12,
+ marginTop: 8,
+ background: "#fff",
+};
+
+function truncate(text, maxLength = 180) {
+ if (!text) return "";
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength).trimEnd()}…`;
+}
+
+function ProposalSummary({ summary }) {
+ if (!summary.length) return null;
+
+ return (
+
+ {summary.map(({ label, value }) => (
+ -
+ {label}: {value}
+
+ ))}
+
+ );
+}
+
+export default function AgentProposalCard({ proposal, onApprove, onReject }) {
+ const config = getProposalCardConfig(proposal?.type);
+
+ if (!config) return null;
+
+ const summary = buildProposalSummary(proposal);
+
+ return (
+
+ {config.title}
+ {config.description}
+
+ {proposal?.rationale ? (
+
+ Rationale: {truncate(proposal.rationale)}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/chat/renderAgentTimelineItem.js b/client/src/components/chat/renderAgentTimelineItem.js
new file mode 100644
index 0000000..994e9a5
--- /dev/null
+++ b/client/src/components/chat/renderAgentTimelineItem.js
@@ -0,0 +1,16 @@
+import React from "react";
+import AgentProposalCard from "./AgentProposalCard";
+
+export function renderAgentTimelineItem(message, handlers = {}) {
+ if (message?.kind === "proposal") {
+ return (
+
+ );
+ }
+
+ return {message?.content ?? ""};
+}
diff --git a/client/src/contexts/workflow/proposalCardConfig.js b/client/src/contexts/workflow/proposalCardConfig.js
new file mode 100644
index 0000000..b4aff19
--- /dev/null
+++ b/client/src/contexts/workflow/proposalCardConfig.js
@@ -0,0 +1,43 @@
+export const PROPOSAL_CARD_CONFIG = {
+ prioritize_failure_hotspots: {
+ title: "Prioritize Failure Hotspots",
+ description:
+ "Reorders the review queue around high-risk slices with the strongest failure signals.",
+ keyFields: [
+ ["Focus metric", "focus_metric"],
+ ["Max hotspots", "max_hotspots"],
+ ["Dataset split", "dataset_split"],
+ ],
+ },
+ preview_correction_impact: {
+ title: "Preview Correction Impact",
+ description:
+ "Estimates the downstream effect before applying a correction to the active workflow.",
+ keyFields: [
+ ["Target slice", "target_slice_id"],
+ ["Correction mode", "correction_mode"],
+ ["Estimated impact", "estimated_impact"],
+ ],
+ },
+};
+
+export function getProposalCardConfig(proposalType) {
+ return PROPOSAL_CARD_CONFIG[proposalType] ?? null;
+}
+
+export function buildProposalSummary(proposal) {
+ if (!proposal) return [];
+
+ const config = getProposalCardConfig(proposal.type);
+ if (!config) return [];
+
+ return config.keyFields
+ .map(([label, key]) => {
+ const value = proposal[key];
+ if (value === undefined || value === null || value === "") {
+ return null;
+ }
+ return { label, value: String(value) };
+ })
+ .filter(Boolean);
+}