mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## 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 <noreply@paperclip.ing>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
import { AgentIdentity } from "@/components/AgentIdentity";
|
|
import { memo, useMemo } from "react";
|
|
import { Link } from "@/lib/router";
|
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
|
import type { TranscriptEntry } from "../adapters";
|
|
import { issuesApi } from "../api/issues";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { cn, relativeTime } from "../lib/utils";
|
|
import { Clock3 } from "lucide-react";
|
|
import { StatusGlyph } from "./StatusGlyph";
|
|
import { RunChatSurface } from "./RunChatSurface";
|
|
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
|
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
|
|
|
const MIN_DASHBOARD_RUNS = 4;
|
|
const DASHBOARD_RUN_CARD_LIMIT = 4;
|
|
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
|
|
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
|
|
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
|
|
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
|
|
const EMPTY_RUNS: LiveRunForIssue[] = [];
|
|
|
|
const runStatusLabels: Record<string, string> = {
|
|
running: "Running",
|
|
queued: "Queued",
|
|
succeeded: "Succeeded",
|
|
failed: "Failed",
|
|
timed_out: "Timed out",
|
|
cancelled: "Cancelled",
|
|
interrupted: "Interrupted",
|
|
};
|
|
|
|
interface ActiveAgentsPanelProps {
|
|
companyId: string;
|
|
title?: string;
|
|
minRunCount?: number;
|
|
fetchLimit?: number;
|
|
cardLimit?: number;
|
|
gridClassName?: string;
|
|
cardClassName?: string;
|
|
emptyMessage?: string;
|
|
queryScope?: string;
|
|
showMoreLink?: boolean;
|
|
showTranscripts?: boolean;
|
|
dedupeLinkedTasks?: boolean;
|
|
}
|
|
|
|
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,
|
|
showTranscripts = false,
|
|
dedupeLinkedTasks = false,
|
|
}: ActiveAgentsPanelProps) {
|
|
const effectiveFetchLimit = fetchLimit;
|
|
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const;
|
|
const sharedLiveRuns = useSharedPollingQuery({
|
|
companyId,
|
|
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: effectiveFetchLimit,
|
|
distinctTasks: dedupeLinkedTasks,
|
|
}),
|
|
enabled: sharedLiveRuns.enabled,
|
|
});
|
|
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
|
|
|
const runs = liveRuns ?? [];
|
|
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<string>();
|
|
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],
|
|
);
|
|
|
|
const issueQueries = useQueries({
|
|
queries: visibleIssueIds.map((issueId) => ({
|
|
queryKey: queryKeys.issues.detail(issueId),
|
|
queryFn: () => issuesApi.get(issueId),
|
|
staleTime: 30_000,
|
|
retry: false,
|
|
})),
|
|
});
|
|
|
|
const issueById = useMemo(() => {
|
|
const map = new Map<string, Issue>();
|
|
for (const query of issueQueries) {
|
|
const issue = query.data;
|
|
if (issue) map.set(issue.id, issue);
|
|
}
|
|
return map;
|
|
}, [issueQueries]);
|
|
|
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
|
runs: showTranscripts ? visibleRuns : EMPTY_RUNS,
|
|
companyId,
|
|
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
|
|
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
|
|
logReadLimitBytes: DASHBOARD_LOG_READ_LIMIT_BYTES,
|
|
enableRealtimeUpdates: false,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{title}
|
|
</h3>
|
|
{runs.length === 0 ? (
|
|
<div className="rounded-xl border border-border p-4">
|
|
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
</div>
|
|
) : (
|
|
<div className={cn("grid grid-cols-1 items-start gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
|
|
{visibleRuns.map((run) => (
|
|
<AgentRunCard
|
|
key={run.id}
|
|
companyId={companyId}
|
|
run={run}
|
|
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
|
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
|
|
hasOutput={hasOutputForRun(run.id)}
|
|
showTranscript={showTranscripts}
|
|
issueLoadFailed={issueQueries.some((query, index) => visibleIssueIds[index] === run.issueId && query.isError)}
|
|
className={cardClassName}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{showMoreLink && runs.length > 0 && (
|
|
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
|
|
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
|
|
{hiddenRunCount > 0
|
|
? `${hiddenRunCount} more active/recent run${hiddenRunCount === 1 ? "" : "s"}`
|
|
: "View all runs"}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const AgentRunCard = memo(function AgentRunCard({
|
|
companyId,
|
|
run,
|
|
issue,
|
|
transcript = EMPTY_TRANSCRIPT,
|
|
hasOutput = false,
|
|
showTranscript = false,
|
|
issueLoadFailed = false,
|
|
className,
|
|
}: {
|
|
companyId: string;
|
|
run: LiveRunForIssue;
|
|
issue?: Pick<Issue, "identifier" | "title" | "status" | "externalConversationState">;
|
|
transcript?: TranscriptEntry[];
|
|
hasOutput?: boolean;
|
|
showTranscript?: boolean;
|
|
issueLoadFailed?: boolean;
|
|
className?: string;
|
|
}) {
|
|
const statusLabel = runStatusLabels[run.status] ?? run.status.replace(/[_-]/g, " ");
|
|
const runUrl = `/agents/${run.agentId}/runs/${run.id}`;
|
|
const timestamp = run.finishedAt
|
|
? `Finished ${relativeTime(run.finishedAt)}`
|
|
: run.startedAt ? `Started ${relativeTime(run.startedAt)}` : `Queued ${relativeTime(run.createdAt)}`;
|
|
const taskStatus = issue?.status === "in_review" && issue.externalConversationState === "waiting" ? "idle" : issue?.status ?? "backlog";
|
|
const taskTitle = issue?.title ?? (issueLoadFailed ? "Task unavailable" : "Loading task…");
|
|
|
|
return (
|
|
<div className={cn(
|
|
"dashboard-agent-card flex min-w-0 flex-col overflow-hidden rounded-xl border",
|
|
showTranscript && "h-(--sz-320px)",
|
|
run.status === "running"
|
|
? "border-(--dashboard-run-border) bg-(--dashboard-run-background) shadow-(--shadow-extract-1)"
|
|
: "border-border bg-background/70",
|
|
className,
|
|
)} data-run-status={run.status}>
|
|
<div className={cn("flex shrink-0 flex-col gap-3 p-3", showTranscript && "border-b border-border/60")}>
|
|
<Link
|
|
to={runUrl}
|
|
title={`${run.agentName} — ${statusLabel} · ${timestamp}`}
|
|
aria-label={`${run.agentName} — ${statusLabel}. View run`}
|
|
className="flex min-w-0 items-center gap-2 rounded-md text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<AgentIdentity agent={{ id: run.agentId, name: run.agentName, appearance: run.agentAppearance }} size="sm" className="gap-2 font-medium" />
|
|
</Link>
|
|
|
|
{run.issueId ? (
|
|
<Link
|
|
to={`/issues/${issue?.identifier ?? run.issueId}`}
|
|
className="min-w-0 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
title={issue ? `${issue.title} · ${issue.identifier}` : taskTitle}
|
|
>
|
|
<span className="flex min-w-0 items-baseline gap-2">
|
|
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
|
<StatusGlyph
|
|
status={taskStatus}
|
|
size="md"
|
|
className="self-center"
|
|
title={issue ? `Task ${taskStatus.replace(/_/g, " ")}` : undefined}
|
|
/>
|
|
<span className="truncate">{taskTitle}</span>
|
|
</span>
|
|
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">{issue?.identifier ?? run.issueId.slice(0, 8)}</span>
|
|
</span>
|
|
</Link>
|
|
) : (
|
|
<Link to={runUrl} className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<Clock3 className="size-4 shrink-0" aria-hidden />
|
|
<span className="truncate">{run.invocationSource === "timer" ? "Scheduled heartbeat" : "No linked task"}</span>
|
|
</Link>
|
|
)}
|
|
<time
|
|
dateTime={run.finishedAt ?? run.startedAt ?? run.createdAt}
|
|
className="text-right font-sans text-xs text-muted-foreground/70"
|
|
>
|
|
{timestamp}
|
|
</time>
|
|
</div>
|
|
|
|
{showTranscript && (
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
<RunChatSurface
|
|
run={run}
|
|
transcript={transcript}
|
|
hasOutput={hasOutput}
|
|
companyId={companyId}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|