diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index d90f0251d..3b7aebe00 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -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(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(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 ( <> - setOpen(true)} - onMouseLeave={() => setOpen(false)} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - aria-describedby={open ? id : undefined} - className="inline-flex" - > + {children} {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 ( + <> + + {children} + + {rect && portalTarget + ? createPortal( + , + portalTarget, + ) + : null} + + ); +} diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index eced1b6d1..0f768e7fb 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -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( + id ? queryKeys.runs.pullRequest(id) : null, + () => apiNullableData(() => runsApi.getRunPullRequest(id!)), + ); +} + export function useRunStageEvents(id: string | undefined, stageId: string | undefined) { return useSWR( id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 5cc885c30..c0d323e82 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -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, diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index ba02cf0ea..750ff36e8 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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 ( +
+ {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); +} + +/** Shown only when the run failed or is archived — see `showStatusPopover`. */ +function StatusPopover({ lifecycle }: { lifecycle: RunLifecycle }) { + const status = lifecycle.status; + return ( + <> + Run status + + {status.kind === "failed" && ( + {humanizeFailureReason(status.reason)} + )} + {lifecycle.error && ( + + {lifecycle.error.message} + + )} + {lifecycle.archived && ( + + {lifecycle.archived_at ? formatAbsoluteTs(lifecycle.archived_at) : "Yes"} + + )} + + + ); +} + +function RepositoryPopover({ + repository, + cloneBranch, +}: { + repository: RepositoryRef; + cloneBranch: string | null | undefined; +}) { + return ( + <> + Repository + + + {repository.name} + + {cloneBranch && ( + + {cloneBranch} + + )} + + + ); +} + +function WorkflowPopover({ + workflow, + labels, +}: { + workflow: WorkflowRef; + labels: Record; +}) { + const labelEntries = Object.entries(labels); + const hasCounts = workflow.node_count > 0 || workflow.edge_count > 0; + return ( + <> + Workflow + {hasCounts && ( +
+ {workflow.node_count} {workflow.node_count === 1 ? "node" : "nodes"} + · + {workflow.edge_count} {workflow.edge_count === 1 ? "edge" : "edges"} +
+ )} + {labelEntries.length > 0 && ( +
+
Labels
+
+ {labelEntries.map(([key, value]) => ( + +
{key}
+
{value}
+
+ ))} +
+
+ )} + + ); +} + +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 =
Loading…
; + } else if (!details) { + body =
Live details unavailable.
; + } else { + const badge = prStateBadge(details); + body = ( +
+
{details.title}
+
+ + {badge.label} + + + {details.head_branch} + → + {details.base_branch} + +
+
+ ); + } + return ( + <> + Pull request + {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
; } - if (!run) { + if (!run || !summary) { return (
0 || + summary.workflow.edge_count > 0 || + Object.keys(summary.labels).length > 0; + const statusBadge = ( + + + {run.statusLabel} + + ); + const repoChip = ( + + + ); + const workflowChip = ( + + + ); + 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 } }) {
- - - {run.statusLabel} - - - - - + {showStatusPopover ? ( + }> + {statusBadge} + + ) : ( + statusBadge + )} + {summary.repository ? ( + + } + > + {repoChip} + + ) : ( + repoChip + )} + {showWorkflowPopover ? ( + + } + > + {workflowChip} + + ) : ( + workflowChip + )} {run.elapsed && (