feat(web): add detail popovers to the run header

Hovering the run header items now reveals a popover with extra
context:

- Run status: failure reason and error message for failed runs;
  archived timestamp for archived runs (no popover otherwise)
- Repository: full owner/repo name and the cloned branch
- Workflow: node and edge counts plus run labels
- PR: live GitHub details fetched lazily on hover — title, an
  open/draft/merged/closed badge, and the head -> base branch arrow

Workflow node/edge counts are new: WorkflowRef now carries
node_count/edge_count, computed in build_summary from the parsed
graph that is already in hand there.

Adds a HoverCard primitive alongside Tooltip (shared useHoverAnchor
hook) for rich, viewport-aware popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-21 14:10:39 -04:00
parent b2925cce38
commit 5095873ddd
No known key found for this signature in database
12 changed files with 368 additions and 42 deletions

View file

@ -2,7 +2,7 @@
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useId, useRef, useState, type ReactNode } from "react";
import { useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
@ -153,30 +153,36 @@ export function ConfirmDialog({
);
}
// Shared hover/focus state for `Tooltip` and `HoverCard`. Returns the trigger
// props to spread and the trigger rect (only while open) for positioning.
function useHoverAnchor() {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLSpanElement>(null);
const rect = open ? (triggerRef.current?.getBoundingClientRect() ?? null) : null;
const triggerProps = {
ref: triggerRef,
onMouseEnter: () => setOpen(true),
onMouseLeave: () => setOpen(false),
onFocus: () => setOpen(true),
onBlur: () => setOpen(false),
};
return { open, rect, triggerProps };
}
export function Tooltip({
label,
children,
}: {
label: React.ReactNode;
children: React.ReactNode;
label: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLSpanElement>(null);
const { open, rect, triggerProps } = useHoverAnchor();
const id = useId();
const rect = open ? triggerRef.current?.getBoundingClientRect() : null;
const portalTarget = typeof document === "undefined" ? null : document.body;
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
aria-describedby={open ? id : undefined}
className="inline-flex"
>
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
{children}
</span>
{rect && portalTarget
@ -198,3 +204,54 @@ export function Tooltip({
</>
);
}
// Anchors the card just below the trigger. Triggers in the left half of the
// viewport grow rightward from their left edge; triggers in the right half
// grow leftward from their right edge — keeping the card on-screen without
// measuring its width.
function hoverCardStyle(rect: DOMRect): CSSProperties {
const margin = 12;
const top = rect.bottom + 6;
if (rect.left > window.innerWidth / 2) {
return { top, right: Math.max(margin, window.innerWidth - rect.right) };
}
return { top, left: Math.max(margin, rect.left) };
}
/**
* Hover-triggered rich popover. Unlike `Tooltip`, it anchors below the trigger,
* stays within the viewport, and allows multi-line wrapping content. The
* `content` node is mounted only while open, so consumers may fetch lazily.
*/
export function HoverCard({
content,
children,
}: {
content: ReactNode;
children: ReactNode;
}) {
const { open, rect, triggerProps } = useHoverAnchor();
const id = useId();
const portalTarget = typeof document === "undefined" ? null : document.body;
return (
<>
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
{children}
</span>
{rect && portalTarget
? createPortal(
<div
role="tooltip"
id={id}
style={hoverCardStyle(rect)}
className="pointer-events-none fixed z-50 max-w-[18rem] rounded-lg bg-panel p-3 text-xs text-fg-2 shadow-xl outline-1 -outline-offset-1 outline-line-strong"
>
{content}
</div>,
portalTarget,
)
: null}
</>
);
}

View file

@ -13,6 +13,7 @@ import type {
PaginatedRunStageList,
PaginatedWorkflowListResponse,
ProviderList,
PullRequestResponse,
RunArtifactListResponse,
RunBilling,
RunProjection,
@ -290,6 +291,15 @@ export function useRunQuestions(id: string | undefined, enabled: boolean) {
);
}
// Fetches live pull request details from GitHub. The header popover mounts the
// consumer of this hook only on hover, so the request stays lazy.
export function useRunPullRequest(id: string | undefined) {
return useSWR<PullRequestResponse | null>(
id ? queryKeys.runs.pullRequest(id) : null,
() => apiNullableData(() => runsApi.getRunPullRequest(id!)),
);
}
export function useRunStageEvents(id: string | undefined, stageId: string | undefined) {
return useSWR<EventEnvelope[]>(
id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null,

View file

@ -74,6 +74,7 @@ export const queryKeys = {
["runs", "sandbox-file", id, path] as const,
sandboxVnc: (id: string) => ["runs", "sandbox-vnc", id] as const,
sandboxServices: (id: string) => ["runs", "sandbox-services", id] as const,
pullRequest: (id: string) => ["runs", "pull-request", id] as const,
preview: (id: string) => ["runs", "preview", id] as const,
cancel: (id: string) => ["runs", "cancel", id] as const,
archive: (id: string) => ["runs", "archive", id] as const,

View file

@ -1,8 +1,10 @@
import {
Fragment,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import {
ArrowPathIcon,
@ -22,13 +24,19 @@ 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, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
import { ConfirmDialog, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui";
import {
isRunStatus,
mapRunToRunItem,
runStatusDisplay,
type Run,
} from "../data/runs";
import type {
PullRequestDetails,
RepositoryRef,
RunLifecycle,
WorkflowRef,
} from "@qltysh/fabro-api-client";
import { useDemoMode } from "../lib/demo-mode";
import { useSWRConfig } from "swr";
import {
@ -44,7 +52,7 @@ import { formatAbsoluteTs, formatRelativeTime } from "../lib/format";
import { queryKeys } from "../lib/query-keys";
import { useRunEvents } from "../lib/run-events";
import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
import { useRun, useRunPullRequest, useRunQuestions, useRunState } from "../lib/queries";
import {
canArchive,
canCancel,
@ -167,11 +175,174 @@ export function meta({ data }: any) {
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
}
// ---- Header hover-card popovers ----
function PopoverHeader({ children }: { children: ReactNode }) {
return (
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
{children}
</div>
);
}
function PopoverRows({ children }: { children: ReactNode }) {
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
}
function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt className="text-fg-3">{label}</dt>
<dd className="min-w-0 text-fg">{children}</dd>
</>
);
}
function humanizeFailureReason(reason: string): string {
const spaced = reason.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/** Shown only when the run failed or is archived — see `showStatusPopover`. */
function StatusPopover({ lifecycle }: { lifecycle: RunLifecycle }) {
const status = lifecycle.status;
return (
<>
<PopoverHeader>Run status</PopoverHeader>
<PopoverRows>
{status.kind === "failed" && (
<PopoverRow label="Reason">{humanizeFailureReason(status.reason)}</PopoverRow>
)}
{lifecycle.error && (
<PopoverRow label="Error">
<span className="break-words">{lifecycle.error.message}</span>
</PopoverRow>
)}
{lifecycle.archived && (
<PopoverRow label="Archived">
{lifecycle.archived_at ? formatAbsoluteTs(lifecycle.archived_at) : "Yes"}
</PopoverRow>
)}
</PopoverRows>
</>
);
}
function RepositoryPopover({
repository,
cloneBranch,
}: {
repository: RepositoryRef;
cloneBranch: string | null | undefined;
}) {
return (
<>
<PopoverHeader>Repository</PopoverHeader>
<PopoverRows>
<PopoverRow label="Name">
<span className="font-mono break-all">{repository.name}</span>
</PopoverRow>
{cloneBranch && (
<PopoverRow label="Branch">
<span className="font-mono break-all">{cloneBranch}</span>
</PopoverRow>
)}
</PopoverRows>
</>
);
}
function WorkflowPopover({
workflow,
labels,
}: {
workflow: WorkflowRef;
labels: Record<string, string>;
}) {
const labelEntries = Object.entries(labels);
const hasCounts = workflow.node_count > 0 || workflow.edge_count > 0;
return (
<>
<PopoverHeader>Workflow</PopoverHeader>
{hasCounts && (
<div className="text-fg">
{workflow.node_count} {workflow.node_count === 1 ? "node" : "nodes"}
<span className="text-fg-muted"> · </span>
{workflow.edge_count} {workflow.edge_count === 1 ? "edge" : "edges"}
</div>
)}
{labelEntries.length > 0 && (
<div className={hasCounts ? "mt-2" : undefined}>
<div className="mb-1 text-fg-3">Labels</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
{labelEntries.map(([key, value]) => (
<Fragment key={key}>
<dt className="font-mono text-fg-3">{key}</dt>
<dd className="min-w-0 font-mono break-all text-fg">{value}</dd>
</Fragment>
))}
</dl>
</div>
)}
</>
);
}
function prStateBadge(details: PullRequestDetails): { label: string; className: string } {
if (details.merged) return { label: "Merged", className: "bg-mint/15 text-mint" };
if (details.draft) return { label: "Draft", className: "bg-overlay-strong text-fg-3" };
if (details.state === "closed") {
return { label: "Closed", className: "bg-coral/15 text-coral" };
}
return { label: "Open", className: "bg-teal-500/15 text-teal-300" };
}
/** Fetches live PR details on hover — mounted only while the card is open. */
function PullRequestPopover({ runId }: { runId: string }) {
const prQuery = useRunPullRequest(runId);
const response = prQuery.data;
const details =
response?.meta.details_status === "available" ? response.data.details : null;
let body: ReactNode;
if (prQuery.isLoading) {
body = <div className="text-fg-3">Loading…</div>;
} else if (!details) {
body = <div className="text-fg-3">Live details unavailable.</div>;
} else {
const badge = prStateBadge(details);
body = (
<div className="space-y-2">
<div className="break-words text-fg">{details.title}</div>
<div className="flex items-center gap-2">
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium ${badge.className}`}
>
{badge.label}
</span>
<span className="flex min-w-0 items-center gap-1 font-mono text-fg-3">
<span className="truncate">{details.head_branch}</span>
<span className="shrink-0 text-fg-muted">→</span>
<span className="truncate">{details.base_branch}</span>
</span>
</div>
</div>
);
}
return (
<>
<PopoverHeader>Pull request</PopoverHeader>
{body}
</>
);
}
export default function RunDetail({ params }: { params: { id: string } }) {
const demoMode = useDemoMode();
const runQuery = useRun(params.id);
const runStateQuery = useRunState(params.id);
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
const summary = runQuery.data;
const run = summary ? buildRunDetailRun(summary) : null;
const statusKind = runQuery.data?.lifecycle.status.kind;
const isBlocked = statusKind === "blocked";
const questionsQuery = useRunQuestions(params.id, isBlocked);
@ -246,7 +417,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
return <div className="py-12" />;
}
if (!run) {
if (!run || !summary) {
return (
<div className="py-12">
<ErrorState
@ -257,6 +428,33 @@ export default function RunDetail({ params }: { params: { id: string } }) {
);
}
const showStatusPopover =
summary.lifecycle.status.kind === "failed" ||
summary.lifecycle.archived ||
summary.lifecycle.error != null;
const showWorkflowPopover =
summary.workflow.node_count > 0 ||
summary.workflow.edge_count > 0 ||
Object.keys(summary.labels).length > 0;
const statusBadge = (
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${run.statusDot}`} />
<span className={`font-medium ${run.statusText}`}>{run.statusLabel}</span>
</span>
);
const repoChip = (
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<FolderIcon className="size-3.5" aria-hidden="true" />
{run.repo}
</span>
);
const workflowChip = (
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<RectangleStackIcon className="size-3.5" aria-hidden="true" />
{run.workflow}
</span>
);
const visibility = lifecycleActionVisibility(run.lifecycleStatus);
const previewPending = previewMutation.isMutating;
const cancelPending = cancelMutation.isMutating;
@ -315,18 +513,38 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<div className="min-w-0 flex-1">
<EditableRunTitle runId={params.id} title={run.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${run.statusDot}`} />
<span className={`font-medium ${run.statusText}`}>{run.statusLabel}</span>
</span>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<FolderIcon className="size-3.5" aria-hidden="true" />
{run.repo}
</span>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<RectangleStackIcon className="size-3.5" aria-hidden="true" />
{run.workflow}
</span>
{showStatusPopover ? (
<HoverCard content={<StatusPopover lifecycle={summary.lifecycle} />}>
{statusBadge}
</HoverCard>
) : (
statusBadge
)}
{summary.repository ? (
<HoverCard
content={
<RepositoryPopover
repository={summary.repository}
cloneBranch={summary.sandbox?.runtime?.clone_branch}
/>
}
>
{repoChip}
</HoverCard>
) : (
repoChip
)}
{showWorkflowPopover ? (
<HoverCard
content={
<WorkflowPopover workflow={summary.workflow} labels={summary.labels} />
}
>
{workflowChip}
</HoverCard>
) : (
workflowChip
)}
{run.elapsed && (
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
@ -347,15 +565,17 @@ export default function RunDetail({ params }: { params: { id: string } }) {
{demoMode && <ConnectMenu />}
{run.pullRequestUrl && run.number != null && (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
<GitPullRequestIcon className="size-4 text-mint" />
<span className="font-mono">#{run.number}</span>
</a>
<HoverCard content={<PullRequestPopover runId={params.id} />}>
<a
href={run.pullRequestUrl}
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
<GitPullRequestIcon className="size-4 text-mint" />
<span className="font-mono">#{run.number}</span>
</a>
</HoverCard>
)}
<ActionsMenu

View file

@ -7987,7 +7987,7 @@ components:
WorkflowRef:
type: object
required: [slug, name, graph_name]
required: [slug, name, graph_name, node_count, edge_count]
properties:
slug:
type: ["string", "null"]
@ -7995,6 +7995,14 @@ components:
type: ["string", "null"]
graph_name:
type: ["string", "null"]
node_count:
type: integer
format: int64
description: Number of nodes in the workflow graph.
edge_count:
type: integer
format: int64
description: Number of edges in the workflow graph.
AutomationRef:
type: object

View file

@ -32,6 +32,8 @@ fn run_summary_json_matches_openapi_shape() {
slug: Some("workflow".to_string()),
name: Some("Ship workflow".to_string()),
graph_name: Some("GraphName".to_string()),
node_count: 7,
edge_count: 9,
},
automation: None,
repository: Some(RepositoryRef {
@ -91,7 +93,9 @@ fn run_summary_json_matches_openapi_shape() {
"workflow": {
"slug": "workflow",
"name": "Ship workflow",
"graph_name": "GraphName"
"graph_name": "GraphName",
"node_count": 7,
"edge_count": 9
},
"automation": null,
"repository": {
@ -197,6 +201,8 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.workflow.name, None);
assert_eq!(summary.workflow.graph_name.as_deref(), Some("GraphName"));
assert_eq!(summary.workflow.slug, None);
assert_eq!(summary.workflow.node_count, 0);
assert_eq!(summary.workflow.edge_count, 0);
assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it");
assert_eq!(summary.labels, HashMap::new());

View file

@ -166,6 +166,8 @@ mod tests {
slug: Some("simple".to_string()),
name: Some("Simple".to_string()),
graph_name: Some("GraphName".to_string()),
node_count: 0,
edge_count: 0,
},
automation: None,
repository: None,

View file

@ -437,6 +437,8 @@ mod tests {
slug: Some("simple".to_string()),
name: Some("Simple".to_string()),
graph_name: Some("GraphName".to_string()),
node_count: 0,
edge_count: 0,
},
automation: None,
repository: None,

View file

@ -1117,6 +1117,8 @@ mod runs {
slug: Some(workflow_slug.into()),
name: Some(workflow_name.into()),
graph_name: None,
node_count: 0,
edge_count: 0,
},
automation: None,
repository: Some(RepositoryRef::from_origin_and_source(

View file

@ -640,6 +640,10 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
slug: state.spec.workflow_slug.clone(),
name: state.spec.workflow_name().map(ToOwned::to_owned),
graph_name: state.spec.graph_name().map(ToOwned::to_owned),
node_count: i64::try_from(state.spec.graph.nodes.len())
.expect("graph node count should fit in i64"),
edge_count: i64::try_from(state.spec.graph.edges.len())
.expect("graph edge count should fit in i64"),
},
automation: None,
repository: Some(RepositoryRef::from_origin_and_source(

View file

@ -54,6 +54,12 @@ pub struct WorkflowRef {
pub name: Option<String>,
#[serde(default)]
pub graph_name: Option<String>,
/// Number of nodes in the workflow graph.
#[serde(default)]
pub node_count: i64,
/// Number of edges in the workflow graph.
#[serde(default)]
pub edge_count: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -18,5 +18,13 @@ export interface WorkflowRef {
'slug': string | null;
'name': string | null;
'graph_name': string | null;
/**
* Number of nodes in the workflow graph.
*/
'node_count': number;
/**
* Number of edges in the workflow graph.
*/
'edge_count': number;
}