fix(frontend): improve agent runs page loading state (#10914)

## Changes 🏗️


https://github.com/user-attachments/assets/356e5364-45be-4f6e-bd1c-cc8e42bf294d

And also tidy up the some of the logic around hooks. I also added a
`okData` helper to avoid having to type case ( `as` ) so much with the
generated types ( given the `response` is a union depending on `status:
200 | 400 | 401` ... )

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run PR locally with the `new-agent-runs` flag enabled
  - [x] Check the nice loading state 

### For configuration changes:

None
This commit is contained in:
Ubbe
2025-09-15 13:56:26 +09:00
committed by GitHub
parent 7c2df24d7c
commit 6575b655f0
7 changed files with 273 additions and 180 deletions

View File

@@ -5,89 +5,73 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { useAgentRunsView } from "./useAgentRunsView";
import { AgentRunsLoading } from "./components/AgentRunsLoading";
import { RunsSidebar } from "./components/RunsSidebar/RunsSidebar";
import React, { useMemo, useState } from "react";
import { RunDetails } from "./components/RunDetails/RunDetails";
import { ScheduleDetails } from "./components/ScheduleDetails/ScheduleDetails";
import { EmptyAgentRuns } from "./components/EmptyAgentRuns/EmptyAgentRuns";
import { Button } from "@/components/atoms/Button/Button";
import { RunAgentModal } from "./components/RunAgentModal/RunAgentModal";
import { PlusIcon } from "@phosphor-icons/react";
export function AgentRunsView() {
const {
response,
agent,
hasAnyItems,
showSidebarLayout,
ready,
error,
agentId,
selectedRun,
handleSelectRun,
clearSelectedRun,
handleCountsChange,
handleClearSelectedRun,
} = useAgentRunsView();
const [sidebarCounts, setSidebarCounts] = useState({
runsCount: 0,
schedulesCount: 0,
});
const hasAnyItems = useMemo(
() =>
(sidebarCounts.runsCount ?? 0) > 0 ||
(sidebarCounts.schedulesCount ?? 0) > 0,
[sidebarCounts],
);
if (!ready) {
return <AgentRunsLoading />;
}
if (error) {
return (
<ErrorCard
isSuccess={false}
responseError={error || undefined}
httpError={
response?.status !== 200
? {
status: response?.status,
statusText: "Request failed",
}
: undefined
}
context="agent"
onRetry={() => window.location.reload()}
/>
);
}
if (!response?.data || response.status !== 200) {
return (
<ErrorCard
isSuccess={false}
responseError={{ message: "No agent data found" }}
context="agent"
onRetry={() => window.location.reload()}
/>
);
if (!ready || !agent) {
return <AgentRunsLoading />;
}
const agent = response.data;
return (
<div
className={
hasAnyItems
showSidebarLayout
? "grid h-screen grid-cols-1 gap-0 pt-6 md:gap-4 lg:grid-cols-[25%_70%]"
: "grid h-screen grid-cols-1 gap-0 pt-6 md:gap-4"
}
>
<div className={hasAnyItems ? "" : "hidden"}>
<div className={showSidebarLayout ? "p-4 pl-5" : "hidden p-4 pl-5"}>
<div className="mb-6">
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
}
agent={agent}
agentId={agent.id.toString()}
/>
</div>
<RunsSidebar
agent={agent}
selectedRunId={selectedRun}
onSelectRun={handleSelectRun}
onCountsChange={setSidebarCounts}
onCountsChange={handleCountsChange}
/>
</div>
{/* Main Content - 70% */}
<div className="p-4">
<div className={!hasAnyItems ? "px-2" : ""}>
<div className={!showSidebarLayout ? "px-2" : ""}>
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -101,14 +85,14 @@ export function AgentRunsView() {
<ScheduleDetails
agent={agent}
scheduleId={selectedRun.replace("schedule:", "")}
onClearSelectedRun={clearSelectedRun}
onClearSelectedRun={handleClearSelectedRun}
/>
) : (
<RunDetails
agent={agent}
runId={selectedRun}
onSelectRun={handleSelectRun}
onClearSelectedRun={clearSelectedRun}
onClearSelectedRun={handleClearSelectedRun}
/>
)
) : hasAnyItems ? (

View File

@@ -4,9 +4,9 @@ import { Skeleton } from "@/components/ui/skeleton";
export function AgentRunsLoading() {
return (
<div className="px-6 py-6">
<div className="flex h-screen w-full gap-4">
<div className="flex h-screen w-full gap-8">
{/* Left Sidebar */}
<div className="w-80 space-y-4">
<div className="w-[20vw] space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />

View File

@@ -1,15 +1,12 @@
"use client";
import React, { useEffect } from "react";
import React from "react";
import {
TabsLine,
TabsLineList,
TabsLineTrigger,
TabsLineContent,
} from "@/components/molecules/TabsLine/TabsLine";
import { Button } from "@/components/atoms/Button/Button";
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
import { RunAgentModal } from "../RunAgentModal/RunAgentModal";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useRunsSidebar } from "./useRunsSidebar";
import { RunListItem } from "./components/RunListItem";
@@ -26,6 +23,7 @@ interface RunsSidebarProps {
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
loading?: boolean;
}) => void;
}
@@ -47,11 +45,11 @@ export function RunsSidebar({
isFetchingMoreRuns,
tabValue,
setTabValue,
} = useRunsSidebar({ graphId: agent.graph_id, onSelectRun });
useEffect(() => {
if (onCountsChange) onCountsChange({ runsCount, schedulesCount });
}, [runsCount, schedulesCount, onCountsChange]);
} = useRunsSidebar({
graphId: agent.graph_id,
onSelectRun,
onCountsChange,
});
if (error) {
return <ErrorCard responseError={error} />;
@@ -59,7 +57,7 @@ export function RunsSidebar({
if (loading) {
return (
<div className="ml-6 w-80 space-y-4">
<div className="ml-6 w-[20vw] space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
@@ -68,77 +66,64 @@ export function RunsSidebar({
}
return (
<div className="min-w-0 bg-gray-50 p-4 pl-5">
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled";
setTabValue(value);
if (value === "runs") {
if (runs && runs.length) onSelectRun(runs[0].id);
} else {
if (schedules && schedules.length)
onSelectRun(`schedule:${schedules[0].id}`);
}
agent={agent}
agentId={agent.id.toString()}
/>
}}
className="min-w-0 overflow-hidden"
>
<TabsLineList>
<TabsLineTrigger value="runs">
Runs <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled <span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
</TabsLineList>
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled";
setTabValue(value);
if (value === "runs") {
if (runs && runs.length) onSelectRun(runs[0].id);
} else {
if (schedules && schedules.length)
onSelectRun(`schedule:${schedules[0].id}`);
}
}}
className="mt-6 min-w-0 overflow-hidden"
>
<TabsLineList>
<TabsLineTrigger value="runs">
Runs <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled{" "}
<span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
</TabsLineList>
<>
<TabsLineContent value="runs">
<InfiniteList
items={runs}
hasMore={!!hasMoreRuns}
isFetchingMore={isFetchingMoreRuns}
onEndReached={fetchMoreRuns}
className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden"
itemWrapperClassName="w-auto lg:w-full"
renderItem={(run) => (
<div className="mb-3 w-[15rem] lg:w-full">
<RunListItem
run={run}
title={agent.name}
selected={selectedRunId === run.id}
onClick={() => onSelectRun && onSelectRun(run.id)}
/>
</div>
)}
/>
</TabsLineContent>
<TabsLineContent value="scheduled">
<div className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden">
{schedules.map((s: GraphExecutionJobInfo) => (
<div className="mb-3 w-[15rem] lg:w-full" key={s.id}>
<ScheduleListItem
schedule={s}
selected={selectedRunId === `schedule:${s.id}`}
onClick={() => onSelectRun(`schedule:${s.id}`)}
/>
</div>
))}
</div>
</TabsLineContent>
</>
</TabsLine>
</div>
<>
<TabsLineContent value="runs">
<InfiniteList
items={runs}
hasMore={!!hasMoreRuns}
isFetchingMore={isFetchingMoreRuns}
onEndReached={fetchMoreRuns}
className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden"
itemWrapperClassName="w-auto lg:w-full"
renderItem={(run) => (
<div className="mb-3 w-[15rem] lg:w-full">
<RunListItem
run={run}
title={agent.name}
selected={selectedRunId === run.id}
onClick={() => onSelectRun && onSelectRun(run.id)}
/>
</div>
)}
/>
</TabsLineContent>
<TabsLineContent value="scheduled">
<div className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden">
{schedules.map((s: GraphExecutionJobInfo) => (
<div className="mb-3 w-[15rem] lg:w-full" key={s.id}>
<ScheduleListItem
schedule={s}
selected={selectedRunId === `schedule:${s.id}`}
onClick={() => onSelectRun(`schedule:${s.id}`)}
/>
</div>
))}
</div>
</TabsLineContent>
</>
</TabsLine>
);
}

View File

@@ -0,0 +1,66 @@
import type { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
import type { InfiniteData } from "@tanstack/react-query";
const AGENT_RUNNING_POLL_INTERVAL = 1500;
function hasValidExecutionsData(
page: unknown,
): page is { data: GraphExecutionsPaginated } {
return (
typeof page === "object" &&
page !== null &&
"data" in page &&
typeof (page as { data: unknown }).data === "object" &&
(page as { data: unknown }).data !== null &&
"executions" in (page as { data: GraphExecutionsPaginated }).data
);
}
export function getRunsPollingInterval(
pages: Array<unknown> | undefined,
isRunsTab: boolean,
): number | false {
if (!isRunsTab || !pages?.length) return false;
try {
const executions = pages.flatMap((page) => {
if (!hasValidExecutionsData(page)) return [];
return page.data.executions || [];
});
const hasActive = executions.some(
(e) => e.status === "RUNNING" || e.status === "QUEUED",
);
return hasActive ? AGENT_RUNNING_POLL_INTERVAL : false;
} catch {
return false;
}
}
export function computeRunsCount(
infiniteData: InfiniteData<unknown> | undefined,
runsLength: number,
): number {
const lastPage = infiniteData?.pages.at(-1);
if (!hasValidExecutionsData(lastPage)) return runsLength;
return lastPage.data.pagination?.total_items || runsLength;
}
export function getNextRunsPageParam(lastPage: unknown): number | undefined {
if (!hasValidExecutionsData(lastPage)) return undefined;
const { pagination } = lastPage.data;
const hasMore =
pagination.current_page * pagination.page_size < pagination.total_items;
return hasMore ? pagination.current_page + 1 : undefined;
}
export function extractRunsFromPages(
infiniteData: InfiniteData<unknown> | undefined,
) {
return (
infiniteData?.pages.flatMap((page) => {
if (!hasValidExecutionsData(page)) return [];
return page.data.executions || [];
}) || []
);
}

View File

@@ -4,18 +4,27 @@ import { useEffect, useMemo, useState } from "react";
import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useSearchParams } from "next/navigation";
const AGENT_RUNNING_POLL_INTERVAL = 1500;
import { okData } from "@/app/api/helpers";
import {
getRunsPollingInterval,
computeRunsCount,
getNextRunsPageParam,
extractRunsFromPages,
} from "./helpers";
type Args = {
graphId?: string;
onSelectRun: (runId: string) => void;
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
loading?: boolean;
}) => void;
};
export function useRunsSidebar({ graphId, onSelectRun }: Args) {
export function useRunsSidebar({ graphId, onSelectRun, onCountsChange }: Args) {
const params = useSearchParams();
const existingRunId = params.get("executionId") as string | undefined;
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
@@ -26,38 +35,11 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
{
query: {
enabled: !!graphId,
// Lightweight polling so statuses refresh; only poll if any run is active
refetchInterval: (q) => {
if (tabValue !== "runs") return false;
const pages = q.state.data?.pages as
| Array<{ data: unknown }>
| undefined;
if (!pages || pages.length === 0) return false;
try {
const executions = pages.flatMap((p) => {
const response = p.data as GraphExecutionsPaginated;
return response.executions || [];
});
const hasActive = executions.some(
(e: { status?: string }) =>
e.status === "RUNNING" || e.status === "QUEUED",
);
return hasActive ? AGENT_RUNNING_POLL_INTERVAL : false;
} catch {
return false;
}
},
refetchInterval: (q) =>
getRunsPollingInterval(q.state.data?.pages, tabValue === "runs"),
refetchIntervalInBackground: true,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as GraphExecutionsPaginated)
.pagination;
const hasMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return hasMore ? pagination.current_page + 1 : undefined;
},
getNextPageParam: getNextRunsPageParam,
},
},
);
@@ -65,19 +47,31 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
const schedulesQuery = useGetV1ListExecutionSchedulesForAGraph(
graphId || "",
{
query: { enabled: !!graphId },
query: {
enabled: !!graphId,
select: (r) => okData<GraphExecutionJobInfo[]>(r) ?? [],
},
},
);
const runs = useMemo(
() =>
runsQuery.data?.pages.flatMap((p) => {
const response = p.data as GraphExecutionsPaginated;
return response.executions;
}) || [],
() => extractRunsFromPages(runsQuery.data),
[runsQuery.data],
);
const schedules = schedulesQuery.data || [];
const runsCount = computeRunsCount(runsQuery.data, runs.length);
const schedulesCount = schedules.length;
const loading = !schedulesQuery.isSuccess || !runsQuery.isSuccess;
// Notify parent about counts and loading state
useEffect(() => {
if (onCountsChange) {
onCountsChange({ runsCount, schedulesCount, loading });
}
}, [runsCount, schedulesCount, loading, onCountsChange]);
useEffect(() => {
if (runs.length > 0) {
if (existingRunId) {
@@ -94,9 +88,6 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
else setTabValue("runs");
}, [existingRunId]);
const schedules: GraphExecutionJobInfo[] =
schedulesQuery.data?.status === 200 ? schedulesQuery.data.data : [];
// If there are no runs but there are schedules, and nothing is selected, auto-select the first schedule
useEffect(() => {
if (!existingRunId && runs.length === 0 && schedules.length > 0)
@@ -107,17 +98,12 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
runs,
schedules,
error: schedulesQuery.error || runsQuery.error,
loading: !schedulesQuery.isSuccess || !runsQuery.isSuccess,
loading,
runsQuery,
tabValue,
setTabValue,
runsCount:
(
runsQuery.data?.pages.at(-1)?.data as
| GraphExecutionsPaginated
| undefined
)?.pagination.total_items || runs.length,
schedulesCount: schedules.length,
runsCount,
schedulesCount,
fetchMoreRuns: runsQuery.fetchNextPage,
hasMoreRuns: runsQuery.hasNextPage,
isFetchingMoreRuns: runsQuery.isFetchingNextPage,

View File

@@ -1,30 +1,77 @@
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { okData } from "@/app/api/helpers";
import { useParams } from "next/navigation";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useMemo, useState } from "react";
export function useAgentRunsView() {
const { id } = useParams();
const agentId = id as string;
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
const {
data: response,
isSuccess,
error,
} = useGetV2GetLibraryAgent(agentId, {
query: {
select: okData<LibraryAgent>,
},
});
const [runParam, setRunParam] = useQueryState("executionId", parseAsString);
const selectedRun = runParam ?? undefined;
const [sidebarCounts, setSidebarCounts] = useState({
runsCount: 0,
schedulesCount: 0,
});
const [sidebarLoading, setSidebarLoading] = useState(true);
const hasAnyItems = useMemo(
() =>
(sidebarCounts.runsCount ?? 0) > 0 ||
(sidebarCounts.schedulesCount ?? 0) > 0,
[sidebarCounts],
);
// Show sidebar layout while loading or when there are items
const showSidebarLayout = sidebarLoading || hasAnyItems;
function handleSelectRun(id: string) {
setRunParam(id, { shallow: true });
}
function clearSelectedRun() {
function handleClearSelectedRun() {
setRunParam(null, { shallow: true });
}
const handleCountsChange = useCallback(
(counts: {
runsCount: number;
schedulesCount: number;
loading?: boolean;
}) => {
setSidebarCounts({
runsCount: counts.runsCount,
schedulesCount: counts.schedulesCount,
});
if (counts.loading !== undefined) {
setSidebarLoading(counts.loading);
}
},
[],
);
return {
agentId: id,
ready: isSuccess,
error,
response,
agent: response,
hasAnyItems,
showSidebarLayout,
selectedRun,
handleClearSelectedRun,
handleCountsChange,
handleSelectRun,
clearSelectedRun,
};
}

View File

@@ -0,0 +1,25 @@
/**
* Narrow an orval response to its success payload if and only if it is a `200` status with OK shape.
*
* Usage with React Query select:
* ```ts
* const { data: agent } = useGetV2GetLibraryAgent(agentId, {
* query: { select: okData<LibraryAgent> },
* });
*
* data // is now properly typed as LibraryAgent | undefined
* ```
*/
export function okData<T>(res: unknown): T | undefined {
if (!res || typeof res !== "object") return undefined;
// status must exist and be exactly 200
const maybeStatus = (res as { status?: unknown }).status;
if (maybeStatus !== 200) return undefined;
// data must exist and be an object/array/string/number/etc. We only need to
// check presence to safely return it as T; the generic T is enforced at call sites.
if (!("data" in (res as Record<string, unknown>))) return undefined;
return (res as { data: T }).data;
}