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.jsx b/client/src/__tests__/workflowTimelineFilters.test.jsx
new file mode 100644
index 0000000..0a9aa55
--- /dev/null
+++ b/client/src/__tests__/workflowTimelineFilters.test.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import WorkflowTimeline from "../components/workflow/WorkflowTimeline";
+
+const events = [
+ { id: "1", actor: "user", type: "prompt.submitted", message: "Ask" },
+ { id: "2", actor: "agent", type: "tool.called", message: "Search" },
+ { id: "3", actor: "system", type: "workflow.started", message: "Init" },
+ { id: "4", actor: "agent", type: "tool.result", message: "Done" },
+];
+
+const getTimelineText = () => screen.getByLabelText("Workflow timeline").textContent;
+
+const expectVisibleEvents = (visibleMessages) => {
+ const text = getTimelineText();
+ events.forEach((event) => {
+ if (visibleMessages.includes(event.message)) {
+ expect(text).toContain(event.message);
+ } else {
+ expect(text).not.toContain(event.message);
+ }
+ });
+};
+
+describe("workflow timeline filters", () => {
+ it("shows full timeline by default", () => {
+ render();
+
+ expectVisibleEvents(["Ask", "Search", "Init", "Done"]);
+ });
+
+ it("filters by actor and event type, then can clear filters", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.selectOptions(screen.getByLabelText("Actor filter"), "agent");
+ expectVisibleEvents(["Search", "Done"]);
+
+ await user.type(screen.getByLabelText("Event type filter"), "result");
+ expectVisibleEvents(["Done"]);
+
+ await user.click(screen.getByRole("button", { name: /clear filters/i }));
+ expectVisibleEvents(["Ask", "Search", "Init", "Done"]);
+ });
+
+ it("supports event type filter independently of actor", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByLabelText("Event type filter"), "tool");
+
+ expectVisibleEvents(["Search", "Done"]);
+ });
+});
diff --git a/client/src/components/workflow/WorkflowTimeline.js b/client/src/components/workflow/WorkflowTimeline.js
new file mode 100644
index 0000000..9af7e9a
--- /dev/null
+++ b/client/src/components/workflow/WorkflowTimeline.js
@@ -0,0 +1,33 @@
+import React from "react";
+import {
+ WorkflowTimelineProvider,
+ useWorkflowTimeline,
+} from "../../contexts/workflow/WorkflowTimelineContext";
+import WorkflowTimelineFilters from "./WorkflowTimelineFilters";
+
+function TimelineEventList() {
+ const { filteredEvents } = useWorkflowTimeline();
+
+ return (
+
+ {filteredEvents.map((event) => (
+ -
+ {event.type} · {event.actor} · {event.message}
+
+ ))}
+
+ );
+}
+
+function WorkflowTimeline({ events }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+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..c8f63be
--- /dev/null
+++ b/client/src/components/workflow/WorkflowTimelineFilters.js
@@ -0,0 +1,44 @@
+import React from "react";
+import { useWorkflowTimeline } from "../../contexts/workflow/WorkflowTimelineContext";
+
+function WorkflowTimelineFilters() {
+ const {
+ actorFilter,
+ setActorFilter,
+ eventTypeFilter,
+ setEventTypeFilter,
+ clearFilters,
+ } = useWorkflowTimeline();
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default WorkflowTimelineFilters;
diff --git a/client/src/contexts/workflow/WorkflowTimelineContext.js b/client/src/contexts/workflow/WorkflowTimelineContext.js
new file mode 100644
index 0000000..19c8f41
--- /dev/null
+++ b/client/src/contexts/workflow/WorkflowTimelineContext.js
@@ -0,0 +1,72 @@
+import React, { createContext, useContext, useMemo, useState } from "react";
+
+const ACTOR_ALL = "all";
+
+const WorkflowTimelineContext = createContext(null);
+
+const normalize = (value) => String(value || "").trim().toLowerCase();
+
+export function WorkflowTimelineProvider({ events, children }) {
+ const [actorFilter, setActorFilter] = useState(ACTOR_ALL);
+ const [eventTypeFilter, setEventTypeFilter] = useState("");
+
+ const normalizedEventTypeFilter = useMemo(
+ () => normalize(eventTypeFilter),
+ [eventTypeFilter],
+ );
+
+ const filteredEvents = useMemo(() => {
+ const hasActorFilter = actorFilter !== ACTOR_ALL;
+ const hasEventTypeFilter = Boolean(normalizedEventTypeFilter);
+
+ if (!hasActorFilter && !hasEventTypeFilter) {
+ return events;
+ }
+
+ return events.filter((event) => {
+ if (hasActorFilter && event.actor !== actorFilter) {
+ return false;
+ }
+
+ if (!hasEventTypeFilter) {
+ return true;
+ }
+
+ return normalize(event.type).includes(normalizedEventTypeFilter);
+ });
+ }, [actorFilter, events, normalizedEventTypeFilter]);
+
+ const clearFilters = () => {
+ setActorFilter(ACTOR_ALL);
+ setEventTypeFilter("");
+ };
+
+ const value = useMemo(
+ () => ({
+ actorFilter,
+ setActorFilter,
+ eventTypeFilter,
+ setEventTypeFilter,
+ filteredEvents,
+ clearFilters,
+ }),
+ [actorFilter, eventTypeFilter, filteredEvents],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWorkflowTimeline() {
+ const context = useContext(WorkflowTimelineContext);
+ if (!context) {
+ throw new Error(
+ "useWorkflowTimeline must be used inside WorkflowTimelineProvider",
+ );
+ }
+
+ return context;
+}