diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2f3de6c7cdc..c00f0279ed4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -92,6 +92,12 @@ function readRunLogLimitBytes(value: unknown) { return Math.max(1, Math.min(RUN_LOG_MAX_LIMIT_BYTES, Math.trunc(parsed))); } +function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(0, Math.min(max, Math.trunc(parsed))); +} + export function agentRoutes(db: Db) { // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. @@ -2486,8 +2492,8 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const minCountParam = req.query.minCount as string | undefined; - const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0; + const minCount = readLiveRunsQueryInt(req.query.minCount, 50); + const limit = readLiveRunsQueryInt(req.query.limit, 50); const columns = { id: heartbeatRuns.id, @@ -2515,7 +2521,7 @@ export function agentRoutes(db: Db) { issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), }; - const liveRuns = await db + const liveRunsQuery = db .select(columns) .from(heartbeatRuns) .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) @@ -2527,7 +2533,10 @@ export function agentRoutes(db: Db) { ) .orderBy(desc(heartbeatRuns.createdAt)); - if (minCount > 0 && liveRuns.length < minCount) { + const liveRuns = limit > 0 ? await liveRunsQuery.limit(limit) : await liveRunsQuery; + const targetRunCount = limit > 0 ? Math.min(minCount, limit) : minCount; + + if (targetRunCount > 0 && liveRuns.length < targetRunCount) { const activeIds = liveRuns.map((r) => r.id); const recentRuns = await db .select(columns) @@ -2541,7 +2550,7 @@ export function agentRoutes(db: Db) { ), ) .orderBy(desc(heartbeatRuns.createdAt)) - .limit(minCount - liveRuns.length); + .limit(targetRunCount - liveRuns.length); const rows = [...liveRuns, ...recentRuns]; res.json(await Promise.all(rows.map(async (run) => ({ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f495dc0e4d0..c34d8d966bb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; +import { DashboardLive } from "./pages/DashboardLive"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; @@ -58,6 +59,7 @@ function boardRoutes() { <> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts new file mode 100644 index 00000000000..9ba8d1635b1 --- /dev/null +++ b/ui/src/api/heartbeats.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { heartbeatsApi } from "./heartbeats"; + +describe("heartbeatsApi.liveRunsForCompany", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + it("keeps the legacy numeric minCount signature", async () => { + await heartbeatsApi.liveRunsForCompany("company-1", 4); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4"); + }); + + it("passes minCount and limit options to the company live-runs endpoint", async () => { + await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 50, limit: 50 }); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50"); + }); +}); diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index dc832dba549..b4bf89c81d7 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -101,8 +101,20 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/live-runs`), activeRunForIssue: (issueId: string) => api.get(`/issues/${issueId}/active-run`), - liveRunsForCompany: (companyId: string, minCount?: number) => - api.get(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`), + liveRunsForCompany: ( + companyId: string, + options?: number | { minCount?: number; limit?: number }, + ) => { + const searchParams = new URLSearchParams(); + if (typeof options === "number") { + searchParams.set("minCount", String(options)); + } else if (options) { + if (options.minCount) searchParams.set("minCount", String(options.minCount)); + if (options.limit) searchParams.set("limit", String(options.limit)); + } + const qs = searchParams.toString(); + return api.get(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`); + }, listInstanceSchedulerAgents: () => api.get("/instance/scheduler-heartbeats"), }; diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx new file mode 100644 index 00000000000..5a528976904 --- /dev/null +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ActiveAgentsPanel } from "./ActiveAgentsPanel"; + +const mockHeartbeatsApi = vi.hoisted(() => ({ + liveRunsForCompany: vi.fn(), +})); + +const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: mockHeartbeatsApi, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("./Identity", () => ({ + Identity: ({ name }: { name: string }) => {name}, +})); + +vi.mock("./RunChatSurface", () => ({ + RunChatSurface: () =>
Run output
, +})); + +vi.mock("./transcript/useLiveRunTranscripts", () => ({ + useLiveRunTranscripts: () => ({ + transcriptByRun: new Map(), + hasOutputForRun: () => false, + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function createRun(index: number) { + return { + id: `run-${index}`, + status: "running", + invocationSource: "assignment", + triggerDetail: null, + startedAt: "2026-04-24T12:00:00.000Z", + finishedAt: null, + createdAt: `2026-04-24T12:00:0${index}.000Z`, + agentId: `agent-${index}`, + agentName: `Agent ${index}`, + adapterType: "codex_local", + issueId: null, + }; +} + +describe("ActiveAgentsPanel", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun)); + mockIssuesApi.list.mockResolvedValue([]); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("links hidden active/recent runs to the full live dashboard", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { + minCount: 4, + limit: undefined, + }); + + const moreLink = [...container.querySelectorAll("a")].find((anchor) => + anchor.textContent?.includes("more active/recent"), + ); + expect(moreLink?.getAttribute("href")).toBe("/dashboard/live"); + + await act(async () => { + root.unmount(); + }); + }); + + it("can request the full live dashboard page limit without a hidden-runs link", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { + minCount: 50, + limit: 50, + }); + expect(container.textContent).not.toContain("more active/recent"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 5a5534f90e0..1b49624a312 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -25,16 +25,36 @@ function isRunActive(run: LiveRunForIssue): boolean { interface ActiveAgentsPanelProps { companyId: string; + title?: string; + minRunCount?: number; + fetchLimit?: number; + cardLimit?: number; + gridClassName?: string; + cardClassName?: string; + emptyMessage?: string; + queryScope?: string; + showMoreLink?: boolean; } -export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { +export function ActiveAgentsPanel({ + companyId, + title = "Agents", + minRunCount = MIN_DASHBOARD_RUNS, + fetchLimit, + cardLimit = DASHBOARD_RUN_CARD_LIMIT, + gridClassName, + cardClassName, + emptyMessage = "No recent agent runs.", + queryScope = "dashboard", + showMoreLink = true, +}: ActiveAgentsPanelProps) { const { data: liveRuns } = useQuery({ - queryKey: [...queryKeys.liveRuns(companyId), "dashboard"], - queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS), + queryKey: [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }], + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }), }); const runs = liveRuns ?? []; - const visibleRuns = useMemo(() => runs.slice(0, DASHBOARD_RUN_CARD_LIMIT), [runs]); + const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]); const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length); const { data: issues } = useQuery({ queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], @@ -62,14 +82,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { return (

- Agents + {title}

{runs.length === 0 ? (
-

No recent agent runs.

+

{emptyMessage}

) : ( -
+
{visibleRuns.map((run) => ( ))}
)} - {hiddenRunCount > 0 && ( + {showMoreLink && hiddenRunCount > 0 && (
- + {hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
@@ -101,6 +122,7 @@ const AgentRunCard = memo(function AgentRunCard({ transcript, hasOutput, isActive, + className, }: { companyId: string; run: LiveRunForIssue; @@ -108,6 +130,7 @@ const AgentRunCard = memo(function AgentRunCard({ transcript: TranscriptEntry[]; hasOutput: boolean; isActive: boolean; + className?: string; }) { return (
diff --git a/ui/src/pages/DashboardLive.tsx b/ui/src/pages/DashboardLive.tsx new file mode 100644 index 00000000000..c899c4bb781 --- /dev/null +++ b/ui/src/pages/DashboardLive.tsx @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { ArrowLeft, RadioTower } from "lucide-react"; +import { Link } from "@/lib/router"; +import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel"; +import { EmptyState } from "../components/EmptyState"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; + +const DASHBOARD_LIVE_RUN_LIMIT = 50; + +export function DashboardLive() { + const { selectedCompanyId, companies } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + useEffect(() => { + setBreadcrumbs([ + { label: "Dashboard", href: "/dashboard" }, + { label: "Live runs" }, + ]); + }, [setBreadcrumbs]); + + if (!selectedCompanyId) { + return ( + + ); + } + + return ( +
+
+
+ + + Dashboard + +

Live agent runs

+

+ Active runs first, followed by the most recent completed runs. +

+
+
Showing up to {DASHBOARD_LIVE_RUN_LIMIT}
+
+ + +
+ ); +}