MAESTRO: Extract Activity Timeline into standalone component with filter, auto-scroll, and color-coded events

This commit is contained in:
John Lightner 2026-04-07 03:13:31 -05:00
parent cf49e9c888
commit 1253994c9e
4 changed files with 558 additions and 142 deletions

View file

@ -29,7 +29,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Build the Leaderboard component (frontend/src/components/Leaderboard.tsx). Real-time ranked table of runs. Columns: rank, config summary (model + key params), individual scores, weighted total, status (completed/cached/running). Click a row to expand full details. Sortable by any column. New entries animate in smoothly. Highlight the current best with a subtle glow effect.
<!-- Extracted from LivePage's inline LeaderboardTable into standalone component. Ranked table with sortable columns (individual scores + weighted total). Click-to-expand detail panel shows scores breakdown with visual bars, run ID, status, duration, token counts, and full config JSON. Best run highlighted with amber background + subtle glow shadow. New entries animate in with slide-in keyframe animation (auto-removed after 600ms). StatusBadge handles completed/running/failed/cached/unknown states. LivePage updated to import from new component. 29 tests added. -->
- [ ] Build the Activity Timeline component (frontend/src/components/Timeline.tsx). Chronological feed of events received via WebSocket. Each event is a card: run.started (blue), run.completed (green), new_best_found (gold), cache_hit (gray), run.failed (red). Include timestamps and key metrics. Auto-scroll to latest, with a "pause scroll" button. Filterable by event type.
- [x] Build the Activity Timeline component (frontend/src/components/Timeline.tsx). Chronological feed of events received via WebSocket. Each event is a card: run.started (blue), run.completed (green), new_best_found (gold), cache_hit (gray), run.failed (red). Include timestamps and key metrics. Auto-scroll to latest, with a "pause scroll" button. Filterable by event type.
<!-- Extracted from LivePage's inline implementation into standalone component. Color-coded event cards (blue=started, green=completed, amber=new_best, slate=cache_hit, red=failed, indigo=progress, emerald=sweep_done). Each card shows event label, formatted timestamp, message, and optional detail. Filter dropdown with 6 options (all/started/completed/new best/cache hits/failed). Auto-scroll toggle with visual state indicator. Handles empty states ("Waiting for events…" vs "No matching events"). Entry animation via CSS keyframes. LivePage updated to import and delegate to Timeline component. 33 tests added. -->
- [ ] Build the Steering Controls component (frontend/src/components/SteeringControls.tsx). Buttons for: Pause (yellow, shows confirmation), Resume (green), Stop (red, shows confirmation), Fork (opens modal to create new experiment from current best), Export Best (dropdown: JSON/YAML/.env). Also show: progress bar (X of Y runs), token counter (running total), estimated cost, cache hit rate percentage, and estimated time remaining.

View file

@ -0,0 +1,334 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import Timeline from "./Timeline";
import type { TimelineEntry, TimelineProps } from "./Timeline";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeEntry(overrides: Partial<TimelineEntry> = {}): TimelineEntry {
return {
id: "tl-1",
type: "run.started",
message: "Run started: gpt-4 t=0.7",
timestamp: new Date("2026-04-07T10:00:00Z"),
...overrides,
};
}
const ENTRY_STARTED = makeEntry({
id: "tl-1",
type: "run.started",
run_id: "r1",
message: "Run started: gpt-4 t=0.7",
});
const ENTRY_COMPLETED = makeEntry({
id: "tl-2",
type: "run.completed",
run_id: "r1",
message: "Run completed: gpt-4 t=0.7",
detail: "Score: 0.875",
});
const ENTRY_BEST = makeEntry({
id: "tl-3",
type: "new_best_found",
run_id: "r1",
message: "New best config found!",
detail: "Score: 0.950",
});
const ENTRY_CACHE = makeEntry({
id: "tl-4",
type: "cache_hit",
run_id: "r2",
message: "Cache hit: gpt-3.5 t=1.0",
});
const ENTRY_FAILED = makeEntry({
id: "tl-5",
type: "run.failed",
run_id: "r3",
message: "Run failed: timeout",
});
const ALL_ENTRIES = [ENTRY_STARTED, ENTRY_COMPLETED, ENTRY_BEST, ENTRY_CACHE, ENTRY_FAILED];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTimeline(overrides: Partial<TimelineProps> = {}) {
const props: TimelineProps = {
entries: ALL_ENTRIES,
eventFilter: "all",
onEventFilterChange: vi.fn(),
autoScroll: true,
onAutoScrollToggle: vi.fn(),
...overrides,
};
return { ...render(<Timeline {...props} />), props };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Timeline", () => {
// --- Rendering ---
it("renders the heading", () => {
renderTimeline();
expect(screen.getByText("Activity Timeline")).toBeInTheDocument();
});
it("renders all entries when filter is 'all'", () => {
renderTimeline();
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(5);
});
it("renders entry messages", () => {
renderTimeline();
expect(screen.getByText("Run started: gpt-4 t=0.7")).toBeInTheDocument();
expect(screen.getByText("Run completed: gpt-4 t=0.7")).toBeInTheDocument();
expect(screen.getByText("New best config found!")).toBeInTheDocument();
});
it("renders entry detail text", () => {
renderTimeline();
expect(screen.getByText("Score: 0.875")).toBeInTheDocument();
expect(screen.getByText("Score: 0.950")).toBeInTheDocument();
});
it("renders event type labels", () => {
renderTimeline();
expect(screen.getByText("Run Started")).toBeInTheDocument();
expect(screen.getByText("Run Completed")).toBeInTheDocument();
expect(screen.getByText("New Best!")).toBeInTheDocument();
expect(screen.getByText("Cache Hit")).toBeInTheDocument();
expect(screen.getByText("Run Failed")).toBeInTheDocument();
});
it("sets data-event-type attribute on entries", () => {
renderTimeline();
const entries = screen.getAllByTestId("timeline-entry");
expect(entries[0]).toHaveAttribute("data-event-type", "run.started");
expect(entries[4]).toHaveAttribute("data-event-type", "run.failed");
});
// --- Color coding ---
it("applies blue color classes for run.started", () => {
renderTimeline({ entries: [ENTRY_STARTED] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("bg-blue-100");
});
it("applies green color classes for run.completed", () => {
renderTimeline({ entries: [ENTRY_COMPLETED] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("bg-green-100");
});
it("applies amber color classes for new_best_found", () => {
renderTimeline({ entries: [ENTRY_BEST] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("bg-amber-100");
});
it("applies slate color classes for cache_hit", () => {
renderTimeline({ entries: [ENTRY_CACHE] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("bg-slate-100");
});
it("applies red color classes for run.failed", () => {
renderTimeline({ entries: [ENTRY_FAILED] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("bg-red-100");
});
// --- Timestamps ---
it("renders formatted timestamps", () => {
renderTimeline({ entries: [ENTRY_STARTED] });
// The exact format depends on locale, but it should contain time info
const entry = screen.getByTestId("timeline-entry");
expect(entry.textContent).toContain(":");
});
// --- Empty state ---
it("shows 'Waiting for events…' when entries is empty", () => {
renderTimeline({ entries: [] });
expect(screen.getByText("Waiting for events…")).toBeInTheDocument();
expect(screen.queryAllByTestId("timeline-entry")).toHaveLength(0);
});
it("shows 'No matching events' when filter excludes all entries", () => {
renderTimeline({ entries: ALL_ENTRIES, eventFilter: "sweep.completed" });
expect(screen.getByText("No matching events")).toBeInTheDocument();
});
// --- Filtering ---
it("filters entries by event type", () => {
renderTimeline({ eventFilter: "run.started" });
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAttribute("data-event-type", "run.started");
});
it("filters to show only completed runs", () => {
renderTimeline({ eventFilter: "run.completed" });
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAttribute("data-event-type", "run.completed");
});
it("filters to show only cache hits", () => {
renderTimeline({ eventFilter: "cache_hit" });
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAttribute("data-event-type", "cache_hit");
});
it("filters to show only failed runs", () => {
renderTimeline({ eventFilter: "run.failed" });
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAttribute("data-event-type", "run.failed");
});
it("filters to show only new best found", () => {
renderTimeline({ eventFilter: "new_best_found" });
const entries = screen.getAllByTestId("timeline-entry");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAttribute("data-event-type", "new_best_found");
});
// --- Filter dropdown ---
it("renders filter dropdown with correct value", () => {
renderTimeline({ eventFilter: "run.failed" });
const select = screen.getByTestId("event-filter") as HTMLSelectElement;
expect(select.value).toBe("run.failed");
});
it("calls onEventFilterChange when filter is changed", async () => {
const user = userEvent.setup();
const { props } = renderTimeline();
const select = screen.getByTestId("event-filter");
await user.selectOptions(select, "run.completed");
expect(props.onEventFilterChange).toHaveBeenCalledWith("run.completed");
});
it("renders all filter options", () => {
renderTimeline();
const select = screen.getByTestId("event-filter");
const options = within(select).getAllByRole("option");
expect(options).toHaveLength(6);
expect(options.map((o) => o.textContent)).toEqual([
"All events",
"Started",
"Completed",
"New best",
"Cache hits",
"Failed",
]);
});
// --- Auto-scroll toggle ---
it("shows 'Auto-scroll ON' when autoScroll is true", () => {
renderTimeline({ autoScroll: true });
expect(screen.getByTestId("toggle-autoscroll")).toHaveTextContent(
"Auto-scroll ON",
);
});
it("shows 'Auto-scroll OFF' when autoScroll is false", () => {
renderTimeline({ autoScroll: false });
expect(screen.getByTestId("toggle-autoscroll")).toHaveTextContent(
"Auto-scroll OFF",
);
});
it("calls onAutoScrollToggle when toggle button is clicked", async () => {
const user = userEvent.setup();
const { props } = renderTimeline();
await user.click(screen.getByTestId("toggle-autoscroll"));
expect(props.onAutoScrollToggle).toHaveBeenCalledTimes(1);
});
it("applies active styling when autoScroll is true", () => {
renderTimeline({ autoScroll: true });
const btn = screen.getByTestId("toggle-autoscroll");
expect(btn.className).toContain("bg-indigo-100");
});
it("applies inactive styling when autoScroll is false", () => {
renderTimeline({ autoScroll: false });
const btn = screen.getByTestId("toggle-autoscroll");
expect(btn.className).toContain("bg-slate-100");
});
// --- Timeline container ---
it("renders the timeline container", () => {
renderTimeline();
expect(screen.getByTestId("timeline-container")).toBeInTheDocument();
});
it("timeline container has scrollable max height", () => {
renderTimeline();
const container = screen.getByTestId("timeline-container");
expect(container.className).toContain("max-h-[500px]");
expect(container.className).toContain("overflow-y-auto");
});
// --- Detail text ---
it("does not render detail paragraph when detail is undefined", () => {
renderTimeline({ entries: [ENTRY_STARTED] });
const entry = screen.getByTestId("timeline-entry");
// ENTRY_STARTED has no detail
const paragraphs = entry.querySelectorAll("p");
expect(paragraphs).toHaveLength(1); // only the message
});
it("renders detail paragraph when detail is provided", () => {
renderTimeline({ entries: [ENTRY_COMPLETED] });
const entry = screen.getByTestId("timeline-entry");
const paragraphs = entry.querySelectorAll("p");
expect(paragraphs).toHaveLength(2); // message + detail
expect(paragraphs[1].textContent).toBe("Score: 0.875");
});
// --- Unknown event types ---
it("handles unknown event types gracefully", () => {
const unknownEntry = makeEntry({
id: "tl-99",
type: "custom.event",
message: "Something custom happened",
});
renderTimeline({ entries: [unknownEntry] });
const entry = screen.getByTestId("timeline-entry");
expect(entry).toHaveAttribute("data-event-type", "custom.event");
// Falls back to showing the raw type name as label
expect(entry.textContent).toContain("custom.event");
});
// --- Animation ---
it("applies animate-in class to entries", () => {
renderTimeline({ entries: [ENTRY_STARTED] });
const entry = screen.getByTestId("timeline-entry");
expect(entry.className).toContain("animate-in");
});
});

View file

@ -0,0 +1,213 @@
import { useRef, useEffect } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TimelineEventType =
| "run.started"
| "run.completed"
| "run.failed"
| "new_best_found"
| "cache_hit"
| "sweep.progress"
| "sweep.completed";
export interface TimelineEntry {
id: string;
type: TimelineEventType | string;
run_id?: string;
message: string;
detail?: string;
timestamp: Date;
}
export interface TimelineProps {
/** Timeline entries to display */
entries: TimelineEntry[];
/** Current event filter value */
eventFilter: string;
/** Callback when filter changes */
onEventFilterChange: (filter: string) => void;
/** Whether auto-scroll is enabled */
autoScroll: boolean;
/** Toggle auto-scroll */
onAutoScrollToggle: () => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EVENT_COLORS: Record<string, string> = {
"run.started":
"bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
"run.completed":
"bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800",
"new_best_found":
"bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
"cache_hit":
"bg-slate-100 dark:bg-slate-700/40 text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-600",
"run.failed":
"bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800",
"sweep.progress":
"bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
"sweep.completed":
"bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
};
const EVENT_LABELS: Record<string, string> = {
"run.started": "Run Started",
"run.completed": "Run Completed",
"new_best_found": "New Best!",
"cache_hit": "Cache Hit",
"run.failed": "Run Failed",
"sweep.progress": "Progress",
"sweep.completed": "Sweep Done",
};
const FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "All events" },
{ value: "run.started", label: "Started" },
{ value: "run.completed", label: "Completed" },
{ value: "new_best_found", label: "New best" },
{ value: "cache_hit", label: "Cache hits" },
{ value: "run.failed", label: "Failed" },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatTime(date: Date): string {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// ---------------------------------------------------------------------------
// Timeline Event Card
// ---------------------------------------------------------------------------
function TimelineCard({ entry }: { entry: TimelineEntry }) {
const colorClass = EVENT_COLORS[entry.type] ?? EVENT_COLORS["run.started"];
return (
<div
data-testid="timeline-entry"
data-event-type={entry.type}
className={`rounded-lg border p-3 ${colorClass} transition-all duration-300 animate-in`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wide">
{EVENT_LABELS[entry.type] ?? entry.type}
</span>
<span className="text-xs opacity-70">{formatTime(entry.timestamp)}</span>
</div>
<p className="text-sm">{entry.message}</p>
{entry.detail && (
<p className="mt-1 text-xs opacity-70">{entry.detail}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Timeline Component
// ---------------------------------------------------------------------------
export default function Timeline({
entries,
eventFilter,
onEventFilterChange,
autoScroll,
onAutoScrollToggle,
}: TimelineProps) {
const timelineEndRef = useRef<HTMLDivElement | null>(null);
const filteredEntries =
eventFilter === "all"
? entries
: entries.filter((e) => e.type === eventFilter);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (autoScroll && timelineEndRef.current) {
timelineEndRef.current.scrollIntoView?.({ behavior: "smooth" });
}
}, [filteredEntries, autoScroll]);
return (
<div className="rounded-xl bg-white dark:bg-slate-800 shadow ring-1 ring-slate-200 dark:ring-slate-700 overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 px-4 py-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Activity Timeline
</h2>
<div className="flex items-center gap-2">
{/* Filter */}
<select
data-testid="event-filter"
value={eventFilter}
onChange={(e) => onEventFilterChange(e.target.value)}
className="rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2 py-1 text-xs text-slate-700 dark:text-slate-300"
>
{FILTER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Pause scroll */}
<button
type="button"
data-testid="toggle-autoscroll"
onClick={onAutoScrollToggle}
className={`rounded-md px-2 py-1 text-xs font-medium transition ${
autoScroll
? "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300"
: "bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400"
}`}
>
{autoScroll ? "Auto-scroll ON" : "Auto-scroll OFF"}
</button>
</div>
</div>
<div
data-testid="timeline-container"
className="max-h-[500px] overflow-y-auto p-4 space-y-2"
>
{filteredEntries.length === 0 && (
<p className="text-center text-sm text-slate-400 dark:text-slate-500 py-8">
{entries.length === 0
? "Waiting for events…"
: "No matching events"}
</p>
)}
{filteredEntries.map((entry) => (
<TimelineCard key={entry.id} entry={entry} />
))}
<div ref={timelineEndRef} />
</div>
{/* Animation keyframes */}
<style>{`
@keyframes timeline-animate-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: timeline-animate-in 0.3s ease-out;
}
`}</style>
</div>
);
}

View file

@ -13,6 +13,8 @@ import type {
} from "../api/client";
import Leaderboard from "../components/Leaderboard";
import type { LeaderboardRow } from "../components/Leaderboard";
import Timeline from "../components/Timeline";
import type { TimelineEntry } from "../components/Timeline";
// ---------------------------------------------------------------------------
// Types
@ -47,15 +49,6 @@ export interface WsEvent {
timestamp?: string;
}
export interface TimelineEntry {
id: string;
type: WsEventType;
run_id?: string;
message: string;
detail?: string;
timestamp: Date;
}
type ConnectionStatus = "connecting" | "connected" | "disconnected";
// ---------------------------------------------------------------------------
@ -76,60 +69,6 @@ function configSummary(config?: Record<string, unknown>): string {
return parts.length > 0 ? parts.join(" ") : JSON.stringify(config).slice(0, 60);
}
function formatTime(date: Date): string {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
const EVENT_COLORS: Record<string, string> = {
"run.started": "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
"run.completed": "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800",
"new_best_found": "bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
"cache_hit": "bg-slate-100 dark:bg-slate-700/40 text-slate-600 dark:text-slate-400 border-slate-200 dark:border-slate-600",
"run.failed": "bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800",
"sweep.progress": "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
"sweep.completed": "bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
};
const EVENT_LABELS: Record<string, string> = {
"run.started": "Run Started",
"run.completed": "Run Completed",
"new_best_found": "New Best!",
"cache_hit": "Cache Hit",
"run.failed": "Run Failed",
"sweep.progress": "Progress",
"sweep.completed": "Sweep Done",
};
// ---------------------------------------------------------------------------
// Timeline Event Card
// ---------------------------------------------------------------------------
function TimelineCard({ entry }: { entry: TimelineEntry }) {
const colorClass = EVENT_COLORS[entry.type] ?? EVENT_COLORS["run.started"];
return (
<div
data-testid="timeline-entry"
data-event-type={entry.type}
className={`rounded-lg border p-3 ${colorClass} transition-all duration-300 animate-in`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wide">
{EVENT_LABELS[entry.type] ?? entry.type}
</span>
<span className="text-xs opacity-70">{formatTime(entry.timestamp)}</span>
</div>
<p className="text-sm">{entry.message}</p>
{entry.detail && (
<p className="mt-1 text-xs opacity-70">{entry.detail}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Steering Controls
@ -362,8 +301,6 @@ export default function LivePage() {
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [eventFilter, setEventFilter] = useState<WsEventType | "all">("all");
const timelineEndRef = useRef<HTMLDivElement | null>(null);
// Leaderboard
const [leaderboard, setLeaderboard] = useState<LeaderboardRow[]>([]);
const [bestRunId, setBestRunId] = useState<string | null>(null);
@ -603,25 +540,6 @@ export default function LivePage() {
};
}, [id, loading, error, connectWs]);
// -------------------------------------------------------------------------
// Auto-scroll timeline
// -------------------------------------------------------------------------
useEffect(() => {
if (autoScroll && timelineEndRef.current) {
timelineEndRef.current.scrollIntoView?.({ behavior: "smooth" });
}
}, [timeline, autoScroll]);
// -------------------------------------------------------------------------
// Filter timeline
// -------------------------------------------------------------------------
const filteredTimeline =
eventFilter === "all"
? timeline
: timeline.filter((e) => e.type === eventFilter);
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
@ -688,63 +606,13 @@ export default function LivePage() {
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Left column — 60% */}
<div className="lg:col-span-3 space-y-6">
{/* Activity Timeline */}
<div className="rounded-xl bg-white dark:bg-slate-800 shadow ring-1 ring-slate-200 dark:ring-slate-700 overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 px-4 py-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Activity Timeline
</h2>
<div className="flex items-center gap-2">
{/* Filter */}
<select
data-testid="event-filter"
value={eventFilter}
onChange={(e) =>
setEventFilter(e.target.value as WsEventType | "all")
}
className="rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2 py-1 text-xs text-slate-700 dark:text-slate-300"
>
<option value="all">All events</option>
<option value="run.started">Started</option>
<option value="run.completed">Completed</option>
<option value="new_best_found">New best</option>
<option value="cache_hit">Cache hits</option>
<option value="run.failed">Failed</option>
</select>
{/* Pause scroll */}
<button
type="button"
data-testid="toggle-autoscroll"
onClick={() => setAutoScroll(!autoScroll)}
className={`rounded-md px-2 py-1 text-xs font-medium transition ${
autoScroll
? "bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300"
: "bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400"
}`}
>
{autoScroll ? "Auto-scroll ON" : "Auto-scroll OFF"}
</button>
</div>
</div>
<div
data-testid="timeline-container"
className="max-h-[500px] overflow-y-auto p-4 space-y-2"
>
{filteredTimeline.length === 0 && (
<p className="text-center text-sm text-slate-400 dark:text-slate-500 py-8">
{timeline.length === 0
? "Waiting for events…"
: "No matching events"}
</p>
)}
{filteredTimeline.map((entry) => (
<TimelineCard key={entry.id} entry={entry} />
))}
<div ref={timelineEndRef} />
</div>
</div>
<Timeline
entries={timeline}
eventFilter={eventFilter}
onEventFilterChange={(f) => setEventFilter(f as WsEventType | "all")}
autoScroll={autoScroll}
onAutoScrollToggle={() => setAutoScroll(!autoScroll)}
/>
</div>
{/* Right column — 40% */}