Skip to content
Open
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
255 changes: 255 additions & 0 deletions apps/webapp/app/components/runs/v3/RunStatusCellTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
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,
isFetchingChildStatuses: fetcher.state !== "idle",
onOpenChange,
};
}

export function RunStatusCellTooltip({
friendlyId,
status,
hasFinished,
childrenStatusesBasePath,
}: {
friendlyId: string;
status: NextRunListItem["status"];
hasFinished: boolean;
childrenStatusesBasePath: string;
}) {
const { childStatuses, isFetchingChildStatuses, 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;
const showLoading =
childStatuses === undefined ||
(isFetchingChildStatuses && !hasChildStatuses) ||
(!hasChildStatuses && !hasFinished);
Comment on lines +228 to +231
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Tooltip shows 'Loading…' indefinitely for root runs that never spawn children while still executing

At RunStatusCellTooltip.tsx:228-231, showLoading is true when !hasChildStatuses && !hasFinished. This means a root run that never triggers any child tasks will show 'Loading …' in the tooltip for its entire execution duration. Only when the run finishes (hasFinished becomes true) does it fall through to show descriptionForTaskRunStatus(status). This is acceptable UX since the tooltip is designed to show child breakdowns and the 'loading' state implies children might still appear, but it could confuse users who hover over a simple root task that will never have children.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return (
<SimpleTooltip
asChild
delayDuration={TOOLTIP_OPEN_DELAY_MS}
onOpenChange={onOpenChange}
content={
showLoading ? (
<span className="text-xs text-text-dimmed">Loading …</span>
) : hasChildStatuses ? (
<ChildStatusBreakdown orderedChildStatuses={orderedChildStatuses} />
) : (
descriptionForTaskRunStatus(status)
)
}
disableHoverableContent
button={
<span className="inline-flex min-w-full items-center">
<TaskRunStatusCombo status={status} />
</span>
}
/>
);
}
22 changes: 17 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,20 @@ 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}
/>
) : (
<SimpleTooltip
content={descriptionForTaskRunStatus(run.status)}
disableHoverableContent
button={<TaskRunStatusCombo status={run.status} />}
/>
)}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
</TableCell>
<TableCell to={path}>
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
Expand Down
Loading