From 3d1c20e95a9bed3d43d56a7ab74a4ca8350f0509 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 15 Jul 2026 16:40:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(panel):=20tooltip=20sweep=20=E2=80=94=20ov?= =?UTF-8?q?erview,=20agents,=20A2A,=20journals,=20auditor,=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derivation tips on every key-metric, scorecard, and quality-metrics figure; the cryptic member-scorecard headers get full decodes; avatar initials, truncated ids, and toggle buttons gain accessible names; title-only hints upgrade to the HelpTip idiom throughout. --- panel/src/app/(dashboard)/a2a/page.tsx | 86 +++++++------ .../(dashboard)/journals/[entryId]/page.tsx | 32 +++-- panel/src/app/(dashboard)/metrics/page.tsx | 102 +++++++++------ .../__tests__/a2a-connection-badge.test.tsx | 8 ++ .../__tests__/a2a-conversation-list.test.tsx | 36 +++++- .../a2a/__tests__/a2a-pair-card.test.tsx | 12 +- .../components/a2a/a2a-connection-badge.tsx | 20 +-- panel/src/components/a2a/a2a-context-pane.tsx | 24 ++-- panel/src/components/a2a/a2a-pair-card.tsx | 32 ++--- .../agents/__tests__/agent-card.test.tsx | 23 ++++ .../__tests__/orchestrator-status.test.tsx | 19 +++ panel/src/components/agents/agent-card.tsx | 23 ++-- .../components/agents/orchestrator-status.tsx | 111 ++++++++-------- panel/src/components/agents/stream-viewer.tsx | 8 +- .../auditor/findings-queue-panel.tsx | 25 ++-- panel/src/components/auditor/flagged-item.tsx | 8 +- .../auditor/quality-metrics-panel.tsx | 26 ++-- .../scorecard-overview-panel.test.tsx | 8 ++ .../dashboard/key-metrics-panel.tsx | 24 ++-- .../dashboard/scorecard-overview-panel.tsx | 23 ++-- .../components/dashboard/team-health-card.tsx | 37 ++++-- .../dashboard/usage-overview-panel.tsx | 43 ++++--- .../journals/__tests__/entry-card.test.tsx | 10 ++ panel/src/components/journals/agent-item.tsx | 23 ++-- panel/src/components/journals/entry-card.tsx | 18 +-- .../components/journals/growth-summary.tsx | 41 +++--- .../metrics/__tests__/scorecards-tab.test.tsx | 10 ++ .../__tests__/task-status-tiles.test.tsx | 21 ++++ .../components/metrics/agent-usage-chart.tsx | 7 +- panel/src/components/metrics/delivery-tab.tsx | 52 +++++--- .../src/components/metrics/scorecards-tab.tsx | 119 ++++++++++++++---- .../components/metrics/task-status-tiles.tsx | 19 +-- .../components/metrics/team-usage-chart.tsx | 7 +- 33 files changed, 723 insertions(+), 334 deletions(-) diff --git a/panel/src/app/(dashboard)/a2a/page.tsx b/panel/src/app/(dashboard)/a2a/page.tsx index a46b0ab9..ad546dc4 100644 --- a/panel/src/app/(dashboard)/a2a/page.tsx +++ b/panel/src/app/(dashboard)/a2a/page.tsx @@ -42,6 +42,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { OfflineState } from "@/components/ui/offline-state"; +import { HelpTip } from "@/components/ui/help-tip"; import { useUIStore } from "@/store"; import { getAgentDisplayName } from "@/lib/agent-utils"; import { lastSenderOf } from "@/components/a2a/a2a-utils"; @@ -275,20 +276,27 @@ function A2APageContent() { {/* Context pane never appears below xl — its toggle is hidden there too, matching the switchboard/list toggle's placement idiom (design doc §1). */} - + + @@ -328,30 +336,34 @@ function A2APageContent() { {view === "switchboard" ? "Switchboard" : "Conversations"} -
- - -
+ +
+ + +
+
- @@ -149,11 +155,10 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) { Journal -

- {entry.journal_id.slice(0, 8)} +

+ + {entry.journal_id.slice(0, 8)} +

@@ -168,13 +173,14 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
- - Task #{entry.task_id.slice(0, 8)} - + + + Task #{entry.task_id.slice(0, 8)} + +
diff --git a/panel/src/app/(dashboard)/metrics/page.tsx b/panel/src/app/(dashboard)/metrics/page.tsx index 24739f94..38ca2848 100644 --- a/panel/src/app/(dashboard)/metrics/page.tsx +++ b/panel/src/app/(dashboard)/metrics/page.tsx @@ -25,6 +25,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { OfflineState } from "@/components/ui/offline-state"; +import { HelpTip } from "@/components/ui/help-tip"; import { ResponsiveTable, ResponsiveTableCardList, @@ -157,10 +158,12 @@ function TeamHealthCard({ -
- - {healthScore}% -
+ +
+ + {healthScore}% +
+
{activeTasks}
@@ -338,26 +341,31 @@ function PerformanceTabContent() { label: "Pending", value: pending, icon: , + tip: "Ready for work but not yet claimed by an agent", }, { label: "In Progress", value: inProgress, icon: , + tip: "Claimed and actively being worked", }, { label: "Blocked", value: blocked, icon: , + tip: "Stuck on an external dependency, not making progress", }, { label: "Awaiting QA", value: awaitingQa, icon: , + tip: "Dev work done and PR open, waiting on QA review", }, { label: "Completed", value: completed, icon: , + tip: "Reached the terminal completed state and merged", }, ]} /> @@ -452,12 +460,16 @@ function TokenUsageCostsSection() {
{/* Time window selector — drives every period-scoped hook below */}
- setPeriod(v as UsagePeriod)} - aria-label="Usage time window" - /> + +
+ setPeriod(v as UsagePeriod)} + aria-label="Usage time window" + /> +
+
{/* Row 1 — Summary cards */} @@ -467,18 +479,21 @@ function TokenUsageCostsSection() { value={summary ? fmtTokens(summary.tokens_input) : undefined} icon={} isLoading={loadingSnap} + tip="Prompt/context tokens sent to the model in the selected window" /> } isLoading={loadingSnap} + tip="Tokens generated by the model in response, in the selected window" /> } isLoading={loadingSnap} + tip="Provider-priced cost for input + output tokens in the selected window" /> } isLoading={loadingSnap} + tip="Input + output tokens combined in the selected window" /> } isLoading={loadingCache} + tip="Cost avoided by serving cached tokens instead of a fresh model call" />
@@ -554,6 +572,7 @@ interface SummaryCardProps { icon: React.ReactNode; trend?: { dir: "up" | "down"; label: string }; isLoading: boolean; + tip?: string; } function SummaryCard({ @@ -562,15 +581,18 @@ function SummaryCard({ icon, trend, isLoading, + tip, }: SummaryCardProps) { return ( - - - {title} - - {icon} - + + + + {title} + + {icon} + + {isLoading ? ( @@ -652,15 +674,17 @@ function CacheEfficiencyCard({ {isLoading ? ( ) : ( -
-
{pct.toFixed(1)}%
-

- {cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache - reads · saved $ - {cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"} -

- -
+ +
+
{pct.toFixed(1)}%
+

+ {cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache + reads · saved $ + {cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"} +

+ +
+
)}
@@ -697,7 +721,11 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) { Role Cost Cache hit - % + + + % + + @@ -754,10 +782,12 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) { return ( - - - Spawn Waste - + + + + Spawn Waste + + {isLoading ? ( @@ -813,11 +843,13 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) { /> )} {data.respawn_strikes.length > 0 && ( -

- {data.respawn_strikes.length} wedged task - {data.respawn_strikes.length === 1 ? "" : "s"} with open respawn - strikes -

+ +

+ {data.respawn_strikes.length} wedged task + {data.respawn_strikes.length === 1 ? "" : "s"} with open + respawn strikes +

+
)}
)} diff --git a/panel/src/components/a2a/__tests__/a2a-connection-badge.test.tsx b/panel/src/components/a2a/__tests__/a2a-connection-badge.test.tsx index 6cb58b99..669c58b4 100644 --- a/panel/src/components/a2a/__tests__/a2a-connection-badge.test.tsx +++ b/panel/src/components/a2a/__tests__/a2a-connection-badge.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ConnectionState } from "@/lib/websocket/connection"; import { A2AConnectionBadge, @@ -42,4 +43,11 @@ describe("A2AConnectionBanner", () => { fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); expect(onDismiss).toHaveBeenCalledTimes(1); }); + + it("shows a matching visible tooltip on the dismiss button", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByRole("button", { name: "Dismiss" })); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Dismiss"); + }); }); diff --git a/panel/src/components/a2a/__tests__/a2a-conversation-list.test.tsx b/panel/src/components/a2a/__tests__/a2a-conversation-list.test.tsx index 49e0bb9f..dfb007a1 100644 --- a/panel/src/components/a2a/__tests__/a2a-conversation-list.test.tsx +++ b/panel/src/components/a2a/__tests__/a2a-conversation-list.test.tsx @@ -1,8 +1,20 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AdminConversationSummary } from "@/lib/api/a2a"; import { A2AConversationList } from "../a2a-conversation-list"; +// jsdom has no ResizeObserver; Radix ScrollArea only reaches for one once a +// Tooltip portal mounts inside it and triggers a size recalculation — the +// other renders below never hit that path. Stub it for the hover test. +if (typeof window !== "undefined" && !window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + function buildConversation( overrides: Partial = {}, ): AdminConversationSummary { @@ -37,9 +49,10 @@ describe("A2AConversationList", () => { // Participants via getAgentDisplayName ("{a} <-> {b}"). expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument(); expect(screen.getByText(/Backend QA/)).toBeInTheDocument(); - // Both participants get an avatar, matching A2APairCard's PairAvatar. - expect(screen.getByTitle("Backend Dev 1")).toBeInTheDocument(); - expect(screen.getByTitle("Backend QA")).toBeInTheDocument(); + // Both participants get an avatar, matching A2APairCard's PairAvatar + // (initials + a hover tooltip with the full name — see next test). + expect(screen.getByText("BD1")).toBeInTheDocument(); + expect(screen.getByText("BQA")).toBeInTheDocument(); // Topic, preview, message count, relative timestamp. expect(screen.getByText("QA handoff")).toBeInTheDocument(); expect( @@ -57,6 +70,23 @@ describe("A2AConversationList", () => { ); }); + it("shows the full name in a hover tooltip on the abbreviated avatar", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.hover(screen.getByText("BD1")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Backend Dev 1", + ); + }); + it("fires onSelect with the conversation id on row click", () => { const onSelect = vi.fn(); render( diff --git a/panel/src/components/a2a/__tests__/a2a-pair-card.test.tsx b/panel/src/components/a2a/__tests__/a2a-pair-card.test.tsx index 17813c77..112d0598 100644 --- a/panel/src/components/a2a/__tests__/a2a-pair-card.test.tsx +++ b/panel/src/components/a2a/__tests__/a2a-pair-card.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AdminPairSummary } from "@/lib/api/a2a"; import { A2APairCard, PairAvatar } from "../a2a-pair-card"; @@ -53,11 +54,20 @@ describe("A2APairCard", () => { it("colors each avatar by team, not a per-agent hue", () => { render(); - expect(screen.getByTitle("Frontend Dev 1")).toHaveClass( + expect(screen.getByText("FD1").parentElement).toHaveClass( "border-violet-500/40", ); }); + it("shows the full agent display name in a hover tooltip (tooltip-aria-label-spec §1b)", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("FD1")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Frontend Dev 1", + ); + }); + it("marks the card as selected via aria-pressed", () => { render( - + + +
); } diff --git a/panel/src/components/a2a/a2a-context-pane.tsx b/panel/src/components/a2a/a2a-context-pane.tsx index 27f8bd84..5f365a06 100644 --- a/panel/src/components/a2a/a2a-context-pane.tsx +++ b/panel/src/components/a2a/a2a-context-pane.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { getAgentDisplayName, getAgentInitials, @@ -23,17 +24,18 @@ function IdentityCard({ slug }: { slug: string }) { href={`/agents/${slug}`} className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors" > -
- - {getAgentInitials(slug)} - -
+ +
+ + {getAgentInitials(slug)} + +
+
{getAgentDisplayName(slug)} diff --git a/panel/src/components/a2a/a2a-pair-card.tsx b/panel/src/components/a2a/a2a-pair-card.tsx index 54659ed6..f1b4e020 100644 --- a/panel/src/components/a2a/a2a-pair-card.tsx +++ b/panel/src/components/a2a/a2a-pair-card.tsx @@ -1,6 +1,7 @@ "use client"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { getAgentDisplayName, getAgentInitials, @@ -27,17 +28,18 @@ interface A2APairCardProps { * identically across the switchboard and the classic list. */ export function PairAvatar({ slug }: { slug: string }) { return ( -
- - {getAgentInitials(slug)} - -
+ +
+ + {getAgentInitials(slug)} + +
+
); } @@ -94,9 +96,11 @@ export function A2APairCard({
{hasHistory && ( - - {pair.message_count} - + + + {pair.message_count} + + )} diff --git a/panel/src/components/agents/__tests__/agent-card.test.tsx b/panel/src/components/agents/__tests__/agent-card.test.tsx index 2ff3f579..9252161c 100644 --- a/panel/src/components/agents/__tests__/agent-card.test.tsx +++ b/panel/src/components/agents/__tests__/agent-card.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { AgentDefinition } from "@/lib/agent-definitions"; import type { AgentStatusResponse } from "@/types"; @@ -130,6 +131,28 @@ describe("AgentCard", () => { ).toBeInTheDocument(); }); + it("sets a matching title on the actions menu trigger for the tooltip text", () => { + // The DropdownMenu mock above (an inline div, not real Radix) swallows the + // Tooltip's injected pointer handlers, so a hover-driven assertion isn't + // reachable here — the real Radix composition mirrors task-actions.tsx's + // proven working Tooltip-around-DropdownMenuTrigger pattern. + render(); + expect( + screen.getByRole("button", { name: "Agent actions" }), + ).toHaveAttribute("title", "Agent actions"); + }); + + it("explains the status dot/label via a hover tooltip reusing the state description map", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.hover(screen.getByText("active")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + /actively working/i, + ); + }); + it("shows a compact one-line token/cost readout when usage data is present", () => { render( { // Active isn't gated by rosterLoading — it already resolved. expect(screen.getByTestId("stat-active")).toHaveTextContent("1"); }); + + it("explains what each stat cell counts via a hover tooltip", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.hover(screen.getByTestId("stat-total-agents")); + expect(await screen.findByRole("tooltip")).toHaveTextContent(/roster/i); + }); }); diff --git a/panel/src/components/agents/agent-card.tsx b/panel/src/components/agents/agent-card.tsx index 29a56ea3..d09927a4 100644 --- a/panel/src/components/agents/agent-card.tsx +++ b/panel/src/components/agents/agent-card.tsx @@ -79,16 +79,19 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) { {agent.name || "Unknown Agent"} - - - + + + + + {!isActive && ( diff --git a/panel/src/components/agents/orchestrator-status.tsx b/panel/src/components/agents/orchestrator-status.tsx index 147f6a24..ef18052d 100644 --- a/panel/src/components/agents/orchestrator-status.tsx +++ b/panel/src/components/agents/orchestrator-status.tsx @@ -2,6 +2,7 @@ import { OrchestratorStatus as OrchestratorStatusType } from "@/types"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { Server, Users, Clock, Activity } from "lucide-react"; interface OrchestratorStatusCardsProps { @@ -39,64 +40,72 @@ export function OrchestratorStatusCards({ return ( -
-
- - Orchestrator + +
+
+ + Orchestrator +
+ {isLoading ? ( + + ) : ( + + {isRunning ? "Running" : "Stopped"} + + )}
- {isLoading ? ( - - ) : ( - - {isRunning ? "Running" : "Stopped"} - - )} -
+ -
-
- - Total Agents + +
+
+ + Total Agents +
+ {rosterLoading ? ( + + ) : ( + + {rosterCount} + + )}
- {rosterLoading ? ( - - ) : ( - - {rosterCount} - - )} -
+ -
-
- - Active + +
+
+ + Active +
+ {isLoading ? ( + + ) : ( + + {activeCount} + + )}
- {isLoading ? ( - - ) : ( - - {activeCount} - - )} -
+ -
-
- - Waiting + +
+
+ + Waiting +
+ {isLoading ? ( + + ) : ( + + {waitingCount} + + )}
- {isLoading ? ( - - ) : ( - - {waitingCount} - - )} -
+ ); diff --git a/panel/src/components/agents/stream-viewer.tsx b/panel/src/components/agents/stream-viewer.tsx index 297ca0f4..f208e7e0 100644 --- a/panel/src/components/agents/stream-viewer.tsx +++ b/panel/src/components/agents/stream-viewer.tsx @@ -85,7 +85,13 @@ export function AgentStreamViewer({ {streamChunks.length > 0 && ( - diff --git a/panel/src/components/auditor/findings-queue-panel.tsx b/panel/src/components/auditor/findings-queue-panel.tsx index 6bef6fee..59881793 100644 --- a/panel/src/components/auditor/findings-queue-panel.tsx +++ b/panel/src/components/auditor/findings-queue-panel.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { HelpTip } from "@/components/ui/help-tip"; import { ListChecks } from "lucide-react"; import Link from "next/link"; @@ -83,12 +84,16 @@ export function FindingsQueuePanel({ > {finding.severity} - - {finding.origin} - - - round {finding.round} - + + + {finding.origin} + + + + + round {finding.round} + +

{finding.actual ?? finding.expected ?? finding.criterion ?? "—"} @@ -104,9 +109,11 @@ export function FindingsQueuePanel({ href={"/tasks/" + finding.task_id} prefetch={false} > - - Task #{finding.task_id.slice(0, 8)} - + + + Task #{finding.task_id.slice(0, 8)} + +

diff --git a/panel/src/components/auditor/flagged-item.tsx b/panel/src/components/auditor/flagged-item.tsx index a4250b28..bc39bc32 100644 --- a/panel/src/components/auditor/flagged-item.tsx +++ b/panel/src/components/auditor/flagged-item.tsx @@ -78,9 +78,11 @@ export function FlaggedItem({ {flag.related_task_id && ( - - Task #{flag.related_task_id.slice(0, 8)} - + + + Task #{flag.related_task_id.slice(0, 8)} + + )}
diff --git a/panel/src/components/auditor/quality-metrics-panel.tsx b/panel/src/components/auditor/quality-metrics-panel.tsx index 8b320aa2..d96c2a6d 100644 --- a/panel/src/components/auditor/quality-metrics-panel.tsx +++ b/panel/src/components/auditor/quality-metrics-panel.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Progress } from "@/components/ui/progress"; +import { HelpTip } from "@/components/ui/help-tip"; import { BarChart3, CheckCircle, @@ -22,6 +23,7 @@ interface MetricDisplay { icon: React.ReactNode; format: (value: number) => string; isPercent?: boolean; + tip: string; } const METRICS: MetricDisplay[] = [ @@ -30,6 +32,7 @@ const METRICS: MetricDisplay[] = [ label: "Tasks Completed (24h)", icon: , format: (v) => String(v), + tip: "Tasks that reached completed in the last 24 hours", }, { key: "qa_pass_rate", @@ -37,6 +40,7 @@ const METRICS: MetricDisplay[] = [ icon: , format: (v) => `${Math.round(v * 100)}%`, isPercent: true, + tip: "Share of QA reviews that passed on the first attempt, no fail bounce", }, { key: "avg_completion_time", @@ -44,6 +48,7 @@ const METRICS: MetricDisplay[] = [ icon: , format: (v) => `${(typeof v === "number" ? v : parseFloat(v) || 0).toFixed(1)}h`, + tip: "Average wall-clock time from claim to completion across recent tasks", }, { key: "documentation_rate", @@ -51,18 +56,21 @@ const METRICS: MetricDisplay[] = [ icon: , format: (v) => `${Math.round(v * 100)}%`, isPercent: true, + tip: "Share of completed tasks that passed through a documentation step", }, { key: "active_blockers", label: "Active Blockers", icon: , format: (v) => String(v), + tip: "Tasks currently in the blocked status right now", }, { key: "longest_block_hours", label: "Longest Block", icon: , format: (v) => `${v}h`, + tip: "How long the longest-running currently-blocked task has been stuck", }, ]; @@ -91,15 +99,17 @@ export function QualityMetricsPanel({ const value = metrics?.[m.key]; return (
-
-
- {m.icon} - {m.label} + +
+
+ {m.icon} + {m.label} +
+ + {value != null ? m.format(value) : "-"} +
- - {value != null ? m.format(value) : "-"} - -
+ {m.isPercent && value != null && ( )} diff --git a/panel/src/components/dashboard/__tests__/scorecard-overview-panel.test.tsx b/panel/src/components/dashboard/__tests__/scorecard-overview-panel.test.tsx index 06618658..98d3a5b2 100644 --- a/panel/src/components/dashboard/__tests__/scorecard-overview-panel.test.tsx +++ b/panel/src/components/dashboard/__tests__/scorecard-overview-panel.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; const { mockOrg } = vi.hoisted(() => ({ mockOrg: vi.fn() })); @@ -44,6 +45,13 @@ describe("ScorecardOverviewPanel", () => { expect(link).toHaveAttribute("href", "/metrics?tab=scorecards"); }); + it("explains a metric row's derivation via a hover tooltip", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("First-pass yield")); + expect(await screen.findByRole("tooltip")).toHaveTextContent(/bounce/i); + }); + it("shows a skeleton while loading", () => { mockOrg.mockReturnValue({ data: undefined, isLoading: true }); const { container } = render(); diff --git a/panel/src/components/dashboard/key-metrics-panel.tsx b/panel/src/components/dashboard/key-metrics-panel.tsx index 2073ea00..385f7474 100644 --- a/panel/src/components/dashboard/key-metrics-panel.tsx +++ b/panel/src/components/dashboard/key-metrics-panel.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { TrendingUp, CheckCircle, @@ -19,6 +20,7 @@ interface MetricItem { label: string; icon: React.ReactNode; format?: (value: number) => string; + tip: string; } // Keys must match DashboardService.get_key_metrics() — the shape /dashboard/ceo @@ -30,24 +32,28 @@ const METRIC_CONFIG: MetricItem[] = [ label: "Velocity (7d)", icon: , format: (v) => `${v} tasks`, + tip: "Tasks completed in the last 7 days", }, { key: "completion_rate", label: "Completion Rate", icon: , format: (v) => `${Math.round(v * 100)}%`, + tip: "Share of tasks started in the last 7 days that reached completed", }, { key: "documentation_coverage", label: "Documentation Coverage", icon: , format: (v) => `${Math.round(v * 100)}%`, + tip: "Share of completed tasks that passed through a documentation step", }, { key: "active_blockers", label: "Active Blockers", icon: , format: (v) => `${v}`, + tip: "Tasks currently in the blocked status right now", }, ]; @@ -70,15 +76,17 @@ export function KeyMetricsPanel({ metrics, isLoading }: KeyMetricsProps) { const rawValue = metrics?.[m.key]; const value = typeof rawValue === "number" ? rawValue : null; return ( -
-
- {m.icon} - {m.label} + +
+
+ {m.icon} + {m.label} +
+ + {value != null ? (m.format ? m.format(value) : value) : "-"} +
- - {value != null ? (m.format ? m.format(value) : value) : "-"} - -
+ ); })}
diff --git a/panel/src/components/dashboard/scorecard-overview-panel.tsx b/panel/src/components/dashboard/scorecard-overview-panel.tsx index 8dbf67f3..2dce55ca 100644 --- a/panel/src/components/dashboard/scorecard-overview-panel.tsx +++ b/panel/src/components/dashboard/scorecard-overview-panel.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { useOrgScorecard } from "@/hooks/use-observability"; import { Trophy, @@ -25,17 +26,20 @@ interface MetricRowProps { icon: React.ReactNode; label: string; value: string; + tip: string; } -function MetricRow({ icon, label, value }: MetricRowProps) { +function MetricRow({ icon, label, value, tip }: MetricRowProps) { return ( -
-
- {icon} - {label} + +
+
+ {icon} + {label} +
+ {value}
- {value} -
+ ); } @@ -81,26 +85,31 @@ export function ScorecardOverviewPanel() { icon={} label="Tasks completed (30d)" value={String(data.tasks_completed)} + tip="Tasks that reached completed org-wide in the last 30 days" /> } label="First-pass yield" value={pctOrNa(data.first_pass_yield)} + tip="Share of completed tasks that shipped without a QA fail, PR-gate fail, PM reject, or CEO reject bounce" /> } label="Throughput / hr" value={numOrNa(data.effort_throughput_per_hour)} + tip="Tasks completed per hour of active (non-idle) agent runtime" /> } label="Active effort" value={data.active_runtime_hours.toFixed(1) + "h"} + tip="Total hours agents spent actively working — idle/waiting time excluded" /> } label="Cost" value={"$" + data.cost_usd.toFixed(2)} + tip="Total provider-priced token cost across all sessions in the period" />
)} diff --git a/panel/src/components/dashboard/team-health-card.tsx b/panel/src/components/dashboard/team-health-card.tsx index 54e5bf81..6243b5a8 100644 --- a/panel/src/components/dashboard/team-health-card.tsx +++ b/panel/src/components/dashboard/team-health-card.tsx @@ -3,6 +3,7 @@ import { TeamHealth } from "@/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { HealthIndicator } from "./health-indicator"; import { Users, AlertTriangle, TrendingUp } from "lucide-react"; @@ -10,6 +11,12 @@ interface TeamHealthCardProps { health: TeamHealth; } +const HEALTH_STATUS_TIP: Record = { + ok: "Healthy — blocked ratio is low and work is flowing", + slow: "Slow — a meaningful share of this team's tasks are blocked", + critical: "Critical — most of this team's tasks are blocked; needs attention", +}; + export function TeamHealthCard({ health }: TeamHealthCardProps) { const teamName = health.team.replace(/_/g, " "); @@ -18,7 +25,11 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) {
{teamName} - + + + + +
@@ -56,17 +67,19 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) { {/* Blocked Ratio */} {health.blocked_ratio > 0 && (
- 0.3 - ? "destructive" - : health.blocked_ratio > 0.1 - ? "secondary" - : "outline" - } - > - {Math.round(health.blocked_ratio * 100)}% blocked - + + 0.3 + ? "destructive" + : health.blocked_ratio > 0.1 + ? "secondary" + : "outline" + } + > + {Math.round(health.blocked_ratio * 100)}% blocked + +
)}
diff --git a/panel/src/components/dashboard/usage-overview-panel.tsx b/panel/src/components/dashboard/usage-overview-panel.tsx index 484f0dd4..bc5ec100 100644 --- a/panel/src/components/dashboard/usage-overview-panel.tsx +++ b/panel/src/components/dashboard/usage-overview-panel.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { useUsageSummary } from "@/hooks/use-usage"; import { useUsageStore } from "@/store/usage-store"; import type { ConnectionState } from "@/lib/websocket/connection"; @@ -32,22 +33,25 @@ interface MetricRowProps { label: string; value: string; sub?: React.ReactNode; + tip?: string; } -function MetricRow({ icon, label, value, sub }: MetricRowProps) { +function MetricRow({ icon, label, value, sub, tip }: MetricRowProps) { return ( -
-
- {icon} - {label} + +
+
+ {icon} + {label} +
+
+ + {value} + + {sub} +
-
- - {value} - - {sub} -
-
+ ); } @@ -113,10 +117,12 @@ export function UsageOverviewPanel() { Token Usage & Cost - - {badge.icon} - {badge.label} - + + + {badge.icon} + {badge.label} + +
@@ -132,16 +138,19 @@ export function UsageOverviewPanel() { icon={} label="Tokens (input)" value={tokensInput != null ? fmt(tokensInput) : "—"} + tip="Prompt/context tokens sent to the model across all agent sessions in this period" /> } label="Tokens (output)" value={tokensOutput != null ? fmt(tokensOutput) : "—"} + tip="Tokens generated by the model in response, across all agent sessions in this period" /> } label="Total cost" value={totalCost != null ? fmtCost(totalCost) : "—"} + tip="Provider-priced cost for input + output tokens this period (local/Ollama sessions cost $0)" /> ) : undefined } + tip="Change in total cost compared to the immediately preceding period of the same length" /> } label="Period" value={periodLabel ?? "—"} + tip="The rolling time window these figures are aggregated over" />
)} diff --git a/panel/src/components/journals/__tests__/entry-card.test.tsx b/panel/src/components/journals/__tests__/entry-card.test.tsx index 4ee28390..9300d287 100644 --- a/panel/src/components/journals/__tests__/entry-card.test.tsx +++ b/panel/src/components/journals/__tests__/entry-card.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { EntryCard } from "../entry-card"; import { JournalEntryType, type JournalEntry } from "@/types"; @@ -55,6 +56,15 @@ describe("EntryCard — task id display", () => { ); }); + it("shows the full task id in a hover tooltip on the truncated badge", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("Task #e27ef84d")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "e27ef84d-1111-2222-3333-444455556666", + ); + }); + it("omits the task row entirely when there is no related task", () => { render(); expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument(); diff --git a/panel/src/components/journals/agent-item.tsx b/panel/src/components/journals/agent-item.tsx index d03a8b1c..fcd5c5af 100644 --- a/panel/src/components/journals/agent-item.tsx +++ b/panel/src/components/journals/agent-item.tsx @@ -2,6 +2,7 @@ import { Agent } from "@/types"; import { cn } from "@/lib/utils"; +import { HelpTip } from "@/components/ui/help-tip"; import { getAgentDisplayName } from "@/lib/agent-utils"; interface AgentItemProps { @@ -52,16 +53,20 @@ export function AgentItem({ )} >
-
- {initialsFor(name)} -
+ +
+ {initialsFor(name)} +
+
{hasEntries && ( - + + + )}
diff --git a/panel/src/components/journals/entry-card.tsx b/panel/src/components/journals/entry-card.tsx index beca59bc..011d92b5 100644 --- a/panel/src/components/journals/entry-card.tsx +++ b/panel/src/components/journals/entry-card.tsx @@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Markdown } from "@/components/ui/markdown"; import { CopyButton } from "@/components/ui/copy-button"; +import { HelpTip } from "@/components/ui/help-tip"; import { EntryTypeBadge } from "./entry-type-badge"; import { Clock, Tag, Link2, ChevronRight } from "lucide-react"; import Link from "next/link"; @@ -86,14 +87,15 @@ export function EntryCard({ entry }: EntryCardProps) { {entry.task_id && (
- - - Task #{entry.task_id.slice(0, 8)} - + + + + Task #{entry.task_id.slice(0, 8)} + +
diff --git a/panel/src/components/journals/growth-summary.tsx b/panel/src/components/journals/growth-summary.tsx index 23937188..6215ebbc 100644 --- a/panel/src/components/journals/growth-summary.tsx +++ b/panel/src/components/journals/growth-summary.tsx @@ -4,6 +4,7 @@ import { Journal, GrowthMetrics } from "@/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; +import { HelpTip } from "@/components/ui/help-tip"; import { BookOpen, Lightbulb, @@ -104,30 +105,34 @@ export function GrowthSummary({ {/* Struggle Resolution Rate */} {growth && growth.struggle_resolution_rate > 0 && ( -
-
- Struggle Resolution - - {Math.round(growth.struggle_resolution_rate * 100)}% - + +
+
+ Struggle Resolution + + {Math.round(growth.struggle_resolution_rate * 100)}% + +
+
- -
+ )} {/* Sentiment Trend */} {growth?.sentiment_trend && ( -
-
- Sentiment Trend - - {growth.sentiment_trend} - + +
+
+ Sentiment Trend + + {growth.sentiment_trend} + +
-
+ )} diff --git a/panel/src/components/metrics/__tests__/scorecards-tab.test.tsx b/panel/src/components/metrics/__tests__/scorecards-tab.test.tsx index d86e0299..3514cbe2 100644 --- a/panel/src/components/metrics/__tests__/scorecards-tab.test.tsx +++ b/panel/src/components/metrics/__tests__/scorecards-tab.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { AgentRole, AgentState, type Agent } from "@/types"; import type { MemberScorecard } from "@/types"; @@ -139,6 +140,15 @@ describe("ScorecardsTabContent", () => { expect(screen.queryByText("system")).not.toBeInTheDocument(); }); + it("explains an abbreviated member-table column via a hover tooltip", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("FPY")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + /first-pass yield/i, + ); + }); + it("surfaces load errors instead of an endless skeleton", () => { mockOrg.mockReturnValue({ data: undefined, diff --git a/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx b/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx index 23e818ad..07e6f414 100644 --- a/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx +++ b/panel/src/components/metrics/__tests__/task-status-tiles.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { TaskStatusTiles } from "../task-status-tiles"; describe("TaskStatusTiles", () => { @@ -34,4 +35,24 @@ describe("TaskStatusTiles", () => { expect(screen.getByText(label)).toBeInTheDocument(); }); }); + + it("explains a tile's meaning via a hover tooltip when one is provided", async () => { + const user = userEvent.setup(); + render( + icon, + tip: "Tasks stuck on an external dependency", + }, + ]} + />, + ); + await user.hover(screen.getByText("Blocked")); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "external dependency", + ); + }); }); diff --git a/panel/src/components/metrics/agent-usage-chart.tsx b/panel/src/components/metrics/agent-usage-chart.tsx index 98829b24..0fe7219d 100644 --- a/panel/src/components/metrics/agent-usage-chart.tsx +++ b/panel/src/components/metrics/agent-usage-chart.tsx @@ -13,6 +13,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { SegmentedControl } from "@/components/ui/segmented-control"; +import { HelpTip } from "@/components/ui/help-tip"; import { useIsMobile } from "@/hooks/use-is-mobile"; import type { AgentUsageRow } from "@/types"; @@ -70,7 +71,11 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) { Agent Tokens - % + + + % + + diff --git a/panel/src/components/metrics/delivery-tab.tsx b/panel/src/components/metrics/delivery-tab.tsx index 23fc6a1c..2ba2d4b7 100644 --- a/panel/src/components/metrics/delivery-tab.tsx +++ b/panel/src/components/metrics/delivery-tab.tsx @@ -12,6 +12,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { ResponsiveTable, ResponsiveTableCardList, @@ -121,25 +122,31 @@ function BottlenecksCard() { ) : ( <>
- Worst stage: + + Worst stage: + {data?.worst_stage ? ( {label(data.worst_stage)} ) : ( )} - - {data?.active_blockers ?? 0} active blockers - + + + {data?.active_blockers ?? 0} active blockers + +
{(data?.by_stage ?? []).slice(0, 6).map((s) => (
{label(s.status)} - - {fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "} - parked - + + + {fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "} + parked + +
- Rework (30d) + + Rework (30d) + {isLoading ? ( @@ -197,7 +206,11 @@ function ReworkCard() { Agent - Rate + + + Rate + + QA fails @@ -287,11 +300,13 @@ function CellScorecard({ team }: { team: string }) { } function ScorecardBody({ card }: { card: Scorecard | undefined }) { - const stat = (k: string, v: string) => ( -
- {k} - {v} -
+ const stat = (k: string, v: string, tip?: string) => ( + +
+ {k} + {v} +
+
); return (
@@ -301,8 +316,13 @@ function ScorecardBody({ card }: { card: Scorecard | undefined }) { card?.avg_cycle_hours != null ? card.avg_cycle_hours.toFixed(1) + "h" : "—", + "Average wall-clock time from claim to completion", + )} + {stat( + "Rework", + pct(card?.rework_rate ?? 0), + "Share of completed tasks that bounced back for revision at least once", )} - {stat("Rework", pct(card?.rework_rate ?? 0))} {stat("Cost", "$" + (card?.cost_usd ?? 0).toFixed(2))}
); diff --git a/panel/src/components/metrics/scorecards-tab.tsx b/panel/src/components/metrics/scorecards-tab.tsx index 46e626a8..29593c22 100644 --- a/panel/src/components/metrics/scorecards-tab.tsx +++ b/panel/src/components/metrics/scorecards-tab.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; +import { HelpTip } from "@/components/ui/help-tip"; import { Table, TableBody, @@ -85,21 +86,35 @@ function OrgSummary() {
); if (isLoading || !data) return ; - const cells: [string, string][] = [ + const cells: [string, string, string?][] = [ ["Members", String(data.member_count)], ["Completed", String(data.tasks_completed)], - ["First-pass yield", pctOrNa(data.first_pass_yield)], - ["Throughput/hr", numOrNa(data.effort_throughput_per_hour, 2)], - ["Active effort", data.active_runtime_hours.toFixed(1) + "h"], + [ + "First-pass yield", + pctOrNa(data.first_pass_yield), + "Share of completed tasks that shipped without a QA/PR-gate/PM/CEO bounce", + ], + [ + "Throughput/hr", + numOrNa(data.effort_throughput_per_hour, 2), + "Tasks completed per hour of active (non-idle) agent runtime", + ], + [ + "Active effort", + data.active_runtime_hours.toFixed(1) + "h", + "Total hours agents spent actively working — idle time excluded", + ], ["Cost", "$" + data.cost_usd.toFixed(2)], ]; return (
- {cells.map(([k, v]) => ( -
-
{v}
-
{k}
-
+ {cells.map(([k, v, tip]) => ( + +
+
{v}
+
{k}
+
+
))}
); @@ -114,21 +129,39 @@ function CeoCard() {
); if (isLoading || !data) return ; - const cells: [string, string][] = [ + const cells: [string, string, string?][] = [ ["Approvals", String(data.approval_count)], - ["Approval p50", hoursOrDash(data.approval_p50_seconds)], - ["Approval p90", hoursOrDash(data.approval_p90_seconds)], + [ + "Approval p50", + hoursOrDash(data.approval_p50_seconds), + "Median time from a task reaching your queue to your approval", + ], + [ + "Approval p90", + hoursOrDash(data.approval_p90_seconds), + "90th percentile — the slowest 10% of approvals took at least this long", + ], ["Unblocks", String(data.unblock_count)], - ["Unblock p50", hoursOrDash(data.unblock_p50_seconds)], - ["God-mode actions", String(data.godmode_actions)], + [ + "Unblock p50", + hoursOrDash(data.unblock_p50_seconds), + "Median time from a task blocking to you unblocking it", + ], + [ + "God-mode actions", + String(data.godmode_actions), + "Direct admin overrides you made outside the normal approval flow", + ], ]; return (
- {cells.map(([k, v]) => ( -
-
{v}
-
{k}
-
+ {cells.map(([k, v, tip]) => ( + +
+
{v}
+
{k}
+
+
))}
); @@ -168,14 +201,46 @@ export function ScorecardsTabContent() { Member - Done - FPY - Effort - Turns/task - QA pass - Escal. - Blocked others - Util. + + + Done + + + + + FPY + + + + + Effort + + + + + Turns/task + + + + + QA pass + + + + + Escal. + + + + + Blocked others + + + + + Util. + + diff --git a/panel/src/components/metrics/task-status-tiles.tsx b/panel/src/components/metrics/task-status-tiles.tsx index 848e0e74..20756e34 100644 --- a/panel/src/components/metrics/task-status-tiles.tsx +++ b/panel/src/components/metrics/task-status-tiles.tsx @@ -1,12 +1,15 @@ "use client"; import { Card } from "@/components/ui/card"; +import { HelpTip } from "@/components/ui/help-tip"; import { cn } from "@/lib/utils"; export interface TaskStatusTileData { label: string; value: number; icon: React.ReactNode; + /** What this status means — shown on hover. Omit for a self-explanatory label. */ + tip?: string; } interface TaskStatusTilesProps { @@ -23,13 +26,15 @@ export function TaskStatusTiles({ tiles, className }: TaskStatusTilesProps) { return (
{tiles.map((tile) => ( - -
- {tile.icon} - {tile.label} -
-
{tile.value}
-
+ + +
+ {tile.icon} + {tile.label} +
+
{tile.value}
+
+
))}
); diff --git a/panel/src/components/metrics/team-usage-chart.tsx b/panel/src/components/metrics/team-usage-chart.tsx index fdad69e8..d8436965 100644 --- a/panel/src/components/metrics/team-usage-chart.tsx +++ b/panel/src/components/metrics/team-usage-chart.tsx @@ -13,6 +13,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { SegmentedControl } from "@/components/ui/segmented-control"; +import { HelpTip } from "@/components/ui/help-tip"; import { useIsMobile } from "@/hooks/use-is-mobile"; import type { TeamUsageRow } from "@/types"; @@ -67,7 +68,11 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) { Team Tokens - % + + + % + +