Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
56 changes: 56 additions & 0 deletions client/src/__tests__/workflowTimelineFilters.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<WorkflowTimeline events={events} />);

expectVisibleEvents(["Ask", "Search", "Init", "Done"]);
});

it("filters by actor and event type, then can clear filters", async () => {
const user = userEvent.setup();
render(<WorkflowTimeline events={events} />);

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(<WorkflowTimeline events={events} />);

await user.type(screen.getByLabelText("Event type filter"), "tool");

expectVisibleEvents(["Search", "Done"]);
});
});
33 changes: 33 additions & 0 deletions client/src/components/workflow/WorkflowTimeline.js
Original file line number Diff line number Diff line change
@@ -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 (
<ul aria-label="Workflow timeline" style={{ margin: 0, paddingLeft: 20 }}>
{filteredEvents.map((event) => (
<li key={event.id}>
<strong>{event.type}</strong> · {event.actor} · {event.message}
</li>
))}
</ul>
);
}

function WorkflowTimeline({ events }) {
return (
<WorkflowTimelineProvider events={events}>
<div style={{ display: "grid", gap: 12 }}>
<WorkflowTimelineFilters />
<TimelineEventList />
</div>
</WorkflowTimelineProvider>
);
}

export default WorkflowTimeline;
44 changes: 44 additions & 0 deletions client/src/components/workflow/WorkflowTimelineFilters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react";
import { useWorkflowTimeline } from "../../contexts/workflow/WorkflowTimelineContext";

function WorkflowTimelineFilters() {
const {
actorFilter,
setActorFilter,
eventTypeFilter,
setEventTypeFilter,
clearFilters,
} = useWorkflowTimeline();

return (
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<label>
Actor filter
<select
aria-label="Actor filter"
value={actorFilter}
onChange={(event) => setActorFilter(event.target.value)}
>
<option value="all">All actors</option>
<option value="user">User</option>
<option value="agent">Agent</option>
<option value="system">System</option>
</select>
</label>
<label>
Event type filter
<input
aria-label="Event type filter"
placeholder="Filter event type"
value={eventTypeFilter}
onChange={(event) => setEventTypeFilter(event.target.value)}
/>
</label>
<button type="button" onClick={clearFilters}>
Clear filters
</button>
</div>
);
}

export default WorkflowTimelineFilters;
72 changes: 72 additions & 0 deletions client/src/contexts/workflow/WorkflowTimelineContext.js
Original file line number Diff line number Diff line change
@@ -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 (
<WorkflowTimelineContext.Provider value={value}>
{children}
</WorkflowTimelineContext.Provider>
);
}

export function useWorkflowTimeline() {
const context = useContext(WorkflowTimelineContext);
if (!context) {
throw new Error(
"useWorkflowTimeline must be used inside WorkflowTimelineProvider",
);
}

return context;
}
Loading