Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions client/src/__tests__/agentProposalCards.test.js
Original file line number Diff line number Diff line change
@@ -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(
<AgentProposalCard
proposal={{
type: "prioritize_failure_hotspots",
rationale:
"Recent runs surfaced unstable boundaries around densely packed synapses, so we should route reviewers to these hotspots first.",
focus_metric: "boundary_instability",
max_hotspots: 12,
dataset_split: "validation",
}}
/>,
);

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(
<AgentProposalCard proposal={proposal} onApprove={onApprove} onReject={onReject} />,
);

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();
});
});
61 changes: 61 additions & 0 deletions client/src/components/chat/AgentProposalCard.js
Original file line number Diff line number Diff line change
@@ -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 (
<ul style={{ paddingLeft: 20, margin: "8px 0" }}>
{summary.map(({ label, value }) => (
<li key={label}>
<strong>{label}:</strong> {value}
</li>
))}
</ul>
);
}

export default function AgentProposalCard({ proposal, onApprove, onReject }) {
const config = getProposalCardConfig(proposal?.type);

if (!config) return null;

const summary = buildProposalSummary(proposal);

return (
<article style={CARD_STYLE} data-testid={`proposal-card-${proposal.type}`}>
<h4 style={{ margin: "0 0 4px" }}>{config.title}</h4>
<p style={{ margin: "0 0 8px", color: "#666" }}>{config.description}</p>

{proposal?.rationale ? (
<p style={{ margin: "0 0 8px" }}>
<strong>Rationale:</strong> {truncate(proposal.rationale)}
</p>
) : null}

<ProposalSummary summary={summary} />

<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button onClick={() => onApprove?.(proposal)} type="button">
Approve
</button>
<button onClick={() => onReject?.(proposal)} type="button">
Reject
</button>
</div>
</article>
);
}
16 changes: 16 additions & 0 deletions client/src/components/chat/renderAgentTimelineItem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from "react";
import AgentProposalCard from "./AgentProposalCard";

export function renderAgentTimelineItem(message, handlers = {}) {
if (message?.kind === "proposal") {
return (
<AgentProposalCard
proposal={message.proposal}
onApprove={handlers.onApproveProposal}
onReject={handlers.onRejectProposal}
/>
);
}

return <span>{message?.content ?? ""}</span>;
}
43 changes: 43 additions & 0 deletions client/src/contexts/workflow/proposalCardConfig.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading