mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
feat(web): add Run Events page to run sidebar
Introduces /runs/:id/events showing all events for a run with the same look as the stage Debug tab — toolbar with category filter and search, plus a row-by-row list with an expandable details panel. Extracts the shared event-list primitives from run-stages.tsx into a new event-debug.tsx module so both views stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c80d38db5
commit
e47e738e8b
6 changed files with 570 additions and 300 deletions
289
apps/fabro-web/app/components/event-debug.tsx
Normal file
289
apps/fabro-web/app/components/event-debug.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import { Tooltip } from "./ui";
|
||||
import { formatAbsoluteTs } from "../lib/format";
|
||||
|
||||
const DEBUG_CATEGORY_TONE: Record<string, string> = {
|
||||
agent: "bg-teal-500/15 text-teal-500",
|
||||
command: "bg-mint/15 text-mint",
|
||||
interview: "bg-coral/15 text-coral",
|
||||
run: "bg-overlay-strong text-fg-2",
|
||||
stage: "bg-amber/15 text-amber",
|
||||
tool: "bg-mint/15 text-mint",
|
||||
};
|
||||
|
||||
export function debugCategory(eventName: string): string {
|
||||
const dot = eventName.indexOf(".");
|
||||
return dot < 0 ? eventName : eventName.slice(0, dot);
|
||||
}
|
||||
|
||||
export function debugCategoryLabel(category: string): string {
|
||||
if (!category) return "Other";
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
export function debugCategoryTone(category: string): string {
|
||||
return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted";
|
||||
}
|
||||
|
||||
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
|
||||
if (!runStart) return "";
|
||||
const startMs = Date.parse(runStart);
|
||||
const eventMs = Date.parse(eventTs);
|
||||
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
|
||||
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
|
||||
const hours = Math.floor(delta / 3600);
|
||||
const minutes = Math.floor((delta % 3600) / 60);
|
||||
const seconds = delta % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const JSON_TOKEN_RE =
|
||||
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
|
||||
|
||||
export function highlightJson(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
JSON_TOKEN_RE.lastIndex = 0;
|
||||
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const token = match[0];
|
||||
let cls: string;
|
||||
if (token.startsWith('"')) {
|
||||
const after = text.slice(JSON_TOKEN_RE.lastIndex);
|
||||
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
|
||||
} else if (token === "true" || token === "false") {
|
||||
cls = "text-coral";
|
||||
} else if (token === "null") {
|
||||
cls = "text-fg-muted";
|
||||
} else {
|
||||
cls = "text-amber";
|
||||
}
|
||||
parts.push(
|
||||
<span key={key++} className={cls}>
|
||||
{token}
|
||||
</span>,
|
||||
);
|
||||
lastIndex = JSON_TOKEN_RE.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function DebugEventRow({
|
||||
event,
|
||||
runStart,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
event: EventEnvelope;
|
||||
runStart: string | undefined;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const eventName = event.event ?? "";
|
||||
const category = debugCategory(eventName);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={`grid w-full grid-cols-[5rem_1fr_auto] items-center gap-4 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
|
||||
selected ? "bg-overlay" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
|
||||
>
|
||||
{debugCategoryLabel(category)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-fg-2">
|
||||
{eventName}
|
||||
</span>
|
||||
<Tooltip label={formatAbsoluteTs(event.ts)}>
|
||||
<span className="font-mono text-xs tabular-nums text-fg-muted">
|
||||
{formatElapsed(event.ts, runStart)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailsPanel({
|
||||
title,
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative shrink-0 self-stretch overflow-hidden transition-[width] duration-200 ease-out ${
|
||||
isOpen ? "w-[28rem]" : "w-0"
|
||||
}`}
|
||||
aria-hidden={isOpen ? undefined : true}
|
||||
>
|
||||
<div className="absolute inset-y-0 right-0 flex w-[28rem] flex-col border-l border-line bg-panel">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-sm font-medium text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4 pb-[calc(1rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
{isOpen ? children : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugEventDetailsPanel({
|
||||
event,
|
||||
onClose,
|
||||
}: {
|
||||
event: EventEnvelope | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DetailsPanel
|
||||
title={event?.event ?? ""}
|
||||
isOpen={event != null}
|
||||
onClose={onClose}
|
||||
>
|
||||
{event ? <DebugEventDetails event={event} /> : null}
|
||||
</DetailsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugEventDetails({ event }: { event: EventEnvelope }) {
|
||||
const text = useMemo(() => JSON.stringify(event, null, 2), [event]);
|
||||
const tokens = useMemo(() => highlightJson(text), [text]);
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
|
||||
{tokens}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectFilter<T extends string>({
|
||||
selected,
|
||||
options,
|
||||
labelOf,
|
||||
onChange,
|
||||
emptyMeansAll = false,
|
||||
}: {
|
||||
selected: T[];
|
||||
options: readonly T[];
|
||||
labelOf: (item: T) => string;
|
||||
onChange: (next: T[]) => void;
|
||||
emptyMeansAll?: boolean;
|
||||
}) {
|
||||
const allSelected = selected.length === options.length;
|
||||
const summary = useMemo(() => {
|
||||
if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types";
|
||||
if (selected.length === 0) return "No types";
|
||||
if (selected.length <= 2) {
|
||||
return options
|
||||
.filter((o) => selected.includes(o))
|
||||
.map(labelOf)
|
||||
.join(", ");
|
||||
}
|
||||
return `${selected.length} types`;
|
||||
}, [allSelected, emptyMeansAll, selected, options, labelOf]);
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={onChange} multiple>
|
||||
<ListboxButton className="inline-flex items-center gap-2 rounded-md bg-panel px-2.5 py-1.5 text-xs text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
|
||||
<FunnelIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{summary}</span>
|
||||
<ChevronUpDownIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
transition
|
||||
anchor={{ to: "bottom start", gap: 4 }}
|
||||
className="z-20 w-44 rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<ListboxOption
|
||||
key={option}
|
||||
value={option}
|
||||
className="group flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden"
|
||||
>
|
||||
<span className="flex size-3.5 items-center justify-center rounded-sm border border-line-strong bg-panel-alt group-data-selected:border-teal-500 group-data-selected:bg-teal-500">
|
||||
<CheckIcon
|
||||
className="size-2.5 text-on-primary opacity-0 group-data-selected:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span>{labelOf(option)}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-sm min-w-48 flex-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="event-search"
|
||||
aria-label="Search events"
|
||||
placeholder="Search events"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="block w-full rounded-md bg-panel py-1.5 pl-8 pr-2.5 text-xs text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 max-sm:text-base/5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@heroicons/react/24/solid";
|
||||
import {
|
||||
Bars3BottomLeftIcon,
|
||||
BoltIcon,
|
||||
DocumentTextIcon,
|
||||
MapIcon,
|
||||
PaperClipIcon,
|
||||
|
|
@ -44,7 +45,7 @@ interface StageSidebarProps {
|
|||
stages: Stage[];
|
||||
runId: string;
|
||||
selectedStageId?: string;
|
||||
activeLink?: "settings" | "source" | "logs" | "artifacts";
|
||||
activeLink?: "settings" | "source" | "logs" | "artifacts" | "events";
|
||||
}
|
||||
|
||||
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
|
||||
|
|
@ -117,19 +118,6 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
|
|||
<div>
|
||||
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Workflow</h3>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/settings`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "settings"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Settings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/source`}
|
||||
|
|
@ -156,6 +144,19 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
|
|||
Run Logs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/events`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "events"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<BoltIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Events
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/artifacts`}
|
||||
|
|
@ -169,6 +170,19 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
|
|||
Artifacts
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`/runs/${runId}/settings`}
|
||||
className={`${linkBase} ${
|
||||
activeLink === "settings"
|
||||
? "bg-overlay text-fg"
|
||||
: "text-fg-3 hover:bg-overlay hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
|
||||
Run Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -174,6 +174,16 @@ export function useRunStageEvents(id: string | undefined, stageId: string | unde
|
|||
);
|
||||
}
|
||||
|
||||
export function useRunEventsList(id: string | undefined) {
|
||||
return useSWR<EventEnvelope[]>(
|
||||
id ? queryKeys.runs.events(id, 1000) : null,
|
||||
() =>
|
||||
fetchAllStageEvents(`run ${id} events`, (sinceSeq, limit) =>
|
||||
apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchRunCommandLog(
|
||||
id: string,
|
||||
stageId: string,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import * as RunStages from "./routes/run-stages";
|
|||
import * as RunSettings from "./routes/run-settings";
|
||||
import * as RunSource from "./routes/run-source";
|
||||
import * as RunLogs from "./routes/run-logs";
|
||||
import * as RunEvents from "./routes/run-events";
|
||||
import * as RunArtifacts from "./routes/run-artifacts";
|
||||
import * as RunFiles from "./routes/run-files";
|
||||
import * as RunBilling from "./routes/run-billing";
|
||||
|
|
@ -95,6 +96,7 @@ export const routes: RouteObject[] = [
|
|||
route("settings", RunSettings),
|
||||
route("source", RunSource),
|
||||
route("logs", RunLogs),
|
||||
route("events", RunEvents),
|
||||
route("artifacts", RunArtifacts),
|
||||
route("files", RunFiles),
|
||||
route("billing", RunBilling),
|
||||
|
|
|
|||
195
apps/fabro-web/app/routes/run-events.tsx
Normal file
195
apps/fabro-web/app/routes/run-events.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
DebugEventDetailsPanel,
|
||||
DebugEventRow,
|
||||
EventSearchInput,
|
||||
MultiSelectFilter,
|
||||
debugCategory,
|
||||
debugCategoryLabel,
|
||||
} from "../components/event-debug";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/state";
|
||||
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
|
||||
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
|
||||
|
||||
export const handle = { wide: true, fullHeight: true };
|
||||
|
||||
export default function RunEvents() {
|
||||
const { id } = useParams();
|
||||
const runQuery = useRun(id);
|
||||
const stagesQuery = useRunStages(id);
|
||||
const eventsQuery = useRunEventsList(id);
|
||||
const stages = useMemo(
|
||||
() => mapRunStagesToSidebarStages(stagesQuery.data),
|
||||
[stagesQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
|
||||
<div className="shrink-0 pb-6 pr-3 pt-6">
|
||||
<StageSidebar stages={stages} runId={id!} activeLink="events" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-px shrink-0">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 -bottom-6 bg-line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EventsView
|
||||
events={eventsQuery.data}
|
||||
error={eventsQuery.error}
|
||||
onRetry={() => void eventsQuery.mutate()}
|
||||
runStart={runQuery.data?.start_time ?? runQuery.data?.created_at}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsView({
|
||||
events,
|
||||
error,
|
||||
onRetry,
|
||||
runStart,
|
||||
}: {
|
||||
events: EventEnvelope[] | undefined;
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
runStart: string | undefined;
|
||||
}) {
|
||||
const [openSeq, setOpenSeq] = useState<number | null>(null);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const all = events ?? [];
|
||||
|
||||
const availableCategories = useMemo<string[]>(() => {
|
||||
const set = new Set<string>();
|
||||
for (const event of all) {
|
||||
if (event.event) set.add(debugCategory(event.event));
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [all]);
|
||||
|
||||
const filtered = useMemo<EventEnvelope[]>(() => {
|
||||
const useCategoryFilter = selectedCategories.length > 0;
|
||||
const cats = new Set(selectedCategories);
|
||||
const needle = search.toLowerCase();
|
||||
return all.filter((event) => {
|
||||
const name = event.event ?? "";
|
||||
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
|
||||
if (needle) {
|
||||
const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
|
||||
if (!blob.includes(needle)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [all, selectedCategories, search]);
|
||||
|
||||
const openEvent = useMemo<EventEnvelope | null>(
|
||||
() => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null),
|
||||
[all, openSeq],
|
||||
);
|
||||
|
||||
const allCategoriesSelected =
|
||||
selectedCategories.length === 0 ||
|
||||
selectedCategories.length === availableCategories.length;
|
||||
const isFiltering = !allCategoriesSelected || search.length > 0;
|
||||
|
||||
function clearFilters() {
|
||||
setSelectedCategories([]);
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 pt-6">
|
||||
<ErrorState
|
||||
title="Couldn't load events"
|
||||
description={errorMessage(error)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (events === undefined) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 pt-6">
|
||||
<LoadingState label="Loading events…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
|
||||
<div className="shrink-0 border-b border-line">
|
||||
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<MultiSelectFilter<string>
|
||||
selected={selectedCategories}
|
||||
options={availableCategories}
|
||||
labelOf={debugCategoryLabel}
|
||||
onChange={setSelectedCategories}
|
||||
emptyMeansAll
|
||||
/>
|
||||
<EventSearchInput value={search} onChange={setSearch} />
|
||||
{isFiltering && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{all.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-fg-muted">
|
||||
{isFiltering
|
||||
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events`
|
||||
: `${all.length.toLocaleString()} events`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
{all.length === 0 ? (
|
||||
<div className="px-2 py-12">
|
||||
<EmptyState
|
||||
title="No events yet"
|
||||
description="Events will appear here as the run executes."
|
||||
/>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-2 py-6 text-sm text-fg-muted">
|
||||
No events match these filters.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((event) => (
|
||||
<DebugEventRow
|
||||
key={`event-${event.seq}`}
|
||||
event={event}
|
||||
runStart={runStart}
|
||||
selected={openSeq === event.seq}
|
||||
onSelect={() => setOpenSeq(event.seq)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenSeq(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string | undefined {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
}
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpDownIcon,
|
||||
CpuChipIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
import { CircleStackIcon, ClockIcon } from "@heroicons/react/20/solid";
|
||||
import { Marked } from "marked";
|
||||
|
||||
import {
|
||||
DebugEventDetailsPanel,
|
||||
DebugEventRow,
|
||||
DetailsPanel,
|
||||
EventSearchInput,
|
||||
MultiSelectFilter,
|
||||
debugCategory,
|
||||
debugCategoryLabel,
|
||||
formatElapsed,
|
||||
highlightJson,
|
||||
} from "../components/event-debug";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { EmptyState } from "../components/state";
|
||||
|
|
@ -39,6 +39,8 @@ export const handle = { wide: true, fullHeight: true };
|
|||
|
||||
type TurnType =
|
||||
| { kind: "system"; ts: string; content: string }
|
||||
| { kind: "steer"; ts: string; content: string }
|
||||
| { kind: "interrupt"; ts: string; content: string }
|
||||
| { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }
|
||||
| { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean; durationMs: number }
|
||||
| {
|
||||
|
|
@ -60,39 +62,18 @@ type PanelSelection =
|
|||
|
||||
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
|
||||
|
||||
const EVENT_KINDS = ["system", "assistant", "tool", "command"] as const;
|
||||
const EVENT_KINDS = ["system", "steer", "interrupt", "assistant", "tool", "command"] as const;
|
||||
type EventKind = (typeof EVENT_KINDS)[number];
|
||||
|
||||
const EVENT_KIND_LABEL: Record<EventKind, string> = {
|
||||
system: "System",
|
||||
steer: "Steer",
|
||||
interrupt: "Interrupt",
|
||||
assistant: "Agent",
|
||||
tool: "Tool",
|
||||
command: "Command",
|
||||
};
|
||||
|
||||
const DEBUG_CATEGORY_TONE: Record<string, string> = {
|
||||
agent: "bg-teal-500/15 text-teal-500",
|
||||
command: "bg-mint/15 text-mint",
|
||||
interview: "bg-coral/15 text-coral",
|
||||
run: "bg-overlay-strong text-fg-2",
|
||||
stage: "bg-amber/15 text-amber",
|
||||
tool: "bg-mint/15 text-mint",
|
||||
};
|
||||
|
||||
function debugCategory(eventName: string): string {
|
||||
const dot = eventName.indexOf(".");
|
||||
return dot < 0 ? eventName : eventName.slice(0, dot);
|
||||
}
|
||||
|
||||
function debugCategoryLabel(category: string): string {
|
||||
if (!category) return "Other";
|
||||
return category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
function debugCategoryTone(category: string): string {
|
||||
return DEBUG_CATEGORY_TONE[category] ?? "bg-overlay text-fg-muted";
|
||||
}
|
||||
|
||||
const EVENTS_TABS = ["transcript", "debug"] as const;
|
||||
type EventsTab = (typeof EVENTS_TABS)[number];
|
||||
|
||||
|
|
@ -156,6 +137,16 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "agent.steering.injected": {
|
||||
const text = getString(props, "text") ?? e.text ?? "";
|
||||
if (text) {
|
||||
turns.push({ kind: "steer", ts: e.ts, content: text });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent.interrupt.injected":
|
||||
turns.push({ kind: "interrupt", ts: e.ts, content: "Agent interrupted" });
|
||||
break;
|
||||
case "agent.tool.started": {
|
||||
const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? "";
|
||||
const args = props.arguments ?? e.arguments;
|
||||
|
|
@ -309,6 +300,10 @@ function turnLabel(turn: TurnType): string {
|
|||
switch (turn.kind) {
|
||||
case "system":
|
||||
return "System";
|
||||
case "steer":
|
||||
return "Steer";
|
||||
case "interrupt":
|
||||
return "Interrupt";
|
||||
case "assistant":
|
||||
return "Agent";
|
||||
case "tool":
|
||||
|
|
@ -322,6 +317,10 @@ function turnTone(turn: TurnType): string {
|
|||
switch (turn.kind) {
|
||||
case "system":
|
||||
return "bg-amber/15 text-amber";
|
||||
case "steer":
|
||||
return "bg-overlay-strong text-fg-2";
|
||||
case "interrupt":
|
||||
return "bg-coral/15 text-coral";
|
||||
case "assistant":
|
||||
return "bg-teal-500/15 text-teal-500";
|
||||
case "tool":
|
||||
|
|
@ -410,6 +409,8 @@ export function humanizeToolName(raw: string): string {
|
|||
export function turnSummary(turn: TurnType): string {
|
||||
switch (turn.kind) {
|
||||
case "system":
|
||||
case "steer":
|
||||
case "interrupt":
|
||||
case "assistant":
|
||||
return oneLine(turn.content);
|
||||
case "tool":
|
||||
|
|
@ -447,6 +448,8 @@ export function turnMetric(turn: TurnType): string | null {
|
|||
case "tool":
|
||||
case "command":
|
||||
return turn.durationMs > 0 ? formatDurationMs(turn.durationMs) : null;
|
||||
case "steer":
|
||||
case "interrupt":
|
||||
case "system":
|
||||
return null;
|
||||
}
|
||||
|
|
@ -455,6 +458,8 @@ export function turnMetric(turn: TurnType): string | null {
|
|||
export function searchableText(turn: TurnType): string {
|
||||
switch (turn.kind) {
|
||||
case "system":
|
||||
case "steer":
|
||||
case "interrupt":
|
||||
case "assistant":
|
||||
return turn.content;
|
||||
case "tool":
|
||||
|
|
@ -464,18 +469,6 @@ export function searchableText(turn: TurnType): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function formatElapsed(eventTs: string, runStart: string | undefined): string {
|
||||
if (!runStart) return "";
|
||||
const startMs = Date.parse(runStart);
|
||||
const eventMs = Date.parse(eventTs);
|
||||
if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return "";
|
||||
const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000));
|
||||
const hours = Math.floor(delta / 3600);
|
||||
const minutes = Math.floor((delta % 3600) / 60);
|
||||
const seconds = delta % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
turn,
|
||||
runStart,
|
||||
|
|
@ -595,45 +588,6 @@ function ToolGroupRow({
|
|||
);
|
||||
}
|
||||
|
||||
function DebugRow({
|
||||
event,
|
||||
runStart,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
event: EventEnvelope;
|
||||
runStart: string | undefined;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const eventName = event.event ?? "";
|
||||
const category = debugCategory(eventName);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={selected}
|
||||
className={`grid w-full grid-cols-[5rem_1fr_auto] items-center gap-4 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
|
||||
selected ? "bg-overlay" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
|
||||
>
|
||||
{debugCategoryLabel(category)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-fg-2">
|
||||
{eventName}
|
||||
</span>
|
||||
<Tooltip label={formatAbsoluteTs(event.ts)}>
|
||||
<span className="font-mono text-xs tabular-nums text-fg-muted">
|
||||
{formatElapsed(event.ts, runStart)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
children,
|
||||
|
|
@ -672,42 +626,6 @@ function prettyJson(raw: string): { text: string; isJson: boolean } {
|
|||
}
|
||||
}
|
||||
|
||||
const JSON_TOKEN_RE =
|
||||
/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g;
|
||||
|
||||
function highlightJson(text: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
JSON_TOKEN_RE.lastIndex = 0;
|
||||
while ((match = JSON_TOKEN_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const token = match[0];
|
||||
let cls: string;
|
||||
if (token.startsWith('"')) {
|
||||
const after = text.slice(JSON_TOKEN_RE.lastIndex);
|
||||
cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint";
|
||||
} else if (token === "true" || token === "false") {
|
||||
cls = "text-coral";
|
||||
} else if (token === "null") {
|
||||
cls = "text-fg-muted";
|
||||
} else {
|
||||
cls = "text-amber";
|
||||
}
|
||||
parts.push(
|
||||
<span key={key++} className={cls}>
|
||||
{token}
|
||||
</span>,
|
||||
);
|
||||
lastIndex = JSON_TOKEN_RE.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function JsonBlock({ value }: { value: string }) {
|
||||
const pretty = useMemo(() => prettyJson(value), [value]);
|
||||
const tokens = useMemo(
|
||||
|
|
@ -749,7 +667,10 @@ function EventDetails({
|
|||
</DetailField>
|
||||
)}
|
||||
|
||||
{(turn.kind === "system" || turn.kind === "assistant") && (
|
||||
{(turn.kind === "system" ||
|
||||
turn.kind === "steer" ||
|
||||
turn.kind === "interrupt" ||
|
||||
turn.kind === "assistant") && (
|
||||
<DetailField label="Content">
|
||||
<Markdown content={turn.content} />
|
||||
</DetailField>
|
||||
|
|
@ -925,53 +846,6 @@ function CommandLogs({
|
|||
);
|
||||
}
|
||||
|
||||
function DetailsPanel({
|
||||
title,
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative shrink-0 self-stretch overflow-hidden transition-[width] duration-200 ease-out ${
|
||||
isOpen ? "w-[28rem]" : "w-0"
|
||||
}`}
|
||||
aria-hidden={isOpen ? undefined : true}
|
||||
>
|
||||
<div className="absolute inset-y-0 right-0 flex w-[28rem] flex-col border-l border-line bg-panel">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-sm font-medium text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-4 pb-[calc(1rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
{isOpen ? children : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetailsPanel({
|
||||
turn,
|
||||
runStart,
|
||||
|
|
@ -1128,34 +1002,6 @@ function ToolGroupDetailsPanel({
|
|||
);
|
||||
}
|
||||
|
||||
function DebugEventDetails({ event }: { event: EventEnvelope }) {
|
||||
const text = useMemo(() => JSON.stringify(event, null, 2), [event]);
|
||||
const tokens = useMemo(() => highlightJson(text), [text]);
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-overlay-strong p-3 font-mono text-xs leading-relaxed text-fg-3">
|
||||
{tokens}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugEventDetailsPanel({
|
||||
event,
|
||||
onClose,
|
||||
}: {
|
||||
event: EventEnvelope | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DetailsPanel
|
||||
title={event?.event ?? ""}
|
||||
isOpen={event != null}
|
||||
onClose={onClose}
|
||||
>
|
||||
{event ? <DebugEventDetails event={event} /> : null}
|
||||
</DetailsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsTabToggle({
|
||||
tab,
|
||||
stageKind,
|
||||
|
|
@ -1193,92 +1039,6 @@ function EventsTabToggle({
|
|||
);
|
||||
}
|
||||
|
||||
function MultiSelectFilter<T extends string>({
|
||||
selected,
|
||||
options,
|
||||
labelOf,
|
||||
onChange,
|
||||
emptyMeansAll = false,
|
||||
}: {
|
||||
selected: T[];
|
||||
options: readonly T[];
|
||||
labelOf: (item: T) => string;
|
||||
onChange: (next: T[]) => void;
|
||||
emptyMeansAll?: boolean;
|
||||
}) {
|
||||
const allSelected = selected.length === options.length;
|
||||
const summary = useMemo(() => {
|
||||
if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types";
|
||||
if (selected.length === 0) return "No types";
|
||||
if (selected.length <= 2) {
|
||||
return options
|
||||
.filter((o) => selected.includes(o))
|
||||
.map(labelOf)
|
||||
.join(", ");
|
||||
}
|
||||
return `${selected.length} types`;
|
||||
}, [allSelected, emptyMeansAll, selected, options, labelOf]);
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={onChange} multiple>
|
||||
<ListboxButton className="inline-flex items-center gap-2 rounded-md bg-panel px-2.5 py-1.5 text-xs text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500">
|
||||
<FunnelIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{summary}</span>
|
||||
<ChevronUpDownIcon className="size-3.5 text-fg-muted" aria-hidden="true" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
transition
|
||||
anchor={{ to: "bottom start", gap: 4 }}
|
||||
className="z-20 w-44 rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<ListboxOption
|
||||
key={option}
|
||||
value={option}
|
||||
className="group flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-xs text-fg-3 data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden"
|
||||
>
|
||||
<span className="flex size-3.5 items-center justify-center rounded-sm border border-line-strong bg-panel-alt group-data-selected:border-teal-500 group-data-selected:bg-teal-500">
|
||||
<CheckIcon
|
||||
className="size-2.5 text-on-primary opacity-0 group-data-selected:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span>{labelOf(option)}</span>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-sm min-w-48 flex-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="event-search"
|
||||
aria-label="Search events"
|
||||
placeholder="Search events"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="block w-full rounded-md bg-panel py-1.5 pl-8 pr-2.5 text-xs text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 max-sm:text-base/5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsToolbar({
|
||||
tab,
|
||||
stageKind,
|
||||
|
|
@ -1348,7 +1108,7 @@ function EventsToolbar({
|
|||
emptyMeansAll
|
||||
/>
|
||||
)}
|
||||
<SearchInput value={search} onChange={onSearchChange} />
|
||||
<EventSearchInput value={search} onChange={onSearchChange} />
|
||||
{isFiltering && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1599,7 +1359,7 @@ export default function RunStages() {
|
|||
</div>
|
||||
) : (
|
||||
filteredDebugEvents.map((event) => (
|
||||
<DebugRow
|
||||
<DebugEventRow
|
||||
key={`debug-${event.seq}`}
|
||||
event={event}
|
||||
runStart={runStart}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue