From ed653cf2c1bcf618f484af27d1705f4ae442ace7 Mon Sep 17 00:00:00 2001
From: Adam Gohain <68021524+akgohain@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:59:39 -0400
Subject: [PATCH] Add workflow timeline actor and event-type filters
---
client/package.json | 3 +-
.../__tests__/workflowTimelineFilters.test.js | 73 +++++++++++++++++++
.../components/workflow/WorkflowTimeline.js | 47 ++++++++++++
.../workflow/WorkflowTimelineFilters.js | 36 +++++++++
.../workflow/WorkflowTimelineFilterContext.js | 45 ++++++++++++
5 files changed, 203 insertions(+), 1 deletion(-)
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/WorkflowTimelineFilterContext.js
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__/workflowTimelineFilters.test.js b/client/src/__tests__/workflowTimelineFilters.test.js
new file mode 100644
index 0000000..4810af5
--- /dev/null
+++ b/client/src/__tests__/workflowTimelineFilters.test.js
@@ -0,0 +1,73 @@
+import React from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import WorkflowTimeline from "../components/workflow/WorkflowTimeline";
+import { WorkflowTimelineFilterProvider } from "../contexts/workflow/WorkflowTimelineFilterContext";
+
+const EVENTS = [
+ { id: "1", actor: "user", type: "load_dataset", message: "Loaded A" },
+ { id: "2", actor: "agent", type: "run_inference", message: "Ran model" },
+ { id: "3", actor: "system", type: "save_result", message: "Persisted" },
+ { id: "4", actor: "user", type: "save_result", message: "Manual save" },
+];
+
+function renderTimeline(events = EVENTS) {
+ return render(
+
+
+ ,
+ );
+}
+
+function expectVisibleTypes(types) {
+ const items = screen.getAllByRole("listitem");
+ expect(items).toHaveLength(types.length);
+ types.forEach((type, index) => {
+ expect(within(items[index]).getByText(type)).toBeTruthy();
+ });
+}
+
+describe("workflow timeline filters", () => {
+ test("shows full timeline by default", () => {
+ renderTimeline();
+ expectVisibleTypes([
+ "load_dataset",
+ "run_inference",
+ "save_result",
+ "save_result",
+ ]);
+ });
+
+ test("applies actor and event type filters together", async () => {
+ const user = userEvent.setup();
+ renderTimeline();
+
+ await user.selectOptions(screen.getByLabelText("Actor filter"), "user");
+
+ const eventTypeInput = screen.getByLabelText("Event type filter");
+ await user.type(eventTypeInput, "save");
+
+ expectVisibleTypes(["save_result"]);
+ expect(screen.getByText("Manual save")).toBeTruthy();
+ });
+
+ test("clear resets filters", async () => {
+ const user = userEvent.setup();
+ renderTimeline();
+
+ await user.selectOptions(screen.getByLabelText("Actor filter"), "system");
+ await user.type(screen.getByLabelText("Event type filter"), "save");
+
+ expectVisibleTypes(["save_result"]);
+ expect(screen.getByText("Persisted")).toBeTruthy();
+
+ await user.click(screen.getByRole("button", { name: "Clear filters" }));
+
+ expectVisibleTypes([
+ "load_dataset",
+ "run_inference",
+ "save_result",
+ "save_result",
+ ]);
+ });
+});
diff --git a/client/src/components/workflow/WorkflowTimeline.js b/client/src/components/workflow/WorkflowTimeline.js
new file mode 100644
index 0000000..e65609a
--- /dev/null
+++ b/client/src/components/workflow/WorkflowTimeline.js
@@ -0,0 +1,47 @@
+import React, { useMemo } from "react";
+import { Typography } from "antd";
+import WorkflowTimelineFilters from "./WorkflowTimelineFilters";
+import { useWorkflowTimelineFilters } from "../../contexts/workflow/WorkflowTimelineFilterContext";
+
+const { Text } = Typography;
+
+function WorkflowTimeline({ events = [] }) {
+ const { filters } = useWorkflowTimelineFilters();
+
+ const visibleEvents = useMemo(() => {
+ const normalizedType = filters.eventType.trim().toLowerCase();
+
+ return events.filter((event) => {
+ const actorMatches =
+ filters.actor === "all" || event.actor === filters.actor;
+ if (!actorMatches) return false;
+ if (!normalizedType) return true;
+ return (event.type || "").toLowerCase().includes(normalizedType);
+ });
+ }, [events, filters.actor, filters.eventType]);
+
+ return (
+
+
+ {visibleEvents.length === 0 ? (
+
+ No timeline events
+
+ ) : (
+
+ {visibleEvents.map((event) => (
+ -
+ {event.actor}
+ {event.type}
+
+ {event.message}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+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..f0ff807
--- /dev/null
+++ b/client/src/components/workflow/WorkflowTimelineFilters.js
@@ -0,0 +1,36 @@
+import React from "react";
+import { Button, Input, Space } from "antd";
+import { useWorkflowTimelineFilters } from "../../contexts/workflow/WorkflowTimelineFilterContext";
+
+function WorkflowTimelineFilters() {
+ const { filters, setActorFilter, setEventTypeFilter, clearFilters } =
+ useWorkflowTimelineFilters();
+
+ return (
+
+
+
+ setEventTypeFilter(event.target.value)}
+ allowClear
+ style={{ minWidth: 220 }}
+ />
+
+
+ );
+}
+
+export default WorkflowTimelineFilters;
diff --git a/client/src/contexts/workflow/WorkflowTimelineFilterContext.js b/client/src/contexts/workflow/WorkflowTimelineFilterContext.js
new file mode 100644
index 0000000..b21c3cf
--- /dev/null
+++ b/client/src/contexts/workflow/WorkflowTimelineFilterContext.js
@@ -0,0 +1,45 @@
+import React, { createContext, useContext, useMemo, useState } from "react";
+
+const DEFAULT_FILTERS = {
+ actor: "all",
+ eventType: "",
+};
+
+const WorkflowTimelineFilterContext = createContext({
+ filters: DEFAULT_FILTERS,
+ setActorFilter: () => {},
+ setEventTypeFilter: () => {},
+ clearFilters: () => {},
+});
+
+export function WorkflowTimelineFilterProvider({ children }) {
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+
+ const value = useMemo(
+ () => ({
+ filters,
+ setActorFilter: (actor) => {
+ setFilters((current) => ({ ...current, actor }));
+ },
+ setEventTypeFilter: (eventType) => {
+ setFilters((current) => ({ ...current, eventType }));
+ },
+ clearFilters: () => {
+ setFilters(DEFAULT_FILTERS);
+ },
+ }),
+ [filters],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWorkflowTimelineFilters() {
+ return useContext(WorkflowTimelineFilterContext);
+}
+
+export { DEFAULT_FILTERS };