From efc2e6810e9bc0dc8cb412b0e7647c0db9821caa Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:18:35 -0500 Subject: [PATCH] fix: show each task once in dashboard agent cards (#14847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their tasks. > - The dashboard shows recent agent activity in compact cards. > - Those cards use run records, so two runs for one task can create duplicate task cards. > - An operator needs to see each task once when scanning the dashboard. > - This pull request selects one run per linked task before it applies the card limit. > - The live runs page still shows each run for run inspection. ## Linked Issues or Issue Description **What happened?** The dashboard showed the same task in two agent cards when that task had both an active run and a completed run. **Expected behavior** The dashboard should show a linked task at most once. It should keep the active run card when one is present. **Steps to reproduce** 1. Start an agent run for a task that already has a completed run. 2. Open the company dashboard. 3. Observe two cards linked to the same task. **Paperclip version or commit** Reproduced on the pre-change master at `8b4aa0692`. **Deployment mode** Local dev, built from source. The bug is in the core dashboard UI and does not depend on an agent adapter or database mode. ## What Changed - Select distinct linked tasks from capped active and recent run samples before applying the dashboard card limit. - Keep separate cards for runs without a linked task. - Preserve the dashboard's count of additional distinct cards behind the live-runs link. - Add UI and embedded Postgres regression tests for duplicate runs and document the dashboard rule. - Give the existing multi-request cross-tenant authorization test enough time on loaded CI runners. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/ActiveAgentsPanel.test.tsx` - `pnpm --filter @paperclipai/ui exec vitest run src/api/heartbeats.test.ts` - `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts server/src/__tests__/agent-live-run-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/agent-cross-tenant-authz-routes.test.ts` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui build` - `pnpm -r typecheck` - `pnpm build` - `pnpm check:token-gates` - Review the dashboard with an active and a completed run on the same task. Confirm that it shows one card. Open Live agent runs to inspect both run records. ## Risks - A very high volume of recent runs for one task can fill the capped sample and leave older tasks off the dashboard. The Live runs page remains available for full run inspection. - The dashboard may fetch up to 50 distinct run representatives to preserve its overflow count. The default run API response and persisted data are unchanged. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-6. The runtime does not expose the exact model ID or context window size to this task. The model used reasoning, tool calls, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/SPEC-implementation.md | 8 +++ .../agent-cross-tenant-authz-routes.test.ts | 2 +- .../src/__tests__/dashboard-service.test.ts | 37 ++++++++++++++ server/src/routes/agents.ts | 44 +++++++++------- .../src/services/dashboard-run-selection.ts | 49 ++++++++++++++++++ ui/src/api/heartbeats.test.ts | 6 +++ ui/src/api/heartbeats.ts | 3 +- ui/src/components/ActiveAgentsPanel.test.tsx | 50 +++++++++++++++++++ ui/src/components/ActiveAgentsPanel.tsx | 30 +++++++++-- ui/src/pages/Dashboard.tsx | 2 +- 10 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 server/src/services/dashboard-run-selection.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 41b5de6d298..20f2d91f4c2 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1197,6 +1197,14 @@ Dashboard payload must include: - month-to-date spend and budget utilization - pending approvals count +The dashboard agent cards show each linked task at most once. The server selects +distinct task cards from bounded active and recent run samples before it applies +the dashboard card limit. When multiple runs belong to one task, an active run +takes precedence over completed runs. +Runs without a linked task remain separate cards. The dashboard keeps the +count of additional distinct cards in its link to the live runs page, which +can show every run. + ## 10.10 Error Semantics - `400` validation error diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index ddb3f40da06..d5fabca417e 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -420,7 +420,7 @@ describe.sequential("agent cross-tenant route authorization", () => { expect(res.body.error).toContain("Key not found"); expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); + }, 30_000); it("requires board access before clearing an agent error", async () => { const app = await createApp({ diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts index 05a1a738c9e..8642844ce5d 100644 --- a/server/src/__tests__/dashboard-service.test.ts +++ b/server/src/__tests__/dashboard-service.test.ts @@ -6,6 +6,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,6 +57,42 @@ describeEmbeddedPostgres("dashboard service", () => { await tempDb?.cleanup(); }); + it("selects distinct task cards before limiting and keeps taskless runs separate", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const repeatedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + const activeRunId = randomUUID(); + const otherTaskRunId = randomUUID(); + const tasklessRunId = randomUUID(); + + await db.insert(companies).values([ + { id: companyId, name: "Dashboard cards", issuePrefix: "CARDS" }, + { id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" }, + ]); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Builder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + ]); + await db.insert(heartbeatRuns).values([ + ...Array.from({ length: 6 }, (_, index) => ({ + id: randomUUID(), companyId, agentId, status: "succeeded", + contextSnapshot: { issueId: repeatedIssueId }, + createdAt: new Date(`2026-04-10T10:0${index}:00.000Z`), + })), + { id: activeRunId, companyId, agentId, status: "running", contextSnapshot: { issueId: repeatedIssueId }, createdAt: new Date("2026-04-10T08:00:00.000Z") }, + { id: otherTaskRunId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T09:04:00.000Z") }, + { id: tasklessRunId, companyId, agentId, status: "succeeded", createdAt: new Date("2026-04-10T09:03:00.000Z") }, + { id: randomUUID(), companyId: otherCompanyId, agentId: otherAgentId, status: "running", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T11:00:00.000Z") }, + ]); + + expect(await selectDashboardRunIds(db, companyId, 3)).toEqual([ + activeRunId, otherTaskRunId, tasklessRunId, + ]); + }); + it("aggregates the full 14-day run activity window without recent-run truncation", async () => { const companyId = randomUUID(); const otherCompanyId = randomUUID(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3f15799110a..494fdd5ccee 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -15,6 +15,7 @@ import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSki import { getExecutionBlocker } from "../services/execution-blocker.js"; import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils"; import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.js"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import { readFile, rm } from "node:fs/promises"; @@ -6873,6 +6874,7 @@ export function agentRoutes( // padded in and renders bogus "live" counts. const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 0); const limit = readLiveRunsQueryInt(req.query.limit, 50, 50); + const distinctTasks = req.query.distinctTasks === "true"; const columns = { id: heartbeatRuns.id, @@ -6888,7 +6890,7 @@ export function agentRoutes( createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, - agentAppearance: agentsTable.appearance, + agentAppearance: agentsTable.appearance, adapterType: agentsTable.adapterType, logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, @@ -6916,10 +6918,25 @@ export function agentRoutes( ) .orderBy(desc(heartbeatRuns.createdAt)); - const liveRuns = await liveRunsQuery.limit(limit); + const liveRuns = distinctTasks ? [] : await liveRunsQuery.limit(limit); + let rows = liveRuns; const targetRunCount = Math.min(minCount, limit); - if (targetRunCount > 0 && liveRuns.length < targetRunCount) { + if (distinctTasks) { + // Return enough representatives for the dashboard to count cards beyond + // its visible four, rather than stopping at the minimum display count. + const selectedIds = await selectDashboardRunIds(db, companyId, limit); + const selectedRows = selectedIds.length === 0 ? [] : await db + .select(columns) + .from(heartbeatRuns) + .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, selectedIds))); + const byId = new Map(selectedRows.map((run) => [run.id, run])); + rows = selectedIds.flatMap((id) => { + const run = byId.get(id); + return run ? [run] : []; + }); + } else if (targetRunCount > 0 && liveRuns.length < targetRunCount) { const activeIds = liveRuns.map((r) => r.id); const recentRuns = await db .select(columns) @@ -6935,24 +6952,15 @@ export function agentRoutes( .orderBy(desc(heartbeatRuns.createdAt)) .limit(targetRunCount - liveRuns.length); - const rows = [...liveRuns, ...recentRuns]; - const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ - ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, - outputSilence: await heartbeat.buildRunOutputSilence(run), - }))))); - return; + rows = [...liveRuns, ...recentRuns]; } - const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({ + const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, + agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), + avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), + execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence(run), }))))); }); diff --git a/server/src/services/dashboard-run-selection.ts b/server/src/services/dashboard-run-selection.ts new file mode 100644 index 00000000000..1efe82f9dae --- /dev/null +++ b/server/src/services/dashboard-run-selection.ts @@ -0,0 +1,49 @@ +import type { Db } from "@paperclipai/db"; +import { heartbeatRuns } from "@paperclipai/db"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; + +const MAX_CANDIDATES_PER_POOL = 1_000; +const ACTIVE_STATUSES = ["queued", "running"]; + +/** Select recent dashboard cards without ranking the company's full run history. */ +export async function selectDashboardRunIds(db: Db, companyId: string, limit: number): Promise { + if (limit <= 0) return []; + + const columns = { + id: heartbeatRuns.id, + status: heartbeatRuns.status, + createdAt: heartbeatRuns.createdAt, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + }; + const [activeRuns, recentRuns] = await Promise.all([ + db.select(columns) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ACTIVE_STATUSES))) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + db.select(columns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + ]); + + const candidates = [...new Map([...activeRuns, ...recentRuns].map((run) => [run.id, run])).values()]; + candidates.sort((a, b) => { + const activeOrder = Number(ACTIVE_STATUSES.includes(b.status)) - Number(ACTIVE_STATUSES.includes(a.status)); + if (activeOrder !== 0) return activeOrder; + const createdOrder = b.createdAt.getTime() - a.createdAt.getTime(); + return createdOrder || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); + }); + + const selectedIds: string[] = []; + const seenCards = new Set(); + for (const run of candidates) { + const cardKey = run.issueId ? `issue:${run.issueId}` : `run:${run.id}`; + if (seenCards.has(cardKey)) continue; + seenCards.add(cardKey); + selectedIds.push(run.id); + if (selectedIds.length === limit) break; + } + return selectedIds; +} diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts index 8d65eeb9ab3..4de7d5a69f7 100644 --- a/ui/src/api/heartbeats.test.ts +++ b/ui/src/api/heartbeats.test.ts @@ -55,6 +55,12 @@ describe("heartbeatsApi.liveRunsForCompany", () => { expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50"); }); + + it("requests distinct task cards before the server applies the dashboard limit", async () => { + await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 4, limit: 4, distinctTasks: true }); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4&limit=4&distinctTasks=true"); + }); }); describe("heartbeatsApi.downloadProviderTrace", () => { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index cf913ef0054..84c7990f664 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -232,7 +232,7 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/active-run`), liveRunsForCompany: ( companyId: string, - options?: number | { minCount?: number; limit?: number }, + options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean }, ) => { const searchParams = new URLSearchParams(); if (typeof options === "number") { @@ -241,6 +241,7 @@ export const heartbeatsApi = { if (options.minCount) searchParams.set("minCount", String(options.minCount)); if (options.limit) searchParams.set("limit", String(options.limit)); + if (options.distinctTasks) searchParams.set("distinctTasks", "true"); } const qs = searchParams.toString(); return api.get( diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx index 80a7b2813ff..532785a228c 100644 --- a/ui/src/components/ActiveAgentsPanel.test.tsx +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -146,6 +146,7 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 4, limit: undefined, + distinctTasks: false, }); const moreLink = [...container.querySelectorAll("a")].find((anchor) => @@ -159,7 +160,54 @@ describe("ActiveAgentsPanel", () => { }); }); + it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => { + const repeatedIssueId = "issue-repeated"; + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, repeatedIssueId), + { ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" }, + createIssueRun(3, "issue-other"), + createRun(4), + createRun(5), + createRun(6), + ]); + + 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, + distinctTasks: true, + }); + expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) => + card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"), + )).toEqual([ + "/agents/agent-1/runs/run-1", + "/agents/agent-3/runs/run-3", + "/agents/agent-4/runs/run-4", + "/agents/agent-5/runs/run-5", + ]); + expect(container.textContent).toContain("1 more active/recent run"); + + await act(async () => root.unmount()); + }); + it("can request the full live dashboard page limit without a hidden-runs link", async () => { + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, "issue-repeated"), + { ...createIssueRun(2, "issue-repeated"), status: "succeeded" }, + ]); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -184,9 +232,11 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 50, limit: 50, + distinctTasks: false, }); expect(container.textContent).not.toContain("more active/recent"); expect(container.textContent).not.toContain("Run output"); + expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2); await act(async () => { root.unmount(); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 34e12a65ce6..b3aaf50a2d7 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -44,6 +44,7 @@ interface ActiveAgentsPanelProps { queryScope?: string; showMoreLink?: boolean; showTranscripts?: boolean; + dedupeLinkedTasks?: boolean; } export function ActiveAgentsPanel({ @@ -58,25 +59,44 @@ export function ActiveAgentsPanel({ queryScope = "dashboard", showMoreLink = true, showTranscripts = false, + dedupeLinkedTasks = false, }: ActiveAgentsPanelProps) { - const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const; + const effectiveFetchLimit = fetchLimit; + const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const; const sharedLiveRuns = useSharedPollingQuery({ companyId, - resourceKey: `live-runs:${queryScope}:${minRunCount}:${fetchLimit ?? "default"}`, + resourceKey: `live-runs:${queryScope}:${minRunCount}:${effectiveFetchLimit ?? "default"}:${dedupeLinkedTasks}`, queryKey: liveRunsQueryKey, enabled: !!companyId, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ queryKey: liveRunsQueryKey, - queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }), + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { + minCount: minRunCount, + limit: effectiveFetchLimit, + distinctTasks: dedupeLinkedTasks, + }), enabled: sharedLiveRuns.enabled, }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const runs = liveRuns ?? []; - const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]); - const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length); + const cardRuns = useMemo(() => { + if (!dedupeLinkedTasks) return runs; + + // The endpoint orders active runs first, then recent completed runs. Keep + // the first run for each task so an active attempt wins over its history. + const seenIssueIds = new Set(); + return runs.filter((run) => { + if (!run.issueId) return true; + if (seenIssueIds.has(run.issueId)) return false; + seenIssueIds.add(run.issueId); + return true; + }); + }, [dedupeLinkedTasks, runs]); + const visibleRuns = useMemo(() => cardRuns.slice(0, cardLimit), [cardLimit, cardRuns]); + const hiddenRunCount = Math.max(0, cardRuns.length - visibleRuns.length); const visibleIssueIds = useMemo( () => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))], [visibleRuns], diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 756727fbaf1..38bf9e51efb 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -374,7 +374,7 @@ export function Dashboard() { )} - + {data && ( <>