feat(ui): live run label → run detail, running row → task detail (#11034)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The agent detail page has a Dashboard tab. The Dashboard tab shows a "Live Run" section for the agent's current heartbeat. > - The "Live Run" section has two clickable pieces: the section heading and the running row. Both pieces linked to the same run detail page. > - Two controls that go to the same place waste a navigation affordance and hide the task the agent runs. > - This pull request splits the two destinations. The heading goes to the run. The running row goes to the task. > - The benefit is that a user reaches the run internals from the label and the work item from the row, in one click each. ## Linked Issues or Issue Description No public GitHub issue exists for this change. The description follows the enhancement issue template. **What existing behavior does this improve?** The "Live Run" section on the agent detail page, Dashboard tab (the `LatestRunCard` component in `ui/src/pages/AgentDetail.tsx`). **Current behavior** The "Live Run" heading and the running row both link to the run detail page (`/agents/:agentId/runs/:runId`). The row shows the run code and an invocation-source chip. There is a separate "View details →" link that also goes to the run detail page. A user cannot reach the task the run works on from this section. **Proposed behavior** The heading becomes a link to the run detail page and appends the short run code, shown as `Live Run · <run code>`. The redundant "View details →" link is removed. The running row links to the task detail page when the run's context snapshot resolves to a known issue, and the row then shows the task status glyph, the task slug, and the task title. A pure timer heartbeat with no resolvable task keeps the previous behavior: run code plus source chip, linking to the run detail page. **Reason and benefit** The heading and the row now go to distinct, intuitive destinations. A user reaches the run internals from the label and the work item from the row, each in a single click. The fallback keeps heartbeats with no task readable and avoids a blank or broken row. ## What Changed - Made the "Live Run" / "Latest Run" heading a `Link` to the run detail page and appended the short run code (`run.id.slice(0, 8)`) in a mono span, formatted `Live Run · <run code>`. Kept the pulsing live dot. - Removed the redundant "View details →" link. - Changed the running row `Link` target to the task detail page (`/issues/:identifier`) when a task resolves, falling back to the run detail page otherwise. - Resolved the task from the run context snapshot (`contextSnapshot.issueId`, falling back to `contextSnapshot.taskId`) against a `Map` of the agent's assigned issues threaded in from `AgentOverview`. - When a task resolves, replaced the run code and source chip in the row with the task status glyph (`StatusGlyph`), the task slug, and the task title. Kept the running spinner, the run status badge, and the timestamp. ## Verification - `pnpm check:token-gates` → 3/3 gates clean. - `pnpm --filter ui typecheck` → passes. - `pnpm --filter ui exec vitest run src/pages/AgentDetail.progress.test.ts src/pages/AgentDetail.instructions.test.tsx` → 10/10 pass. - Manual (needs a reviewer with a browser): open an agent detail page → Dashboard tab. - For a live issue-execution run: the heading reads `Live Run · <run code>` and opens the run detail page; the row shows the task status icon, slug, and title and opens the task detail page. - For a pure timer heartbeat with no task: the row falls back to run code + source chip and opens the run detail page. No blank row. - Confirm both states in light and dark mode. ## Risks Low risk. The change is presentational and scoped to one component. The task lookup is defensive: it reads the context snapshot with a fallback key and only renders the task row when the issue is present in the already-loaded assigned-issue set, so an unknown or missing issue degrades to the previous run-detail behavior rather than breaking. ## Model Used - Provider: Anthropic (Claude). - Model: claude-opus-4-8 (Opus 4.8). - Context window: 200K. - Reasoning mode: extended thinking, tool use. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4e76227f12
commit
b67c512f82
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HeartbeatRun } from "@paperclipai/shared";
|
||||
import { getRunSnapshotIssueId, resolveLatestRunNavigation, type LatestRunIssue } from "./AgentDetail";
|
||||
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
function makeRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
||||
return {
|
||||
id: "d3bd37f9-1111-2222-3333-444455556666",
|
||||
contextSnapshot: null,
|
||||
...overrides,
|
||||
} as HeartbeatRun;
|
||||
}
|
||||
|
||||
function issuesMap(...issues: LatestRunIssue[]): Map<string, LatestRunIssue> {
|
||||
return new Map(issues.map((i) => [i.id, i]));
|
||||
}
|
||||
|
||||
describe("resolveLatestRunNavigation", () => {
|
||||
const runHref = `/agents/${AGENT_ID}/runs/d3bd37f9-1111-2222-3333-444455556666`;
|
||||
|
||||
it("resolves the task from contextSnapshot.issueId and points the row at the task detail page", () => {
|
||||
const issue: LatestRunIssue = { id: "issue-7", title: "Fix the thing", status: "in_progress", identifier: "PAP-7" };
|
||||
const run = makeRun({ contextSnapshot: { issueId: "issue-7" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue));
|
||||
|
||||
expect(nav.task).toBe(issue);
|
||||
expect(nav.runHref).toBe(runHref);
|
||||
expect(nav.rowHref).toBe("/issues/PAP-7");
|
||||
});
|
||||
|
||||
it("falls back to contextSnapshot.taskId when issueId is absent (older snapshots)", () => {
|
||||
const issue: LatestRunIssue = { id: "issue-9", title: "Legacy", status: "todo", identifier: "PAP-9" };
|
||||
const run = makeRun({ contextSnapshot: { taskId: "issue-9" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue));
|
||||
|
||||
expect(nav.task).toBe(issue);
|
||||
expect(nav.rowHref).toBe("/issues/PAP-9");
|
||||
});
|
||||
|
||||
it("prefers issueId over taskId when both are present", () => {
|
||||
const wanted: LatestRunIssue = { id: "issue-a", title: "A", status: "in_progress", identifier: "PAP-A" };
|
||||
const other: LatestRunIssue = { id: "issue-b", title: "B", status: "in_progress", identifier: "PAP-B" };
|
||||
const run = makeRun({ contextSnapshot: { issueId: "issue-a", taskId: "issue-b" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(wanted, other));
|
||||
|
||||
expect(nav.task).toBe(wanted);
|
||||
expect(nav.rowHref).toBe("/issues/PAP-A");
|
||||
});
|
||||
|
||||
it("uses the issue id when the resolved task has no identifier slug", () => {
|
||||
const issue: LatestRunIssue = { id: "issue-noident", title: "No slug", status: "in_progress", identifier: null };
|
||||
const run = makeRun({ contextSnapshot: { issueId: "issue-noident" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue));
|
||||
|
||||
expect(nav.rowHref).toBe("/issues/issue-noident");
|
||||
});
|
||||
|
||||
it("falls back to the run detail page for a pure timer heartbeat with no snapshot", () => {
|
||||
const run = makeRun({ contextSnapshot: null });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap());
|
||||
|
||||
expect(nav.task).toBeUndefined();
|
||||
expect(nav.runHref).toBe(runHref);
|
||||
expect(nav.rowHref).toBe(runHref);
|
||||
});
|
||||
|
||||
it("falls back to the run detail page when the snapshot issue is absent from the lookup (LatestRunCard fills this gap via a targeted fetch)", () => {
|
||||
const run = makeRun({ contextSnapshot: { issueId: "issue-missing" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap());
|
||||
|
||||
expect(nav.task).toBeUndefined();
|
||||
expect(nav.rowHref).toBe(runHref);
|
||||
});
|
||||
|
||||
it("resolves an issue fetched directly (outside the bounded assigned-issues page) once folded into the lookup", () => {
|
||||
// Mirrors LatestRunCard folding a directly-fetched fallback issue into the
|
||||
// map keyed by the snapshot id, so a live run whose task sits beyond the
|
||||
// server page limit still links to the task detail page.
|
||||
const fetched: LatestRunIssue = { id: "issue-far", title: "Far task", status: "in_progress", identifier: "PAP-999" };
|
||||
const run = makeRun({ contextSnapshot: { issueId: "issue-far" } as HeartbeatRun["contextSnapshot"] });
|
||||
|
||||
const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(fetched));
|
||||
|
||||
expect(nav.task).toBe(fetched);
|
||||
expect(nav.rowHref).toBe("/issues/PAP-999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRunSnapshotIssueId", () => {
|
||||
it("reads issueId from the snapshot", () => {
|
||||
expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: "issue-7" } as HeartbeatRun["contextSnapshot"] }))).toBe("issue-7");
|
||||
});
|
||||
|
||||
it("falls back to taskId for older snapshots", () => {
|
||||
expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { taskId: "issue-9" } as HeartbeatRun["contextSnapshot"] }))).toBe("issue-9");
|
||||
});
|
||||
|
||||
it("prefers issueId over taskId", () => {
|
||||
expect(
|
||||
getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: "issue-a", taskId: "issue-b" } as HeartbeatRun["contextSnapshot"] })),
|
||||
).toBe("issue-a");
|
||||
});
|
||||
|
||||
it("returns undefined for a pure timer heartbeat with no snapshot", () => {
|
||||
expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: null }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("coerces non-string ids to strings", () => {
|
||||
expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: 42 } as unknown as HeartbeatRun["contextSnapshot"] }))).toBe("42");
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,7 @@ import { StatusBadge } from "../components/StatusBadge";
|
|||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { StatusGlyph } from "../components/StatusGlyph";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { StarToggle } from "../components/StarToggle";
|
||||
import { Identity } from "../components/Identity";
|
||||
|
|
@ -1429,21 +1430,81 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN
|
|||
);
|
||||
}
|
||||
|
||||
function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) {
|
||||
if (runs.length === 0) return null;
|
||||
export type LatestRunIssue = { id: string; title: string; status: string; identifier?: string | null };
|
||||
|
||||
const sorted = [...runs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
/**
|
||||
* The id of the issue a run works on, read from its context snapshot. Newer
|
||||
* snapshots use `issueId`; older ones use `taskId`. Returns undefined for pure
|
||||
* timer heartbeats that carry no task reference.
|
||||
*/
|
||||
export function getRunSnapshotIssueId(
|
||||
run: Pick<HeartbeatRun, "contextSnapshot">,
|
||||
): string | undefined {
|
||||
const ctx = run.contextSnapshot as Record<string, unknown> | null;
|
||||
const issueId = ctx?.issueId ?? ctx?.taskId;
|
||||
return issueId ? String(issueId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Live Run section's two navigation destinations and the task (if
|
||||
* any) the run works on. The run→task link lives in the run's context snapshot
|
||||
* (`issueId`, falling back to `taskId` for older snapshots); the `HeartbeatRun`
|
||||
* itself doesn't carry the issue id. The heading always links to the run detail
|
||||
* page; the running row links to the task detail page when the snapshot resolves
|
||||
* to a known issue, otherwise falls back to the run detail page (pure timer
|
||||
* heartbeats or an issue that can't be resolved).
|
||||
*/
|
||||
export function resolveLatestRunNavigation(
|
||||
run: Pick<HeartbeatRun, "id" | "contextSnapshot">,
|
||||
agentId: string,
|
||||
issuesById: Map<string, LatestRunIssue>,
|
||||
): { task: LatestRunIssue | undefined; runHref: string; rowHref: string } {
|
||||
const issueId = getRunSnapshotIssueId(run);
|
||||
const task = issueId ? issuesById.get(issueId) : undefined;
|
||||
const runHref = `/agents/${agentId}/runs/${run.id}`;
|
||||
const rowHref = task ? `/issues/${task.identifier ?? task.id}` : runHref;
|
||||
return { task, runHref, rowHref };
|
||||
}
|
||||
|
||||
function LatestRunCard({
|
||||
runs,
|
||||
agentId,
|
||||
issuesById,
|
||||
}: {
|
||||
runs: HeartbeatRun[];
|
||||
agentId: string;
|
||||
issuesById: Map<string, LatestRunIssue>;
|
||||
}) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...runs].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
),
|
||||
[runs]
|
||||
);
|
||||
|
||||
const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued");
|
||||
const run = liveRun ?? sorted[0];
|
||||
const isLive = run.status === "running" || run.status === "queued";
|
||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const summaryRaw = run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
|
||||
// The assigned-issues list this card resolves against is bounded (server page
|
||||
// limit), so a live run can reference a valid issue that isn't on the loaded
|
||||
// page. When the snapshot points at an issue we don't already have, fetch it
|
||||
// directly so the running row always links to the task rather than falling
|
||||
// back to run metadata. `enabled` keeps this a no-op for the common case.
|
||||
const snapshotIssueId = run ? getRunSnapshotIssueId(run) : undefined;
|
||||
const needsFallbackFetch = !!snapshotIssueId && !issuesById.has(snapshotIssueId);
|
||||
const { data: fallbackIssue } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(snapshotIssueId ?? "__none__"),
|
||||
queryFn: () => issuesApi.get(snapshotIssueId as string),
|
||||
enabled: needsFallbackFetch,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const summaryRaw = run
|
||||
? run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? ""
|
||||
: "";
|
||||
|
||||
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
|
||||
const summary = useMemo(() => {
|
||||
|
|
@ -1463,28 +1524,48 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||
return excerpt.join(" ");
|
||||
}, [summaryRaw]);
|
||||
|
||||
if (!run) return null;
|
||||
|
||||
const isLive = run.status === "running" || run.status === "queued";
|
||||
// Fold any directly-fetched fallback issue into the lookup, keyed by the same
|
||||
// snapshot id used to resolve the row so it hits regardless of id-vs-slug.
|
||||
const effectiveIssuesById =
|
||||
fallbackIssue && snapshotIssueId
|
||||
? new Map(issuesById).set(snapshotIssueId, {
|
||||
id: fallbackIssue.id,
|
||||
title: fallbackIssue.title,
|
||||
status: fallbackIssue.status,
|
||||
identifier: fallbackIssue.identifier,
|
||||
})
|
||||
: issuesById;
|
||||
const { task, runHref, rowHref } = resolveLatestRunNavigation(run, agentId, effectiveIssuesById);
|
||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
{isLive ? "Live Run" : "Latest Run"}
|
||||
</h3>
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${run.id}`}
|
||||
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
|
||||
to={runHref}
|
||||
className="no-underline"
|
||||
>
|
||||
View details →
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium transition-colors hover:text-foreground">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
<span>{isLive ? "Live Run" : "Latest Run"}</span>
|
||||
<span className="font-mono text-xs font-normal text-muted-foreground">
|
||||
· {run.id.slice(0, 8)}
|
||||
</span>
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/agents/${agentId}/runs/${run.id}`}
|
||||
to={rowHref}
|
||||
className={cn(
|
||||
"block border rounded-lg p-4 space-y-2 w-full no-underline transition-colors hover:bg-muted/50 cursor-pointer",
|
||||
isLive ? "border-blue-500/30 shadow-(--shadow-extract-14)" : "border-border"
|
||||
|
|
@ -1493,16 +1574,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||||
<Badge variant="ghost" className={cn(
|
||||
"px-1.5 text-(length:--text-nano)",
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</Badge>
|
||||
{task ? (
|
||||
<>
|
||||
<StatusGlyph status={task.status} size="sm" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{task.identifier ?? task.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate text-xs">{task.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||||
<Badge variant="ghost" className={cn(
|
||||
"px-1.5 text-(length:--text-nano)",
|
||||
run.invocationSource === "timer" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{relativeTime(run.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -1533,10 +1626,16 @@ function AgentOverview({
|
|||
agentId: string;
|
||||
agentRouteId: string;
|
||||
}) {
|
||||
const issuesById = useMemo(() => {
|
||||
const map = new Map<string, (typeof assignedIssues)[number]>();
|
||||
for (const issue of assignedIssues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [assignedIssues]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Latest Run */}
|
||||
<LatestRunCard runs={runs} agentId={agentRouteId} />
|
||||
<LatestRunCard runs={runs} agentId={agentRouteId} issuesById={issuesById} />
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
|
|
|||
Loading…
Reference in New Issue