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
70 changes: 70 additions & 0 deletions client/src/__tests__/workflowTimelineFilters.test.js
Original file line number Diff line number Diff line change
@@ -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 (
<WorkflowTimeline
events={EVENTS}
filters={filters}
onFilterChange={setFilters}
onFilterReset={() => setFilters(DEFAULT_TIMELINE_FILTERS)}
/>
);
}

render(<Harness />);

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("");
});
});
37 changes: 37 additions & 0 deletions client/src/components/workflow/WorkflowTimeline.js
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<WorkflowTimelineFilters
filters={filters}
onChange={onFilterChange}
onReset={onFilterReset}
/>
<ul aria-label="Workflow timeline">
{visibleEvents.map((event) => (
<li key={event.id || `${event.actor}-${event.eventType}-${event.timestamp}`}>
<strong>{event.actor || "unknown"}</strong>: {event.eventType || event.type}
</li>
))}
</ul>
</section>
);
}

export default WorkflowTimeline;
59 changes: 59 additions & 0 deletions client/src/components/workflow/WorkflowTimelineFilters.js
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<label>
Actor
<select
aria-label="Actor filter"
value={actorValue}
onChange={(event) => onChange?.({ ...filters, actor: event.target.value })}
>
{TIMELINE_ACTOR_OPTIONS.map((actor) => (
<option key={actor} value={actor}>
{actorLabels[actor]}
</option>
))}
</select>
</label>

<label>
Event type
<input
aria-label="Event type filter"
type="text"
value={eventTypeValue}
placeholder="Filter event type"
onChange={(event) =>
onChange?.({ ...filters, eventType: event.target.value })
}
/>
</label>

<button type="button" onClick={() => onReset?.()}>
Clear
</button>
</div>
);
}

export default WorkflowTimelineFilters;
48 changes: 48 additions & 0 deletions client/src/contexts/workflow/timelineFilters.js
Original file line number Diff line number Diff line change
@@ -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));
}
Loading