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..05b2954 --- /dev/null +++ b/client/src/__tests__/agentProposalCards.test.js @@ -0,0 +1,87 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AgentProposalCard from "../components/chat/AgentProposalCard"; + +describe("AgentProposalCard", () => { + test("renders prioritize_failure_hotspots card and actions", async () => { + const user = userEvent.setup(); + const onApprove = jest.fn(); + const onReject = jest.fn(); + const proposal = { + type: "prioritize_failure_hotspots", + rationale: "Focus review on areas with recurrent model misses.", + payload: { + priority_metric: "error_density", + max_hotspots: 5, + candidate_count: 12, + }, + }; + + render( + , + ); + + expect(screen.getByText("Prioritize Failure Hotspots")).toBeInTheDocument(); + expect(screen.getByText(/recurrent model misses/i)).toBeInTheDocument(); + expect(screen.getByText(/Priority metric:/i)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Approve" })); + await user.click(screen.getByRole("button", { name: "Reject" })); + + expect(onApprove).toHaveBeenCalledWith(proposal); + expect(onReject).toHaveBeenCalledWith(proposal); + }); + + test("renders preview_correction_impact card and actions", async () => { + const user = userEvent.setup(); + const onApprove = jest.fn(); + const onReject = jest.fn(); + const proposal = { + type: "preview_correction_impact", + rationale: "Estimate impact before applying batch corrections.", + payload: { + target_region: "slice_020-030", + estimated_quality_gain: "+4.2% IoU", + confidence: "high", + }, + }; + + render( + , + ); + + expect(screen.getByText("Preview Correction Impact")).toBeInTheDocument(); + expect(screen.getByText(/Estimate impact/i)).toBeInTheDocument(); + expect(screen.getByText(/Target region:/i)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Approve" })); + await user.click(screen.getByRole("button", { name: "Reject" })); + + expect(onApprove).toHaveBeenCalledWith(proposal); + expect(onReject).toHaveBeenCalledWith(proposal); + }); + + test("falls back for unknown proposal types", () => { + const renderFallback = jest.fn(() =>
Existing card renderer
); + + render( + , + ); + + expect(renderFallback).toHaveBeenCalled(); + expect(screen.getByText("Existing card renderer")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/chat/AgentProposalCard.js b/client/src/components/chat/AgentProposalCard.js new file mode 100644 index 0000000..ba09e06 --- /dev/null +++ b/client/src/components/chat/AgentProposalCard.js @@ -0,0 +1,75 @@ +import React from "react"; +import { + extractKeyFields, + PROPOSAL_TITLES, +} from "../../contexts/workflow/proposalCardFields"; + +const cardStyle = { + border: "1px solid #d9d9d9", + borderRadius: 8, + padding: 10, + marginTop: 8, +}; + +const rationaleStyle = { + margin: "6px 0 8px", + color: "#595959", + fontSize: 13, +}; + +function ProposalCardBody({ proposal }) { + if ( + proposal?.type !== "prioritize_failure_hotspots" && + proposal?.type !== "preview_correction_impact" + ) { + return null; + } + + const keyFields = extractKeyFields(proposal); + const rationale = proposal?.rationale || proposal?.summary || "No rationale provided."; + + return ( + <> + {PROPOSAL_TITLES[proposal.type]} +
{rationale}
+ {keyFields.length > 0 ? ( +
    + {keyFields.map((field) => ( +
  • + {field.label}: {String(field.value)} +
  • + ))} +
+ ) : null} + + ); +} + +export default function AgentProposalCard({ + proposal, + onApprove, + onReject, + renderFallback, +}) { + const isNewProposalType = + proposal?.type === "prioritize_failure_hotspots" || + proposal?.type === "preview_correction_impact"; + + if (!isNewProposalType) { + return renderFallback ? renderFallback(proposal) : null; + } + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/client/src/contexts/workflow/proposalCardFields.js b/client/src/contexts/workflow/proposalCardFields.js new file mode 100644 index 0000000..ba56c16 --- /dev/null +++ b/client/src/contexts/workflow/proposalCardFields.js @@ -0,0 +1,26 @@ +export const PROPOSAL_TITLES = { + prioritize_failure_hotspots: "Prioritize Failure Hotspots", + preview_correction_impact: "Preview Correction Impact", +}; + +export const PROPOSAL_FIELD_CONFIG = { + prioritize_failure_hotspots: [ + { key: "priority_metric", label: "Priority metric" }, + { key: "max_hotspots", label: "Hotspot limit" }, + { key: "candidate_count", label: "Candidates" }, + ], + preview_correction_impact: [ + { key: "target_region", label: "Target region" }, + { key: "estimated_quality_gain", label: "Est. quality gain" }, + { key: "confidence", label: "Confidence" }, + ], +}; + +export const extractKeyFields = (proposal) => { + const fieldConfig = PROPOSAL_FIELD_CONFIG[proposal?.type] || []; + const source = proposal?.payload || {}; + + return fieldConfig + .map(({ key, label }) => ({ label, value: source[key] })) + .filter(({ value }) => value !== undefined && value !== null && value !== ""); +};