From 9161d16d57ebd80dc47668fd741125948779dd37 Mon Sep 17 00:00:00 2001 From: Adam Gohain <68021524+akgohain@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:01:04 -0400 Subject: [PATCH] Add frontend cards for new agent proposal actions --- .../src/__tests__/agentProposalCards.test.js | 59 ++++++++++++++++++ .../src/components/chat/AgentProposalCard.js | 61 +++++++++++++++++++ .../chat/renderAgentTimelineItem.js | 16 +++++ .../contexts/workflow/proposalCardConfig.js | 43 +++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 client/src/__tests__/agentProposalCards.test.js create mode 100644 client/src/components/chat/AgentProposalCard.js create mode 100644 client/src/components/chat/renderAgentTimelineItem.js create mode 100644 client/src/contexts/workflow/proposalCardConfig.js 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 ( + + ); +} + +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); +}