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, + }; +};