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; +}