Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/runs-list-live-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Runs index live-reloads visible run status, shows a new-runs refresh banner, and child-status tooltips on root rows.
5 changes: 3 additions & 2 deletions apps/webapp/app/components/primitives/Buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,12 @@ export function ButtonContent(props: ButtonContentPropsType) {

type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus"
"type" | "disabled" | "onClick" | "name" | "value" | "form" | "autoFocus" | "aria-label"
> &
React.ComponentProps<typeof ButtonContent>;

export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, autoFocus, onClick, ...props }, ref) => {
({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => {
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);

Expand Down Expand Up @@ -352,6 +352,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
ref={innerRef}
form={props.form}
autoFocus={autoFocus}
aria-label={ariaLabel}
>
<ButtonContent
{...props}
Expand Down
23 changes: 23 additions & 0 deletions apps/webapp/app/components/primitives/PulsingDot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";

export function PulsingDot({
className,
ringClassName,
dotClassName,
}: {
className?: string;
ringClassName?: string;
dotClassName?: string;
}) {
return (
<span className={cn("relative flex size-2", className)}>
<span
className={cn(
"absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000",
ringClassName
)}
/>
<span className={cn("size-2 rounded-full bg-blue-500", dotClassName)} />
</span>
);
}
6 changes: 4 additions & 2 deletions apps/webapp/app/components/primitives/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function SimpleTooltip({
sideOffset,
open,
onOpenChange,
delayDuration,
}: {
button: React.ReactNode;
content: React.ReactNode;
Expand All @@ -80,12 +81,13 @@ function SimpleTooltip({
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip open={open} onOpenChange={onOpenChange}>
<Tooltip open={open} onOpenChange={onOpenChange} delayDuration={delayDuration}>
<TooltipTrigger
type="button"
type={asChild ? undefined : "button"}
tabIndex={-1}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
Expand Down
247 changes: 247 additions & 0 deletions apps/webapp/app/components/runs/v3/RunStatusCellTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { useFetcher } from "@remix-run/react";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
import type { loader as childStatusesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses";
import { isFinalRunStatus } from "~/v3/taskStatus";
import {
descriptionForTaskRunStatus,
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";

const TOOLTIP_OPEN_DELAY_MS = 400;
const TOOLTIP_POLL_INTERVAL_MS = 3000;

type ChildStatusEntry = { status: NextRunListItem["status"]; count: number };

// Compare status/count pairs so unchanged polling responses don't
// re-render or re-animate the tooltip.
function childStatusesKey(statuses: ChildStatusEntry[]) {
return [...statuses]
.sort((a, b) => a.status.localeCompare(b.status))
.map((entry) => `${entry.status}:${entry.count}`)
.join("|");
}

function areChildStatusesEqual(previous: ChildStatusEntry[] | undefined, next: ChildStatusEntry[]) {
if (previous === undefined) return false;
return childStatusesKey(previous) === childStatusesKey(next);
}

function hasActiveChildStatuses(statuses: ChildStatusEntry[] | undefined) {
if (statuses === undefined) return false;

return statuses.some((entry) => entry.count > 0 && !isFinalRunStatus(entry.status));
}

function shouldPollWhileTooltipOpen(
statuses: ChildStatusEntry[] | undefined,
rootHasFinished: boolean
) {
if (statuses === undefined) return true;
// Empty child statuses while the root is still running can mean
// children have not been created yet, so keep polling.
if (statuses.length === 0) return !rootHasFinished;

// All current children may be final while the root is still running — more
// dependents can still be created.
return hasActiveChildStatuses(statuses) || !rootHasFinished;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function ChildStatusBreakdown({
orderedChildStatuses,
}: {
orderedChildStatuses: { status: NextRunListItem["status"]; count: number }[];
}) {
return (
<div className="flex min-w-[10rem] flex-col gap-1 p-1">
<AnimatePresence initial={false} mode="popLayout">
{orderedChildStatuses.map((entry) => (
<motion.div
key={entry.status}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center justify-between gap-2"
>
<TaskRunStatusCombo status={entry.status} />
<motion.span
key={entry.count}
layout
initial={{ opacity: 0.6, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="text-xs tabular-nums text-text-bright"
>
{entry.count}
</motion.span>
</motion.div>
))}
</AnimatePresence>
</div>
);
}

function useChildRunStatusesTooltip({
friendlyId,
hasFinished,
childrenStatusesBasePath,
}: {
friendlyId: string;
hasFinished: boolean;
childrenStatusesBasePath: string;
}) {
const fetcher = useFetcher<typeof childStatusesLoader>({
key: `child-statuses-${friendlyId}`,
});
const fetcherStateRef = useRef(fetcher.state);
fetcherStateRef.current = fetcher.state;

const [childStatuses, setChildStatuses] = useState<ChildStatusEntry[] | undefined>();
const isOpenRef = useRef(false);
const pollIntervalRef = useRef<ReturnType<typeof setInterval>>();
const prevHasFinishedRef = useRef(hasFinished);

const childrenStatusesUrl = useMemo(
() => `${childrenStatusesBasePath}/children-statuses?runIds=${encodeURIComponent(friendlyId)}`,
[childrenStatusesBasePath, friendlyId]
);

const loadChildStatuses = useCallback(() => {
if (fetcherStateRef.current !== "idle") return;
fetcher.load(childrenStatusesUrl);
}, [childrenStatusesUrl, fetcher]);

// Keep the latest loader callback available to the polling interval
// without recreating the interval on every render.
const loadChildStatusesRef = useRef(loadChildStatuses);
loadChildStatusesRef.current = loadChildStatuses;

const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = undefined;
}
}, []);

const startPolling = useCallback(() => {
if (pollIntervalRef.current) return;

pollIntervalRef.current = setInterval(() => {
if (document.visibilityState !== "visible") return;
loadChildStatusesRef.current();
}, TOOLTIP_POLL_INTERVAL_MS);
}, []);

useEffect(() => {
if (!fetcher.data?.runs) return;

const entry = fetcher.data.runs.find((run) => run.friendlyId === friendlyId);
if (!entry) return;

setChildStatuses((previous) =>
areChildStatusesEqual(previous, entry.statuses) ? previous : entry.statuses
);

if (isOpenRef.current && !shouldPollWhileTooltipOpen(entry.statuses, hasFinished)) {
stopPolling();
}
}, [fetcher.data, friendlyId, hasFinished, stopPolling]);

const onOpenChange = useCallback(
(open: boolean) => {
isOpenRef.current = open;
if (open) {
loadChildStatuses();
startPolling();
} else {
stopPolling();
}
},
[loadChildStatuses, startPolling, stopPolling]
);

useEffect(() => {
prevHasFinishedRef.current = hasFinished;
stopPolling();
setChildStatuses(undefined);
if (isOpenRef.current) {
loadChildStatuses();
startPolling();
}
// Only reset when the hovered run changes, not when hasFinished toggles.
// eslint-disable-next-line react-hooks/exhaustive-deps -- friendlyId
}, [friendlyId]);

useEffect(() => {
if (!isOpenRef.current) return;
if (prevHasFinishedRef.current === hasFinished) return;

prevHasFinishedRef.current = hasFinished;
loadChildStatuses();
}, [hasFinished, loadChildStatuses]);

useEffect(() => () => stopPolling(), [stopPolling]);

return { childStatuses, onOpenChange };
}

export function RunStatusCellTooltip({
friendlyId,
status,
hasFinished,
childrenStatusesBasePath,
}: {
friendlyId: string;
status: NextRunListItem["status"];
hasFinished: boolean;
childrenStatusesBasePath: string;
}) {
const { childStatuses, onOpenChange } = useChildRunStatusesTooltip({
friendlyId,
hasFinished,
childrenStatusesBasePath,
});

const orderedChildStatuses = useMemo(() => {
const childStatusesMap = new Map(
(childStatuses ?? []).map((entry) => [entry.status, entry.count])
);

return filterableTaskRunStatuses
.map((s) => ({
status: s,
count: childStatusesMap.get(s) ?? 0,
}))
.filter((entry) => entry.count > 0);
}, [childStatuses]);

const hasChildStatuses = orderedChildStatuses.length > 0;

return (
<SimpleTooltip
asChild
delayDuration={TOOLTIP_OPEN_DELAY_MS}
onOpenChange={onOpenChange}
content={
childStatuses === undefined ? (
<span className="text-xs text-text-dimmed">Loading child runs…</span>
) : hasChildStatuses ? (
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
) : (
descriptionForTaskRunStatus(status)
)
}
disableHoverableContent
button={
<span className="inline-flex min-w-full items-center">
<TaskRunStatusCombo status={status} />
</span>
}
/>
);
}
18 changes: 13 additions & 5 deletions apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { RunStatusCellTooltip } from "./RunStatusCellTooltip";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
Expand All @@ -74,6 +75,7 @@ type RunsTableProps = {
variant?: TableVariant;
disableAdjacentRows?: boolean;
additionalTableState?: Record<string, string>;
childrenStatusesBasePath?: string;
};

export function TaskRunsTable({
Expand All @@ -87,6 +89,7 @@ export function TaskRunsTable({
allowSelection = false,
variant = "dimmed",
additionalTableState,
childrenStatusesBasePath,
}: RunsTableProps) {
const regions = useRegions();
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
Expand Down Expand Up @@ -371,11 +374,16 @@ export function TaskRunsTable({
</TableCell>
<TableCell to={path}>{run.version ?? "–"}</TableCell>
<TableCell to={path}>
<SimpleTooltip
content={descriptionForTaskRunStatus(run.status)}
disableHoverableContent
button={<TaskRunStatusCombo status={run.status} />}
/>
{run.rootTaskRunId === null && childrenStatusesBasePath ? (
<RunStatusCellTooltip
friendlyId={run.friendlyId}
status={run.status}
hasFinished={run.hasFinished}
childrenStatusesBasePath={childrenStatusesBasePath}
/>
) : (
<TaskRunStatusCombo status={run.status} />
)}
</TableCell>
<TableCell to={path}>
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
Expand Down
Loading