diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx index 8918ae1579..8871a05e24 100644 --- a/ui/src/components/ActiveAgentsPanel.test.tsx +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -4,7 +4,7 @@ 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"; +import { ActiveAgentsPanel, AgentRunCard } from "./ActiveAgentsPanel"; const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn(), @@ -30,10 +30,6 @@ vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, })); -vi.mock("./Identity", () => ({ - Identity: ({ name }: { name: string }) => {name}, -})); - vi.mock("./RunChatSurface", () => ({ RunChatSurface: () =>
Run output
, })); @@ -156,6 +152,7 @@ describe("ActiveAgentsPanel", () => { anchor.textContent?.includes("more active/recent"), ); expect(moreLink?.getAttribute("href")).toBe("/dashboard/live"); + expect(container.textContent).not.toContain("Run output"); await act(async () => { root.unmount(); @@ -189,6 +186,7 @@ describe("ActiveAgentsPanel", () => { limit: 50, }); expect(container.textContent).not.toContain("more active/recent"); + expect(container.textContent).not.toContain("Run output"); await act(async () => { root.unmount(); @@ -224,7 +222,8 @@ describe("ActiveAgentsPanel", () => { const issueLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent?.includes("Phase 4B"), ); - expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI"); + expect(issueLink?.textContent).toContain("Phase 4B: Implement LLM Wiki distillation UI"); + expect(issueLink?.textContent).toContain("PAP-3562"); expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562"); }); @@ -232,4 +231,65 @@ describe("ActiveAgentsPanel", () => { root.unmount(); }); }); + + it("keeps run outcomes distinct from the linked task status", async () => { + const root = createRoot(container); + const statuses = ["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"]; + await act(async () => { + root.render(<>{statuses.map((status, index) => ( + + ))}); + }); + const headers = [...container.querySelectorAll('a[aria-label$=". View run"]')]; + expect(headers.map((header) => header.getAttribute("aria-label"))).toEqual([ + "Agent 0 — Running. View run", "Agent 1 — Queued. View run", + "Agent 2 — Succeeded. View run", "Agent 3 — Failed. View run", + "Agent 4 — Timed out. View run", "Agent 5 — Cancelled. View run", + "Agent 6 — Interrupted. View run", + ]); + expect(headers.every((header) => header.querySelector("svg") === null)).toBe(true); + expect(container.querySelector(".status-chip")).toBeNull(); + expect(container.querySelectorAll('[aria-label="Task in review"]')).toHaveLength(7); + expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0); + expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')?.getAttribute("href")) + .toBe("/agents/agent-0/runs/run-0"); + await act(async () => root.unmount()); + }); + + it("keeps a failed task lookup navigable and shows a clear error", async () => { + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.textContent).toContain("Task unavailable"); + expect(container.querySelector('a[href="/issues/issue-missing"]')).not.toBeNull(); + await act(async () => root.unmount()); + }); + + it("does not animate running records while execution is reconnecting", async () => { + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')).not.toBeNull(); + expect(container.querySelector(".status-chip")).toBeNull(); + expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0); + await act(async () => root.unmount()); + }); }); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index b68d80f49a..25bbb97b18 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,45 +1,18 @@ import { memo, useMemo } from "react"; import { Link } from "@/lib/router"; import { useQueries, useQuery } from "@tanstack/react-query"; -import { requiresExecutionReconciliation, type Issue, type IssueRecoveryAction } from "@paperclipai/shared"; +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 { - deriveActiveRecoveryDisplayState, - RECOVERY_CHIP_DEFAULT_TONE, -} from "../lib/recovery-display"; -import { ExternalLink } from "lucide-react"; +import { Clock3 } from "lucide-react"; import { Identity } from "./Identity"; +import { StatusGlyph } from "./StatusGlyph"; import { RunChatSurface } from "./RunChatSurface"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling"; -import { Badge } from "@/components/ui/badge"; - -function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) { - const state = deriveActiveRecoveryDisplayState(action); - if (!state || requiresExecutionReconciliation(action.cause)) return null; - const tone = RECOVERY_CHIP_DEFAULT_TONE[state]; - const Icon = tone.icon; - return ( - - - {tone.label} - - ); -} const MIN_DASHBOARD_RUNS = 4; const DASHBOARD_RUN_CARD_LIMIT = 4; @@ -47,10 +20,17 @@ 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[] = []; -function isRunActive(run: LiveRunForIssue): boolean { - return run.status === "queued" || run.status === "running"; -} +const runStatusLabels: Record = { + running: "Running", + queued: "Queued", + succeeded: "Succeeded", + failed: "Failed", + timed_out: "Timed out", + cancelled: "Cancelled", + interrupted: "Interrupted", +}; interface ActiveAgentsPanelProps { companyId: string; @@ -63,6 +43,7 @@ interface ActiveAgentsPanelProps { emptyMessage?: string; queryScope?: string; showMoreLink?: boolean; + showTranscripts?: boolean; } export function ActiveAgentsPanel({ @@ -76,6 +57,7 @@ export function ActiveAgentsPanel({ emptyMessage = "No recent agent runs.", queryScope = "dashboard", showMoreLink = true, + showTranscripts = false, }: ActiveAgentsPanelProps) { const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const; const sharedLiveRuns = useSharedPollingQuery({ @@ -119,7 +101,7 @@ export function ActiveAgentsPanel({ }, [issueQueries]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ - runs: visibleRuns, + runs: showTranscripts ? visibleRuns : EMPTY_RUNS, companyId, maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN, logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS, @@ -137,7 +119,7 @@ export function ActiveAgentsPanel({

{emptyMessage}

) : ( -
+
{visibleRuns.map((run) => ( visibleIssueIds[index] === run.issueId && query.isError)} className={cardClassName} /> ))}
)} - {showMoreLink && hiddenRunCount > 0 && ( + {showMoreLink && runs.length > 0 && (
- {hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"} + {hiddenRunCount > 0 + ? `${hiddenRunCount} more active/recent run${hiddenRunCount === 1 ? "" : "s"}` + : "View all runs"}
)} @@ -163,88 +148,94 @@ export function ActiveAgentsPanel({ ); } -const AgentRunCard = memo(function AgentRunCard({ +export const AgentRunCard = memo(function AgentRunCard({ companyId, run, issue, - transcript, - hasOutput, - isActive, + transcript = EMPTY_TRANSCRIPT, + hasOutput = false, + showTranscript = false, + issueLoadFailed = false, className, }: { companyId: string; run: LiveRunForIssue; - issue?: Issue; - transcript: TranscriptEntry[]; - hasOutput: boolean; - isActive: boolean; + issue?: Pick; + 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 taskTitle = issue?.title ?? (issueLoadFailed ? "Task unavailable" : "Loading task…"); + return (
-
-
-
-
- {isActive && (!run.execution || run.execution.phase === "working") ? ( - - - - - ) : ( - - )} - -
-
- {isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} -
-
+ )} data-run-status={run.status}> +
+ + + + {run.issueId ? ( - + + + + {taskTitle} + + {issue?.identifier ?? run.issueId.slice(0, 8)} + + + ) : ( + + + {run.invocationSource === "timer" ? "Scheduled heartbeat" : "No linked task"} -
- - {run.issueId && ( -
- - {issue?.identifier ?? run.issueId.slice(0, 8)} - {issue?.title ? ` - ${issue.title}` : ""} - - {issue?.activeRecoveryAction ? ( -
- -
- ) : null} -
)} +
-
- -
+ {showTranscript && ( +
+ +
+ )}
); }); diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx index cc72236270..104e94cbea 100644 --- a/ui/src/components/ActivityCharts.tsx +++ b/ui/src/components/ActivityCharts.tsx @@ -20,9 +20,9 @@ function emptyRunDay(date: string): DashboardRunActivityDay { } const runSegmentColors = { - succeeded: "var(--hex-10b981)", + succeeded: "var(--status-task-icon-done)", recovered: "var(--status-task-todo)", - failed: "var(--hex-ef4444)", + failed: "var(--status-task-icon-blocked)", other: "var(--hex-737373)", } as const; @@ -166,7 +166,7 @@ export function RunActivityChart(props: RunChartProps) { } const priorityColors: Record = { - critical: "var(--hex-ef4444)", + critical: "var(--status-task-icon-blocked)", high: "var(--hex-f97316)", medium: "var(--hex-eab308)", low: "var(--hex-6b7280)", @@ -223,14 +223,15 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA // status vocabulary; badge, row, chart, and log agree). Previously an // independent palette (todo blue, in_progress violet, etc.). `backlog` // deliberately keeps --project-none (pre-B5, per user ruling); the -// priority series and success-rate tints below are not status hues and -// are left alone. +// non-red priority series and warning success-rate tints retain their own hues. +// Progress, done, and blocked use the icon hues so bars and legends match +// the task icons in each theme. const statusColors: Record = { todo: "var(--status-task-todo)", - in_progress: "var(--status-task-in_progress)", + in_progress: "var(--status-task-icon-in_progress)", in_review: "var(--status-task-in_review)", - done: "var(--status-task-done)", - blocked: "var(--status-task-blocked)", + done: "var(--status-task-icon-done)", + blocked: "var(--status-task-icon-blocked)", cancelled: "var(--status-task-cancelled)", backlog: "var(--project-none)", }; @@ -309,7 +310,7 @@ export function SuccessRateChart(props: RunChartProps) { // rather than dragging it down as failures. const effectiveSucceeded = entry.succeeded + entry.recovered; const rate = entry.total > 0 ? effectiveSucceeded / entry.total : 0; - const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)"; + const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--status-task-icon-done)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--status-task-icon-blocked)"; return (
0 ? Math.round(rate * 100) : 0}% (${effectiveSucceeded}/${entry.total})`}> {entry.total > 0 ? ( diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index 13d335e854..f2ca58a21c 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -53,27 +53,44 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en const inner = (
-
-
- - {actorAvatarUrl && } - {deriveInitials(actorName)} - -

- {actorName} - {verb} - {name && {name}} - {entityTitle && — {entityTitle}} -

+
+ +
+
+

+ + {actorName}{" "} + {verb} + + {event.entityType === "issue" ? ( + {entityTitle} + ) : ( + + {name && {name}} + {entityTitle && — {entityTitle}} + + )} +

+ + {event.entityType === "issue" ? name : null} + +
+
+ + {timeAgo(event.createdAt)} + +
- {timeAgo(event.createdAt)}
); const classes = cn( - "px-4 py-2 text-sm", + "dashboard-list-row text-sm", link && "cursor-pointer hover:bg-accent/50 transition-colors", className, ); diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index e51f942b65..b123da3ea7 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -24,7 +24,7 @@ type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | function CrumbIdentifier({ identifier }: { identifier?: string }) { if (!identifier) return null; return ( - + {identifier} ); @@ -113,9 +113,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: return (
{menuButton} -

+

{currentCrumb.leading ? ( - {currentCrumb.leading} + {currentCrumb.leading} ) : null} {currentCrumb.label} @@ -137,9 +137,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: {isLast || !crumb.href ? ( crumb.leading || crumb.identifier ? ( - + {crumb.leading && ( - {crumb.leading} + {crumb.leading} )} {!taskDetailLayout ? : null} {crumb.label} @@ -154,12 +154,12 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: {crumb.leading && ( - {crumb.leading} + {crumb.leading} )} {!taskDetailLayout ? : null} {crumb.label} @@ -194,9 +194,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: {menuButton}
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? ( -

+

{breadcrumbs[0].leading && ( - {breadcrumbs[0].leading} + {breadcrumbs[0].leading} )} {breadcrumbs[0].label} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 111f60690d..7a3418dcc8 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -144,8 +144,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).not.toContain("Settings"); expect(container.querySelector('[aria-label="Back from Settings"]')).toBeNull(); const settingsSurface = container.querySelector('[data-contextual-sidebar="settings"]'); - expect(settingsSurface?.classList).toContain("bg-border/50"); - expect(settingsSurface?.classList).toContain("dark:bg-muted"); + expect(settingsSurface?.classList).toContain("primary-sidebar-surface"); expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe( primarySidebarStyles.nav, ); diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 970709436f..bd71c36ff9 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -173,8 +173,7 @@ describe("Sidebar", () => { const sidebar = container.querySelector("aside"); expect(sidebar?.classList).not.toContain("border-r"); expect(sidebar?.classList).not.toContain("border-border"); - expect(sidebar?.classList).toContain("bg-border/50"); - expect(sidebar?.classList).toContain("dark:bg-muted"); + expect(sidebar?.classList).toContain("primary-sidebar-surface"); flushSync(() => { root.unmount(); diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index e918ed3540..efbcfa4e56 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -182,7 +182,7 @@ export function SidebarNavItem({ )} {!rail && (hasLive || liveAccessory) && ( - + {liveAccessory} {hasLive && ( <> diff --git a/ui/src/components/SidebarRecentTasks.tsx b/ui/src/components/SidebarRecentTasks.tsx index be9797bbf4..3bcaf90380 100644 --- a/ui/src/components/SidebarRecentTasks.tsx +++ b/ui/src/components/SidebarRecentTasks.tsx @@ -250,11 +250,11 @@ function RecentTasksList({ <> {entries.map((entry) => ( -
+
{!rail ? ( @@ -265,7 +265,7 @@ function RecentTasksList({ variant="ghost" size="icon-xs" aria-label={`More actions for ${entry.title}`} - className="absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 pointer-coarse:opacity-100 group-hover/recent-task:opacity-100 group-focus-within/recent-task:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground data-[state=open]:opacity-100" + className="sidebar-action-menu absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground pointer-events-none opacity-0 transition-opacity hover:bg-sidebar-accent dark:hover:bg-sidebar-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:before:hidden group-hover/recent-task:pointer-events-auto group-hover/recent-task:opacity-100 group-focus-within/recent-task:pointer-events-auto group-focus-within/recent-task:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:bg-sidebar-accent data-[state=open]:text-foreground data-[state=open]:opacity-100" >