diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index fb353789f..70b7daf03 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -44,6 +44,29 @@ const EVENT_KIND_LABEL: Record = { command: "Command", }; +const DEBUG_CATEGORY_TONE: Record = { + 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]; @@ -384,6 +407,32 @@ function EventRow({ ); } +function DebugRow({ + event, + runStart, +}: { + event: EventEnvelope; + runStart: string | undefined; +}) { + const eventName = event.event ?? ""; + const category = debugCategory(eventName); + return ( +
+ + {debugCategoryLabel(category)} + + + {eventName} + + + {formatElapsed(event.ts, runStart)} + +
+ ); +} + function DetailField({ label, children, @@ -610,23 +659,31 @@ function EventsTabToggle({ ); } -function KindFilter({ +function MultiSelectFilter({ selected, + options, + labelOf, onChange, + emptyMeansAll = false, }: { - selected: EventKind[]; - onChange: (kinds: EventKind[]) => void; + 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 (selected.length === EVENT_KINDS.length) return "All types"; + if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types"; if (selected.length === 0) return "No types"; if (selected.length <= 2) { - return EVENT_KINDS.filter((k) => selected.includes(k)) - .map((k) => EVENT_KIND_LABEL[k]) + return options + .filter((o) => selected.includes(o)) + .map(labelOf) .join(", "); } return `${selected.length} types`; - }, [selected]); + }, [allSelected, emptyMeansAll, selected, options, labelOf]); return ( @@ -640,10 +697,10 @@ function KindFilter({ 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" > - {EVENT_KINDS.map((kind) => ( + {options.map((option) => ( @@ -652,7 +709,7 @@ function KindFilter({ aria-hidden="true" /> - {EVENT_KIND_LABEL[kind]} + {labelOf(option)} ))} @@ -693,6 +750,9 @@ function EventsToolbar({ onTabChange, selectedKinds, onKindsChange, + selectedDebugCategories, + onDebugCategoriesChange, + availableDebugCategories, search, onSearchChange, filteredCount, @@ -702,41 +762,60 @@ function EventsToolbar({ onTabChange: (tab: EventsTab) => void; selectedKinds: EventKind[]; onKindsChange: (kinds: EventKind[]) => void; + selectedDebugCategories: string[]; + onDebugCategoriesChange: (categories: string[]) => void; + availableDebugCategories: readonly string[]; search: string; onSearchChange: (value: string) => void; filteredCount: number; totalCount: number; }) { - const allKindsSelected = selectedKinds.length === EVENT_KINDS.length; - const isFiltering = !allKindsSelected || search.length > 0; - const showTranscriptControls = tab === "transcript"; + const transcriptAllSelected = selectedKinds.length === EVENT_KINDS.length; + const debugAllSelected = + selectedDebugCategories.length === 0 || + selectedDebugCategories.length === availableDebugCategories.length; + const isFiltering = tab === "transcript" + ? !transcriptAllSelected || search.length > 0 + : !debugAllSelected || search.length > 0; function clearFilters() { - onKindsChange([...EVENT_KINDS]); + if (tab === "transcript") onKindsChange([...EVENT_KINDS]); + else onDebugCategoriesChange([]); onSearchChange(""); } return (
- {showTranscriptControls ? ( -
- - - {isFiltering && ( - - )} -
- ) : ( -
- )} - {showTranscriptControls && isFiltering && totalCount > 0 && ( +
+ {tab === "transcript" ? ( + + selected={selectedKinds} + options={EVENT_KINDS} + labelOf={(k) => EVENT_KIND_LABEL[k]} + onChange={onKindsChange} + /> + ) : ( + + selected={selectedDebugCategories} + options={availableDebugCategories} + labelOf={debugCategoryLabel} + onChange={onDebugCategoriesChange} + emptyMeansAll + /> + )} + + {isFiltering && ( + + )} +
+ {isFiltering && totalCount > 0 && ( {filteredCount.toLocaleString()} of {totalCount.toLocaleString()} events @@ -775,6 +854,7 @@ export default function RunStages() { const [selectedKinds, setSelectedKinds] = useState([ ...EVENT_KINDS, ]); + const [selectedDebugCategories, setSelectedDebugCategories] = useState([]); const [search, setSearch] = useState(""); const filteredTurns = useMemo<{ turn: TurnType; index: number }[]>(() => { const kindSet = new Set(selectedKinds); @@ -788,6 +868,34 @@ export default function RunStages() { return out; }, [turns, selectedKinds, search]); + const debugEvents = useMemo(() => { + if (!selectedStageId) return []; + return (stageEventsQuery.data ?? []).filter( + (e) => activityEventStageId(e) === selectedStageId, + ); + }, [stageEventsQuery.data, selectedStageId]); + const availableDebugCategories = useMemo(() => { + const set = new Set(); + for (const event of debugEvents) { + if (event.event) set.add(debugCategory(event.event)); + } + return Array.from(set).sort(); + }, [debugEvents]); + const filteredDebugEvents = useMemo(() => { + const useCategoryFilter = selectedDebugCategories.length > 0; + const cats = new Set(selectedDebugCategories); + const needle = search.toLowerCase(); + return debugEvents.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; + }); + }, [debugEvents, selectedDebugCategories, search]); + if (!id || !stages.length) { return (
@@ -822,10 +930,13 @@ export default function RunStages() { onTabChange={setTab} selectedKinds={selectedKinds} onKindsChange={setSelectedKinds} + selectedDebugCategories={selectedDebugCategories} + onDebugCategoriesChange={setSelectedDebugCategories} + availableDebugCategories={availableDebugCategories} search={search} onSearchChange={setSearch} - filteredCount={filteredTurns.length} - totalCount={turns.length} + filteredCount={tab === "transcript" ? filteredTurns.length : filteredDebugEvents.length} + totalCount={tab === "transcript" ? turns.length : debugEvents.length} />
@@ -846,7 +957,19 @@ export default function RunStages() { /> )) ) - ) : null} + ) : debugEvents.length > 0 && filteredDebugEvents.length === 0 ? ( +
+ No events match these filters. +
+ ) : ( + filteredDebugEvents.map((event) => ( + + )) + )}