diff --git a/client/package.json b/client/package.json
index 7a08018..b533384 100644
--- a/client/package.json
+++ b/client/package.json
@@ -29,7 +29,8 @@
"scripts": {
"start": "react-scripts start",
"build": "cross-env CI=false react-scripts build",
- "electron": "electron ."
+ "electron": "electron .",
+ "test": "react-scripts test"
},
"eslintConfig": {
"extends": [
diff --git a/client/src/__tests__/agentProposalCards.test.js b/client/src/__tests__/agentProposalCards.test.js
new file mode 100644
index 0000000..0d21b44
--- /dev/null
+++ b/client/src/__tests__/agentProposalCards.test.js
@@ -0,0 +1,71 @@
+import React from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import AgentProposalCard from "../components/chat/AgentProposalCard";
+
+describe("AgentProposalCard", () => {
+ it("renders prioritize_failure_hotspots with approve/reject", () => {
+ const onApprove = jest.fn();
+ const onReject = jest.fn();
+ const proposal = {
+ type: "prioritize_failure_hotspots",
+ rationale: "Focus annotation time where model uncertainty is highest.",
+ target_dataset: "set-a",
+ hotspots: ["slice_01", "slice_10"],
+ priority_metric: "uncertainty",
+ min_failure_rate: 0.2,
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy();
+ expect(
+ screen.getByText(/Focus annotation time where model uncertainty is highest./),
+ ).toBeTruthy();
+ expect(screen.getByText("set-a")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve" }));
+ fireEvent.click(screen.getByRole("button", { name: "Reject" }));
+
+ expect(onApprove).toHaveBeenCalledWith(proposal);
+ expect(onReject).toHaveBeenCalledWith(proposal);
+ });
+
+ it("renders preview_correction_impact with compact rationale", () => {
+ const proposal = {
+ proposal_type: "preview_correction_impact",
+ rationale:
+ "A".repeat(200),
+ target_metric: "f1",
+ expected_delta: "+0.05",
+ sample_size: 128,
+ confidence: "high",
+ };
+
+ render();
+
+ expect(screen.getByText("Preview Correction Impact")).toBeTruthy();
+ expect(screen.getByText(/A{157}…/)).toBeTruthy();
+ expect(screen.getByText("f1")).toBeTruthy();
+ expect(screen.getByText("+0.05")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Approve" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Reject" })).toBeTruthy();
+ });
+
+ it("keeps fallback proposal rendering for existing types", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Agent Proposal")).toBeTruthy();
+ expect(screen.getByText("Keep behavior stable")).toBeTruthy();
+ expect(screen.getByText("bar")).toBeTruthy();
+ });
+});
diff --git a/client/src/components/chat/AgentProposalCard.js b/client/src/components/chat/AgentProposalCard.js
new file mode 100644
index 0000000..2474c47
--- /dev/null
+++ b/client/src/components/chat/AgentProposalCard.js
@@ -0,0 +1,48 @@
+import React from "react";
+import { getProposalCardContent } from "../../contexts/workflow/proposalCardConfig";
+
+function AgentProposalCard({ proposal, onApprove, onReject, disabled = false }) {
+ const content = getProposalCardContent(proposal);
+
+ return (
+
+ {content.title}
+ {content.rationale}
+
+ {content.fields.map((field) => (
+
+ - {field.label}
+ - {field.value}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+export default AgentProposalCard;
diff --git a/client/src/components/chat/ChatTimelineMessage.js b/client/src/components/chat/ChatTimelineMessage.js
new file mode 100644
index 0000000..8b074b4
--- /dev/null
+++ b/client/src/components/chat/ChatTimelineMessage.js
@@ -0,0 +1,18 @@
+import React from "react";
+import AgentProposalCard from "./AgentProposalCard";
+
+function ChatTimelineMessage({ item, onApprove, onReject }) {
+ if (item?.kind === "agent_proposal") {
+ return (
+
+ );
+ }
+
+ return
{item?.text}
;
+}
+
+export default ChatTimelineMessage;
diff --git a/client/src/contexts/workflow/proposalCardConfig.js b/client/src/contexts/workflow/proposalCardConfig.js
new file mode 100644
index 0000000..82c8bea
--- /dev/null
+++ b/client/src/contexts/workflow/proposalCardConfig.js
@@ -0,0 +1,76 @@
+const toDisplayValue = (value) => {
+ if (value === null || value === undefined || value === "") {
+ return "—";
+ }
+
+ if (Array.isArray(value)) {
+ return value.length ? value.join(", ") : "—";
+ }
+
+ if (typeof value === "object") {
+ return JSON.stringify(value);
+ }
+
+ return String(value);
+};
+
+const compactRationale = (rationale) => {
+ if (!rationale) return "No rationale provided.";
+ const trimmed = String(rationale).trim();
+ return trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed;
+};
+
+const pickEntries = (proposal, keys) =>
+ keys.map((key) => ({ key, label: key.replace(/_/g, " "), value: toDisplayValue(proposal[key]) }));
+
+export const getProposalCardContent = (proposal = {}) => {
+ const type = proposal.type || proposal.proposal_type || "proposal";
+
+ if (type === "prioritize_failure_hotspots") {
+ return {
+ type,
+ title: "Prioritize Failure Hotspots",
+ rationale: compactRationale(proposal.rationale || proposal.why),
+ fields: [
+ ...pickEntries(proposal, [
+ "target_dataset",
+ "hotspots",
+ "priority_metric",
+ "min_failure_rate",
+ ]),
+ ],
+ };
+ }
+
+ if (type === "preview_correction_impact") {
+ return {
+ type,
+ title: "Preview Correction Impact",
+ rationale: compactRationale(proposal.rationale || proposal.why),
+ fields: [
+ ...pickEntries(proposal, [
+ "target_metric",
+ "expected_delta",
+ "sample_size",
+ "confidence",
+ ]),
+ ],
+ };
+ }
+
+ const fallbackFields = Object.entries(proposal)
+ .filter(([key]) => !["type", "proposal_type", "rationale", "why"].includes(key))
+ .slice(0, 4)
+ .map(([key, value]) => ({
+ key,
+ label: key.replace(/_/g, " "),
+ value: toDisplayValue(value),
+ }));
+
+ return {
+ type,
+ title: "Agent Proposal",
+ rationale: compactRationale(proposal.rationale || proposal.why),
+ fields: fallbackFields,
+ };
+};