From 6cb3d03ade338afb2139523d5f5aab7fb578af3d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 06:36:51 -0400 Subject: [PATCH] feat(web): show rich hover popovers on waterfall rows Replaces the native title tooltips on waterfall phase and stage rows with HoverCard popovers that surface the status pill, started timestamp, and live elapsed/duration. Promotes PopoverHeader, Rows, and Row from run-detail.tsx into components/ui.tsx so the waterfall and run header share one set of primitives, and lets HoverCard accept a className so a full-row block trigger can host the popover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/run-waterfall.tsx | 103 ++++++++++++++---- apps/fabro-web/app/components/ui.tsx | 25 ++++- apps/fabro-web/app/routes/run-detail.tsx | 31 ++---- 3 files changed, 112 insertions(+), 47 deletions(-) diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx index 9bf76c148..b9b06fb08 100644 --- a/apps/fabro-web/app/components/run-waterfall.tsx +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -1,10 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router"; import { StageState, type RunStage } from "@qltysh/fabro-api-client"; +import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui"; import { isVisibleStage } from "../data/runs"; import { formatAbsoluteTs, formatDurationMs } from "../lib/format"; -import { formatStageLabel } from "../lib/stage-sidebar"; +import { + formatStageLabel, + stageStatusLabel, + stageStatusTone, +} from "../lib/stage-sidebar"; import { deriveRunPhases, type RunPhase } from "../lib/run-phases"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; @@ -25,7 +30,7 @@ interface Row { durationMs: number | null; barClass: string; href: string | null; - tooltip: string; + popover: ReactNode; } const MIN_BAR_WIDTH_PCT = 0.4; @@ -71,23 +76,72 @@ function chooseTickIntervalMs(rangeMs: number): number { return 60 * 60_000; } +function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode { + return ( + <> + {phase.label} + + + {formatAbsoluteTs(new Date(phase.startMs).toISOString())} + + + + {durationMs != null ? formatDurationMs(durationMs) : "--"} + + + + + ); +} + function phaseRow(phase: RunPhase, nowMs: number): Row { const endMs = phase.endMs; + const inFlight = endMs == null; const closedEnd = endMs ?? nowMs; - const durationMs = closedEnd - phase.startMs; + const rawDuration = closedEnd - phase.startMs; + const durationMs = rawDuration >= 0 ? rawDuration : null; return { key: `phase:${phase.kind}`, kind: "phase", label: phase.label, startMs: phase.startMs, endMs, - durationMs: durationMs >= 0 ? durationMs : null, - barClass: endMs == null ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", + durationMs, + barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", href: null, - tooltip: `${phase.label} • started ${formatAbsoluteTs(new Date(phase.startMs).toISOString())}`, + popover: phasePopover(phase, durationMs, inFlight), }; } +function stagePopover( + stage: RunStage, + durationMs: number | null, + inFlight: boolean, +): ReactNode { + return ( + <> + {formatStageLabel(stage)} + + + + {stageStatusLabel(stage.status)} + + + {stage.started_at && ( + {formatAbsoluteTs(stage.started_at)} + )} + + + {durationMs != null ? formatDurationMs(durationMs) : "--"} + + + + + ); +} + function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { if (!stage.started_at) return null; const startMs = Date.parse(stage.started_at); @@ -105,7 +159,7 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { durationMs, barClass: stageBarClass(stage.status), href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`, - tooltip: `${formatStageLabel(stage)} • ${stage.status} • started ${formatAbsoluteTs(stage.started_at)}`, + popover: stagePopover(stage, durationMs, inFlight), }; } @@ -242,11 +296,8 @@ function WaterfallRow({ row.kind === "phase" ? "text-fg-muted" : "text-fg-2"; - const content = ( -
+ const inner = ( +
{row.label}
@@ -261,15 +312,19 @@ function WaterfallRow({
); - if (row.href) { - return ( - - {content} - - ); - } - return content; + const trigger = row.href ? ( + + {inner} + + ) : ( + inner + ); + return ( + + {trigger} + + ); } diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index 3b7aebe00..5f460d4e8 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -226,9 +226,11 @@ function hoverCardStyle(rect: DOMRect): CSSProperties { export function HoverCard({ content, children, + className = "inline-flex", }: { content: ReactNode; children: ReactNode; + className?: string; }) { const { open, rect, triggerProps } = useHoverAnchor(); const id = useId(); @@ -236,7 +238,7 @@ export function HoverCard({ return ( <> - + {children} {rect && portalTarget @@ -255,3 +257,24 @@ export function HoverCard({ ); } + +export function PopoverHeader({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function PopoverRows({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function PopoverRow({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 4ba358ee1..86cb6f7e4 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -38,7 +38,15 @@ import { InterviewDock } from "../components/interview-dock"; import { SteerBar, type SteerBarHandle } from "../components/steer-bar"; import { ErrorState } from "../components/state"; import { useToast } from "../components/toast"; -import { ConfirmDialog, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui"; +import { + ConfirmDialog, + HoverCard, + PopoverHeader, + PopoverRow, + PopoverRows, + SECONDARY_BUTTON_CLASS, + Tooltip, +} from "../components/ui"; import { isRunStatus, mapRunToRunItem, @@ -193,27 +201,6 @@ export function meta({ data }: any) { // ---- Header hover-card popovers ---- -function PopoverHeader({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - -function PopoverRows({ children }: { children: ReactNode }) { - return
{children}
; -} - -function PopoverRow({ label, children }: { label: string; children: ReactNode }) { - return ( - <> -
{label}
-
{children}
- - ); -} - function humanizeFailureReason(reason: string): string { const spaced = reason.replace(/_/g, " "); return spaced.charAt(0).toUpperCase() + spaced.slice(1);