From 8ecd6beca2cd122224bcdff7d24d70d65f711375 Mon Sep 17 00:00:00 2001 From: Adam Gohain <68021524+akgohain@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:59:28 -0400 Subject: [PATCH] Add workflow timeline actor and event type filters --- .../__tests__/workflowTimelineFilters.test.js | 70 +++++++++++++++++++ .../components/workflow/WorkflowTimeline.js | 37 ++++++++++ .../workflow/WorkflowTimelineFilters.js | 59 ++++++++++++++++ .../src/contexts/workflow/timelineFilters.js | 48 +++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 client/src/__tests__/workflowTimelineFilters.test.js create mode 100644 client/src/components/workflow/WorkflowTimeline.js create mode 100644 client/src/components/workflow/WorkflowTimelineFilters.js create mode 100644 client/src/contexts/workflow/timelineFilters.js diff --git a/client/src/__tests__/workflowTimelineFilters.test.js b/client/src/__tests__/workflowTimelineFilters.test.js new file mode 100644 index 0000000..9ff18ac --- /dev/null +++ b/client/src/__tests__/workflowTimelineFilters.test.js @@ -0,0 +1,70 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import WorkflowTimeline from "../components/workflow/WorkflowTimeline"; +import { + DEFAULT_TIMELINE_FILTERS, + filterTimelineEvents, +} from "../contexts/workflow/timelineFilters"; + +const EVENTS = [ + { id: "1", actor: "user", eventType: "message_sent", timestamp: "1" }, + { id: "2", actor: "agent", eventType: "tool_call", timestamp: "2" }, + { id: "3", actor: "system", eventType: "checkpoint_saved", timestamp: "3" }, + { id: "4", actor: "agent", eventType: "message_sent", timestamp: "4" }, +]; + +describe("workflow timeline filters", () => { + it("preserves the full timeline by default", () => { + const visible = filterTimelineEvents(EVENTS, DEFAULT_TIMELINE_FILTERS); + expect(visible).toHaveLength(EVENTS.length); + expect(visible).toBe(EVENTS); + }); + + it("filters by actor and event type combinations", () => { + expect(filterTimelineEvents(EVENTS, { actor: "agent", eventType: "" })).toEqual([ + EVENTS[1], + EVENTS[3], + ]); + + expect( + filterTimelineEvents(EVENTS, { actor: "agent", eventType: "tool" }), + ).toEqual([EVENTS[1]]); + + expect( + filterTimelineEvents(EVENTS, { actor: "all", eventType: "message" }), + ).toEqual([EVENTS[0], EVENTS[3]]); + }); + + it("updates visible events and supports clear", () => { + function Harness() { + const [filters, setFilters] = React.useState(DEFAULT_TIMELINE_FILTERS); + return ( + setFilters(DEFAULT_TIMELINE_FILTERS)} + /> + ); + } + + render(); + + expect(screen.getAllByRole("listitem")).toHaveLength(4); + + fireEvent.change(screen.getByLabelText("Actor filter"), { + target: { value: "agent" }, + }); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + + fireEvent.change(screen.getByLabelText("Event type filter"), { + target: { value: "tool" }, + }); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getAllByRole("listitem")).toHaveLength(4); + expect(screen.getByLabelText("Actor filter").value).toBe("all"); + expect(screen.getByLabelText("Event type filter").value).toBe(""); + }); +}); diff --git a/client/src/components/workflow/WorkflowTimeline.js b/client/src/components/workflow/WorkflowTimeline.js new file mode 100644 index 0000000..269aae3 --- /dev/null +++ b/client/src/components/workflow/WorkflowTimeline.js @@ -0,0 +1,37 @@ +import React, { useMemo } from "react"; +import WorkflowTimelineFilters from "./WorkflowTimelineFilters"; +import { + DEFAULT_TIMELINE_FILTERS, + filterTimelineEvents, +} from "../../contexts/workflow/timelineFilters"; + +function WorkflowTimeline({ + events = [], + filters = DEFAULT_TIMELINE_FILTERS, + onFilterChange, + onFilterReset, +}) { + const visibleEvents = useMemo( + () => filterTimelineEvents(events, filters), + [events, filters], + ); + + return ( +
+ +
    + {visibleEvents.map((event) => ( +
  • + {event.actor || "unknown"}: {event.eventType || event.type} +
  • + ))} +
+
+ ); +} + +export default WorkflowTimeline; diff --git a/client/src/components/workflow/WorkflowTimelineFilters.js b/client/src/components/workflow/WorkflowTimelineFilters.js new file mode 100644 index 0000000..b18511a --- /dev/null +++ b/client/src/components/workflow/WorkflowTimelineFilters.js @@ -0,0 +1,59 @@ +import React from "react"; +import { + DEFAULT_TIMELINE_FILTERS, + TIMELINE_ACTOR_OPTIONS, +} from "../../contexts/workflow/timelineFilters"; + +const actorLabels = { + all: "All actors", + user: "User", + agent: "Agent", + system: "System", +}; + +function WorkflowTimelineFilters({ + filters = DEFAULT_TIMELINE_FILTERS, + onChange, + onReset, +}) { + const actorValue = filters.actor || DEFAULT_TIMELINE_FILTERS.actor; + const eventTypeValue = filters.eventType || DEFAULT_TIMELINE_FILTERS.eventType; + + return ( +
+ + + + + +
+ ); +} + +export default WorkflowTimelineFilters; diff --git a/client/src/contexts/workflow/timelineFilters.js b/client/src/contexts/workflow/timelineFilters.js new file mode 100644 index 0000000..de69d57 --- /dev/null +++ b/client/src/contexts/workflow/timelineFilters.js @@ -0,0 +1,48 @@ +export const TIMELINE_ACTOR_OPTIONS = ["all", "user", "agent", "system"]; + +export const DEFAULT_TIMELINE_FILTERS = { + actor: "all", + eventType: "", +}; + +export function normalizeTimelineFilters(filters = {}) { + const actor = TIMELINE_ACTOR_OPTIONS.includes(filters.actor) + ? filters.actor + : DEFAULT_TIMELINE_FILTERS.actor; + const eventType = (filters.eventType || "").trim(); + + return { + actor, + eventType, + }; +} + +export function eventMatchesTimelineFilters(event, filters = DEFAULT_TIMELINE_FILTERS) { + const normalized = normalizeTimelineFilters(filters); + const actor = (event?.actor || "").toLowerCase(); + const eventType = (event?.eventType || event?.type || "").toLowerCase(); + + if (normalized.actor !== "all" && actor !== normalized.actor) { + return false; + } + + if (!normalized.eventType) { + return true; + } + + return eventType.includes(normalized.eventType.toLowerCase()); +} + +export function filterTimelineEvents(events = [], filters = DEFAULT_TIMELINE_FILTERS) { + if (!Array.isArray(events) || events.length === 0) { + return []; + } + + const normalized = normalizeTimelineFilters(filters); + + if (normalized.actor === "all" && !normalized.eventType) { + return events; + } + + return events.filter((event) => eventMatchesTimelineFilters(event, normalized)); +}