Skip to content

Commit d01ac9e

Browse files
committed
feat(chat-ui): add timeline filters and proposal cards
(cherry picked from commit ecc2493)
1 parent 2b3f140 commit d01ac9e

10 files changed

Lines changed: 635 additions & 37 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
4+
import AgentProposalCard from "../components/chat/AgentProposalCard";
5+
6+
jest.mock("antd", () => ({
7+
Button: ({ children, ...props }) => (
8+
<button type="button" {...props}>
9+
{children}
10+
</button>
11+
),
12+
Space: ({ children }) => <div>{children}</div>,
13+
Tag: ({ children }) => <span>{children}</span>,
14+
Typography: {
15+
Text: ({ children }) => <span>{children}</span>,
16+
},
17+
}));
18+
19+
describe("AgentProposalCard", () => {
20+
it("renders hotspot proposal fields and approve/reject actions", () => {
21+
const onApprove = jest.fn();
22+
const onReject = jest.fn();
23+
const proposal = {
24+
type: "prioritize_failure_hotspots",
25+
rationale: "Focus annotation where the model fails most often.",
26+
target_dataset: "set-a",
27+
hotspots: ["z:11", "z:12"],
28+
priority_metric: "error_rate",
29+
min_failure_rate: 0.2,
30+
};
31+
32+
render(
33+
<AgentProposalCard
34+
proposal={proposal}
35+
onApprove={onApprove}
36+
onReject={onReject}
37+
/>,
38+
);
39+
40+
expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy();
41+
expect(screen.getByText("set-a")).toBeTruthy();
42+
43+
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
44+
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
45+
46+
expect(onApprove).toHaveBeenCalledWith(proposal);
47+
expect(onReject).toHaveBeenCalledWith(proposal);
48+
});
49+
50+
it("supports correction-impact cards with compact rationale", () => {
51+
const proposal = {
52+
proposal_type: "preview_correction_impact",
53+
rationale: "A".repeat(200),
54+
target_metric: "f1",
55+
expected_delta: "+0.05",
56+
sample_size: 128,
57+
confidence: "high",
58+
};
59+
60+
render(<AgentProposalCard proposal={proposal} />);
61+
62+
expect(screen.getByText("Preview Correction Impact")).toBeTruthy();
63+
expect(screen.getByText(/A{157}/)).toBeTruthy();
64+
expect(screen.getByText("+0.05")).toBeTruthy();
65+
});
66+
67+
it("renders fallback proposal content", () => {
68+
render(
69+
<AgentProposalCard
70+
proposal={{ type: "custom_type", rationale: "Keep behavior stable", foo: "bar" }}
71+
/>,
72+
);
73+
74+
expect(screen.getByText("Agent Proposal")).toBeTruthy();
75+
expect(screen.getByText("Keep behavior stable")).toBeTruthy();
76+
expect(screen.getByText("bar")).toBeTruthy();
77+
});
78+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {
2+
DEFAULT_TIMELINE_FILTERS,
3+
filterTimelineEvents,
4+
} from "../contexts/workflow/timelineFilters";
5+
6+
const EVENTS = [
7+
{ id: "1", actor: "user", event_type: "dataset.loaded" },
8+
{ id: "2", actor: "agent", event_type: "agent.proposal_created" },
9+
{ id: "3", actor: "system", event_type: "inference.completed" },
10+
{ id: "4", actor: "agent", event_type: "agent.proposal_approved" },
11+
];
12+
13+
describe("workflow timeline filters", () => {
14+
it("preserves the full timeline by default", () => {
15+
const visible = filterTimelineEvents(EVENTS, DEFAULT_TIMELINE_FILTERS);
16+
expect(visible).toHaveLength(EVENTS.length);
17+
expect(visible).toBe(EVENTS);
18+
});
19+
20+
it("filters by actor and event type combinations", () => {
21+
expect(filterTimelineEvents(EVENTS, { actor: "agent", eventType: "" })).toEqual([
22+
EVENTS[1],
23+
EVENTS[3],
24+
]);
25+
26+
expect(
27+
filterTimelineEvents(EVENTS, { actor: "agent", eventType: "approved" }),
28+
).toEqual([EVENTS[3]]);
29+
30+
expect(
31+
filterTimelineEvents(EVENTS, { actor: "all", eventType: "proposal" }),
32+
).toEqual([EVENTS[1], EVENTS[3]]);
33+
});
34+
});

client/src/api.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,42 @@ export async function listWorkflowEvents(workflowId) {
500500
}
501501
}
502502

503+
export async function getWorkflowHotspots(workflowId) {
504+
try {
505+
const res = await apiClient.get(`/api/workflows/${workflowId}/hotspots`);
506+
return res.data;
507+
} catch (error) {
508+
handleError(error);
509+
}
510+
}
511+
512+
export async function getWorkflowImpactPreview(workflowId) {
513+
try {
514+
const res = await apiClient.get(`/api/workflows/${workflowId}/impact-preview`);
515+
return res.data;
516+
} catch (error) {
517+
handleError(error);
518+
}
519+
}
520+
521+
export async function getWorkflowMetrics(workflowId) {
522+
try {
523+
const res = await apiClient.get(`/api/workflows/${workflowId}/metrics`);
524+
return res.data;
525+
} catch (error) {
526+
handleError(error);
527+
}
528+
}
529+
530+
export async function exportWorkflowBundle(workflowId) {
531+
try {
532+
const res = await apiClient.post(`/api/workflows/${workflowId}/export-bundle`);
533+
return res.data;
534+
} catch (error) {
535+
handleError(error);
536+
}
537+
}
538+
503539
export async function appendWorkflowEvent(workflowId, event) {
504540
try {
505541
const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event);

client/src/components/WorkflowTimeline.js

Lines changed: 136 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import React from "react";
2-
import { Button, Empty, List, Space, Tag, Typography } from "antd";
3-
import { CheckOutlined, CloseOutlined } from "@ant-design/icons";
1+
import React, { useMemo, useState } from "react";
2+
import { Button, Empty, Input, List, Select, Space, Tag, Typography } from "antd";
43
import { useWorkflow } from "../contexts/WorkflowContext";
4+
import AgentProposalCard from "./chat/AgentProposalCard";
5+
import {
6+
DEFAULT_TIMELINE_FILTERS,
7+
filterTimelineEvents,
8+
normalizeTimelineFilters,
9+
TIMELINE_ACTOR_OPTIONS,
10+
} from "../contexts/workflow/timelineFilters";
511

612
const { Text } = Typography;
713

@@ -14,6 +20,12 @@ const STAGE_LABELS = {
1420
evaluation: "Evaluation",
1521
};
1622

23+
const SEVERITY_COLORS = {
24+
low: "default",
25+
medium: "orange",
26+
high: "red",
27+
};
28+
1729
function formatEventTime(value) {
1830
if (!value) return "";
1931
const date = new Date(value);
@@ -24,14 +36,22 @@ function formatEventTime(value) {
2436
function WorkflowTimeline({ limit = 8 }) {
2537
const workflowContext = useWorkflow();
2638
const workflow = workflowContext?.workflow;
27-
const events = workflowContext?.events || [];
39+
const events = workflowContext?.events;
40+
const hotspots = workflowContext?.hotspots || [];
41+
const impactPreview = workflowContext?.impactPreview;
42+
const refreshInsights = workflowContext?.refreshInsights;
2843
const approveAgentAction = workflowContext?.approveAgentAction;
2944
const rejectAgentAction = workflowContext?.rejectAgentAction;
45+
const [filters, setFilters] = useState(DEFAULT_TIMELINE_FILTERS);
3046

31-
if (!workflowContext) return null;
32-
33-
const visibleEvents = events.slice(-limit).reverse();
47+
const reversedEvents = useMemo(() => [...(events || [])].reverse(), [events]);
48+
const visibleEvents = useMemo(() => {
49+
return filterTimelineEvents(reversedEvents, filters).slice(0, limit);
50+
}, [reversedEvents, filters, limit]);
3451
const stageLabel = STAGE_LABELS[workflow?.stage] || workflow?.stage || "Loading";
52+
const topHotspot = hotspots[0] || null;
53+
54+
if (!workflowContext) return null;
3555

3656
return (
3757
<div
@@ -55,9 +75,87 @@ function WorkflowTimeline({ limit = 8 }) {
5575
{stageLabel}
5676
</Tag>
5777
</Space>
58-
<Text type="secondary" style={{ fontSize: 12 }}>
59-
{workflow?.title || "Segmentation Workflow"}
60-
</Text>
78+
<Space size="small">
79+
<Text type="secondary" style={{ fontSize: 12 }}>
80+
{workflow?.title || "Segmentation Workflow"}
81+
</Text>
82+
<Button
83+
size="small"
84+
type="text"
85+
onClick={() => refreshInsights?.()}
86+
style={{ paddingInline: 4 }}
87+
>
88+
Refresh Insights
89+
</Button>
90+
</Space>
91+
</Space>
92+
93+
{(topHotspot || impactPreview) && (
94+
<div
95+
style={{
96+
marginTop: 8,
97+
padding: "6px 8px",
98+
background: "#fff",
99+
border: "1px solid #f0f0f0",
100+
borderRadius: 6,
101+
}}
102+
>
103+
{topHotspot && (
104+
<Space size="small" wrap>
105+
<Text style={{ fontSize: 12 }} strong>
106+
Hotspot
107+
</Text>
108+
<Tag color={SEVERITY_COLORS[topHotspot.severity] || "default"}>
109+
{topHotspot.severity}
110+
</Tag>
111+
<Text style={{ fontSize: 12 }}>{topHotspot.summary}</Text>
112+
</Space>
113+
)}
114+
{impactPreview && (
115+
<div style={{ marginTop: topHotspot ? 4 : 0 }}>
116+
<Text style={{ fontSize: 12 }} strong>
117+
Impact
118+
</Text>
119+
<Text style={{ fontSize: 12 }}>
120+
{` ${impactPreview.summary} (confidence: ${impactPreview.confidence})`}
121+
</Text>
122+
</div>
123+
)}
124+
</div>
125+
)}
126+
127+
<Space size="small" style={{ marginTop: 8, display: "flex" }} wrap>
128+
<Select
129+
aria-label="Actor filter"
130+
size="small"
131+
value={filters.actor}
132+
style={{ minWidth: 124 }}
133+
options={TIMELINE_ACTOR_OPTIONS.map((value) => ({
134+
value,
135+
label: value === "all" ? "All actors" : value,
136+
}))}
137+
onChange={(value) =>
138+
setFilters((prev) => normalizeTimelineFilters({ ...prev, actor: value }))
139+
}
140+
/>
141+
<Input
142+
aria-label="Event type filter"
143+
size="small"
144+
value={filters.eventType}
145+
placeholder="Filter event type"
146+
style={{ maxWidth: 180 }}
147+
onChange={(event) =>
148+
setFilters((prev) =>
149+
normalizeTimelineFilters({ ...prev, eventType: event.target.value }),
150+
)
151+
}
152+
/>
153+
<Button
154+
size="small"
155+
onClick={() => setFilters(DEFAULT_TIMELINE_FILTERS)}
156+
>
157+
Clear
158+
</Button>
61159
</Space>
62160

63161
{visibleEvents.length === 0 ? (
@@ -85,22 +183,9 @@ function WorkflowTimeline({ limit = 8 }) {
85183
actions={
86184
isPendingProposal
87185
? [
88-
<Button
89-
key="approve"
90-
size="small"
91-
icon={<CheckOutlined />}
92-
onClick={() => approveAgentAction?.(event.id)}
93-
>
94-
Approve
95-
</Button>,
96-
<Button
97-
key="reject"
98-
size="small"
99-
icon={<CloseOutlined />}
100-
onClick={() => rejectAgentAction?.(event.id)}
101-
>
102-
Reject
103-
</Button>,
186+
<Tag key="pending" color="gold">
187+
Needs review
188+
</Tag>,
104189
]
105190
: []
106191
}
@@ -117,17 +202,31 @@ function WorkflowTimeline({ limit = 8 }) {
117202
</Space>
118203
}
119204
description={
120-
<Space size="small" wrap>
121-
<Text type="secondary" style={{ fontSize: 11 }}>
122-
{event.actor}
123-
</Text>
124-
<Text type="secondary" style={{ fontSize: 11 }}>
125-
{event.event_type}
126-
</Text>
127-
<Text type="secondary" style={{ fontSize: 11 }}>
128-
{formatEventTime(event.created_at)}
129-
</Text>
130-
</Space>
205+
<div>
206+
<Space size="small" wrap>
207+
<Text type="secondary" style={{ fontSize: 11 }}>
208+
{event.actor}
209+
</Text>
210+
<Text type="secondary" style={{ fontSize: 11 }}>
211+
{event.event_type}
212+
</Text>
213+
<Text type="secondary" style={{ fontSize: 11 }}>
214+
{formatEventTime(event.created_at)}
215+
</Text>
216+
</Space>
217+
{isPendingProposal && (
218+
<AgentProposalCard
219+
proposal={{
220+
...(event.payload || {}),
221+
type: event.payload?.action || "agent_proposal",
222+
rationale: event.summary,
223+
...(event.payload?.params || {}),
224+
}}
225+
onApprove={() => approveAgentAction?.(event.id)}
226+
onReject={() => rejectAgentAction?.(event.id)}
227+
/>
228+
)}
229+
</div>
131230
}
132231
/>
133232
</List.Item>

0 commit comments

Comments
 (0)