feat(panel): tooltip sweep — overview, agents, A2A, journals, auditor, metrics

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.
This commit is contained in:
Renn F
2026-07-15 16:40:13 +02:00
parent c37516f640
commit 3d1c20e95a
33 changed files with 723 additions and 334 deletions
+49 -37
View File
@@ -42,6 +42,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { useUIStore } from "@/store"; import { useUIStore } from "@/store";
import { getAgentDisplayName } from "@/lib/agent-utils"; import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils"; import { lastSenderOf } from "@/components/a2a/a2a-utils";
@@ -275,20 +276,27 @@ function A2APageContent() {
{/* Context pane never appears below xl — its toggle is hidden {/* Context pane never appears below xl — its toggle is hidden
there too, matching the switchboard/list toggle's placement there too, matching the switchboard/list toggle's placement
idiom (design doc §1). */} idiom (design doc §1). */}
<Button <HelpTip
type="button" label={contextOpen ? "Hide context panel" : "Show context panel"}
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
title={contextOpen ? "Hide context panel" : "Show context panel"}
> >
{contextOpen ? ( <Button
<PanelRightClose className="h-3.5 w-3.5" /> type="button"
) : ( variant="ghost"
<PanelRightOpen className="h-3.5 w-3.5" /> size="sm"
)} className="hidden h-7 px-2 xl:inline-flex"
</Button> onClick={toggleContext}
aria-label={
contextOpen ? "Hide context panel" : "Show context panel"
}
title={contextOpen ? "Hide context panel" : "Show context panel"}
>
{contextOpen ? (
<PanelRightClose className="h-3.5 w-3.5" />
) : (
<PanelRightOpen className="h-3.5 w-3.5" />
)}
</Button>
</HelpTip>
</div> </div>
</div> </div>
@@ -328,30 +336,34 @@ function A2APageContent() {
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"} {view === "switchboard" ? "Switchboard" : "Conversations"}
</span> </span>
<div className="ml-auto flex items-center gap-1"> <HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<Button <div className="ml-auto flex items-center gap-1">
type="button" <Button
variant={view === "switchboard" ? "secondary" : "ghost"} type="button"
size="sm" variant={view === "switchboard" ? "secondary" : "ghost"}
className="h-7 px-2" size="sm"
aria-pressed={view === "switchboard"} className="h-7 px-2"
onClick={() => setView("switchboard")} aria-pressed={view === "switchboard"}
title="Switchboard: org-chart pair cards" aria-label="Switchboard: org-chart pair cards"
> onClick={() => setView("switchboard")}
<LayoutGrid className="h-3.5 w-3.5" /> title="Switchboard: org-chart pair cards"
</Button> >
<Button <LayoutGrid className="h-3.5 w-3.5" />
type="button" </Button>
variant={view === "list" ? "secondary" : "ghost"} <Button
size="sm" type="button"
className="h-7 px-2" variant={view === "list" ? "secondary" : "ghost"}
aria-pressed={view === "list"} size="sm"
onClick={() => setView("list")} className="h-7 px-2"
title="Classic conversation list" aria-pressed={view === "list"}
> aria-label="Classic conversation list"
<ListIcon className="h-3.5 w-3.5" /> onClick={() => setView("list")}
</Button> title="Classic conversation list"
</div> >
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div> </div>
<A2AFilterBar <A2AFilterBar
filters={filters} filters={filters}
@@ -9,6 +9,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -112,7 +113,12 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href="/journals" prefetch={false}> <Link href="/journals" prefetch={false}>
<Button variant="ghost" size="icon"> <Button
variant="ghost"
size="icon"
aria-label="Back to journals"
title="Back to journals"
>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
</Link> </Link>
@@ -149,11 +155,10 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
<User className="h-4 w-4" /> <User className="h-4 w-4" />
<span>Journal</span> <span>Journal</span>
</div> </div>
<p <p className="font-medium font-mono flex items-center gap-1">
className="font-medium font-mono flex items-center gap-1" <HelpTip label={entry.journal_id}>
title={entry.journal_id} <span>{entry.journal_id.slice(0, 8)}</span>
> </HelpTip>
{entry.journal_id.slice(0, 8)}
<CopyButton value={entry.journal_id} className="px-1 py-0.5" /> <CopyButton value={entry.journal_id} className="px-1 py-0.5" />
</p> </p>
</CardContent> </CardContent>
@@ -168,13 +173,14 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link href={`/tasks/${entry.task_id}`} prefetch={false}> <Link href={`/tasks/${entry.task_id}`} prefetch={false}>
<Badge <HelpTip label={entry.task_id}>
variant="outline" <Badge
className="hover:bg-muted cursor-pointer" variant="outline"
title={entry.task_id} className="hover:bg-muted cursor-pointer"
> >
Task #{entry.task_id.slice(0, 8)} Task #{entry.task_id.slice(0, 8)}
</Badge> </Badge>
</HelpTip>
</Link> </Link>
<CopyButton value={entry.task_id} className="px-1 py-0.5" /> <CopyButton value={entry.task_id} className="px-1 py-0.5" />
</div> </div>
+67 -35
View File
@@ -25,6 +25,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
ResponsiveTable, ResponsiveTable,
ResponsiveTableCardList, ResponsiveTableCardList,
@@ -157,10 +158,12 @@ function TeamHealthCard({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-2 mb-3"> <HelpTip label="100 minus 20 points per blocked task — a rough at-a-glance score, not a precise metric">
<Progress value={healthScore} className="flex-1" /> <div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">{healthScore}%</span> <Progress value={healthScore} className="flex-1" />
</div> <span className="text-sm font-medium">{healthScore}%</span>
</div>
</HelpTip>
<div className="grid grid-cols-3 gap-2 text-center text-xs"> <div className="grid grid-cols-3 gap-2 text-center text-xs">
<div> <div>
<div className="font-semibold text-blue-600">{activeTasks}</div> <div className="font-semibold text-blue-600">{activeTasks}</div>
@@ -338,26 +341,31 @@ function PerformanceTabContent() {
label: "Pending", label: "Pending",
value: pending, value: pending,
icon: <Clock className="h-3.5 w-3.5 text-gray-500" />, icon: <Clock className="h-3.5 w-3.5 text-gray-500" />,
tip: "Ready for work but not yet claimed by an agent",
}, },
{ {
label: "In Progress", label: "In Progress",
value: inProgress, value: inProgress,
icon: <Activity className="h-3.5 w-3.5 text-blue-500" />, icon: <Activity className="h-3.5 w-3.5 text-blue-500" />,
tip: "Claimed and actively being worked",
}, },
{ {
label: "Blocked", label: "Blocked",
value: blocked, value: blocked,
icon: <AlertTriangle className="h-3.5 w-3.5 text-red-500" />, icon: <AlertTriangle className="h-3.5 w-3.5 text-red-500" />,
tip: "Stuck on an external dependency, not making progress",
}, },
{ {
label: "Awaiting QA", label: "Awaiting QA",
value: awaitingQa, value: awaitingQa,
icon: <Timer className="h-3.5 w-3.5 text-yellow-500" />, icon: <Timer className="h-3.5 w-3.5 text-yellow-500" />,
tip: "Dev work done and PR open, waiting on QA review",
}, },
{ {
label: "Completed", label: "Completed",
value: completed, value: completed,
icon: <CheckCircle className="h-3.5 w-3.5 text-green-500" />, icon: <CheckCircle className="h-3.5 w-3.5 text-green-500" />,
tip: "Reached the terminal completed state and merged",
}, },
]} ]}
/> />
@@ -452,12 +460,16 @@ function TokenUsageCostsSection() {
<div className="space-y-6"> <div className="space-y-6">
{/* Time window selector — drives every period-scoped hook below */} {/* Time window selector — drives every period-scoped hook below */}
<div className="flex justify-end"> <div className="flex justify-end">
<SegmentedControl <HelpTip label="Time window — narrows every card, chart, and table below to this period">
options={TIME_WINDOW_OPTIONS} <div>
value={period} <SegmentedControl
onValueChange={(v) => setPeriod(v as UsagePeriod)} options={TIME_WINDOW_OPTIONS}
aria-label="Usage time window" value={period}
/> onValueChange={(v) => setPeriod(v as UsagePeriod)}
aria-label="Usage time window"
/>
</div>
</HelpTip>
</div> </div>
{/* Row 1 — Summary cards */} {/* Row 1 — Summary cards */}
@@ -467,18 +479,21 @@ function TokenUsageCostsSection() {
value={summary ? fmtTokens(summary.tokens_input) : undefined} value={summary ? fmtTokens(summary.tokens_input) : undefined}
icon={<Zap className="h-4 w-4 text-yellow-500" />} icon={<Zap className="h-4 w-4 text-yellow-500" />}
isLoading={loadingSnap} isLoading={loadingSnap}
tip="Prompt/context tokens sent to the model in the selected window"
/> />
<SummaryCard <SummaryCard
title="Tokens Output" title="Tokens Output"
value={summary ? fmtTokens(summary.tokens_output) : undefined} value={summary ? fmtTokens(summary.tokens_output) : undefined}
icon={<Zap className="h-4 w-4 text-blue-500" />} icon={<Zap className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap} isLoading={loadingSnap}
tip="Tokens generated by the model in response, in the selected window"
/> />
<SummaryCard <SummaryCard
title="Total Cost" title="Total Cost"
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined} value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
icon={<Coins className="h-4 w-4 text-green-500" />} icon={<Coins className="h-4 w-4 text-green-500" />}
isLoading={loadingSnap} isLoading={loadingSnap}
tip="Provider-priced cost for input + output tokens in the selected window"
/> />
<SummaryCard <SummaryCard
title="Trend vs Prior" title="Trend vs Prior"
@@ -495,12 +510,14 @@ function TokenUsageCostsSection() {
) )
} }
isLoading={loadingSnap} isLoading={loadingSnap}
tip="Change in total cost vs. the immediately preceding window of the same length"
/> />
<SummaryCard <SummaryCard
title="Total Tokens" title="Total Tokens"
value={summary ? fmtTokens(summary.total_tokens) : undefined} value={summary ? fmtTokens(summary.total_tokens) : undefined}
icon={<Activity className="h-4 w-4 text-blue-500" />} icon={<Activity className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap} isLoading={loadingSnap}
tip="Input + output tokens combined in the selected window"
/> />
<SummaryCard <SummaryCard
title="Cache Saved" title="Cache Saved"
@@ -511,6 +528,7 @@ function TokenUsageCostsSection() {
} }
icon={<Sparkles className="h-4 w-4 text-purple-500" />} icon={<Sparkles className="h-4 w-4 text-purple-500" />}
isLoading={loadingCache} isLoading={loadingCache}
tip="Cost avoided by serving cached tokens instead of a fresh model call"
/> />
</div> </div>
@@ -554,6 +572,7 @@ interface SummaryCardProps {
icon: React.ReactNode; icon: React.ReactNode;
trend?: { dir: "up" | "down"; label: string }; trend?: { dir: "up" | "down"; label: string };
isLoading: boolean; isLoading: boolean;
tip?: string;
} }
function SummaryCard({ function SummaryCard({
@@ -562,15 +581,18 @@ function SummaryCard({
icon, icon,
trend, trend,
isLoading, isLoading,
tip,
}: SummaryCardProps) { }: SummaryCardProps) {
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <HelpTip label={tip}>
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardHeader className="flex flex-row items-center justify-between pb-2">
{title} <CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle> {title}
{icon} </CardTitle>
</CardHeader> {icon}
</CardHeader>
</HelpTip>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-7 w-24" /> <Skeleton className="h-7 w-24" />
@@ -652,15 +674,17 @@ function CacheEfficiencyCard({
{isLoading ? ( {isLoading ? (
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
) : ( ) : (
<div> <HelpTip label="Share of tokens served from the prompt cache instead of a fresh model call">
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div> <div>
<p className="text-xs text-muted-foreground mt-1"> <div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache <p className="text-xs text-muted-foreground mt-1">
reads · saved $ {cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"} reads · saved $
</p> {cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
<Progress value={pct} className="mt-2" /> </p>
</div> <Progress value={pct} className="mt-2" />
</div>
</HelpTip>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -697,7 +721,11 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
<th className="pb-1 font-medium">Role</th> <th className="pb-1 font-medium">Role</th>
<th className="pb-1 font-medium text-right">Cost</th> <th className="pb-1 font-medium text-right">Cost</th>
<th className="pb-1 font-medium text-right">Cache hit</th> <th className="pb-1 font-medium text-right">Cache hit</th>
<th className="pb-1 font-medium text-right">%</th> <th className="pb-1 font-medium text-right">
<HelpTip label="Share of the org's total cost this window attributable to this role">
<span>%</span>
</HelpTip>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -754,10 +782,12 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"> <HelpTip label="An agent spawn that made no commit and left no trace of progress before exiting">
<AlertTriangle className="h-4 w-4 text-orange-500" /> <CardTitle className="text-base flex items-center gap-2">
Spawn Waste <AlertTriangle className="h-4 w-4 text-orange-500" />
</CardTitle> Spawn Waste
</CardTitle>
</HelpTip>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
@@ -813,11 +843,13 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
/> />
)} )}
{data.respawn_strikes.length > 0 && ( {data.respawn_strikes.length > 0 && (
<p className="text-xs text-muted-foreground"> <HelpTip label="Tasks the respawn-loop breaker has flagged for repeatedly re-spawning the same agent with no progress">
{data.respawn_strikes.length} wedged task <p className="text-xs text-muted-foreground">
{data.respawn_strikes.length === 1 ? "" : "s"} with open respawn {data.respawn_strikes.length} wedged task
strikes {data.respawn_strikes.length === 1 ? "" : "s"} with open
</p> respawn strikes
</p>
</HelpTip>
)} )}
</div> </div>
)} )}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ConnectionState } from "@/lib/websocket/connection"; import type { ConnectionState } from "@/lib/websocket/connection";
import { import {
A2AConnectionBadge, A2AConnectionBadge,
@@ -42,4 +43,11 @@ describe("A2AConnectionBanner", () => {
fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1); expect(onDismiss).toHaveBeenCalledTimes(1);
}); });
it("shows a matching visible tooltip on the dismiss button", async () => {
const user = userEvent.setup();
render(<A2AConnectionBanner state="disconnected" onDismiss={vi.fn()} />);
await user.hover(screen.getByRole("button", { name: "Dismiss" }));
expect(await screen.findByRole("tooltip")).toHaveTextContent("Dismiss");
});
}); });
@@ -1,8 +1,20 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AdminConversationSummary } from "@/lib/api/a2a"; import type { AdminConversationSummary } from "@/lib/api/a2a";
import { A2AConversationList } from "../a2a-conversation-list"; 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( function buildConversation(
overrides: Partial<AdminConversationSummary> = {}, overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary { ): AdminConversationSummary {
@@ -37,9 +49,10 @@ describe("A2AConversationList", () => {
// Participants via getAgentDisplayName ("{a} <-> {b}"). // Participants via getAgentDisplayName ("{a} <-> {b}").
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument(); expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument(); expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
// Both participants get an avatar, matching A2APairCard's PairAvatar. // Both participants get an avatar, matching A2APairCard's PairAvatar
expect(screen.getByTitle("Backend Dev 1")).toBeInTheDocument(); // (initials + a hover tooltip with the full name — see next test).
expect(screen.getByTitle("Backend QA")).toBeInTheDocument(); expect(screen.getByText("BD1")).toBeInTheDocument();
expect(screen.getByText("BQA")).toBeInTheDocument();
// Topic, preview, message count, relative timestamp. // Topic, preview, message count, relative timestamp.
expect(screen.getByText("QA handoff")).toBeInTheDocument(); expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect( 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(
<A2AConversationList
conversations={[buildConversation()]}
selectedId={null}
onSelect={vi.fn()}
isLoading={false}
pulses={{}}
/>,
);
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", () => { it("fires onSelect with the conversation id on row click", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
render( render(
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react"; import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AdminPairSummary } from "@/lib/api/a2a"; import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard, PairAvatar } from "../a2a-pair-card"; import { A2APairCard, PairAvatar } from "../a2a-pair-card";
@@ -53,11 +54,20 @@ describe("A2APairCard", () => {
it("colors each avatar by team, not a per-agent hue", () => { it("colors each avatar by team, not a per-agent hue", () => {
render(<PairAvatar slug="fe-dev-1" />); render(<PairAvatar slug="fe-dev-1" />);
expect(screen.getByTitle("Frontend Dev 1")).toHaveClass( expect(screen.getByText("FD1").parentElement).toHaveClass(
"border-violet-500/40", "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(<PairAvatar slug="fe-dev-1" />);
await user.hover(screen.getByText("FD1"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Frontend Dev 1",
);
});
it("marks the card as selected via aria-pressed", () => { it("marks the card as selected via aria-pressed", () => {
render( render(
<A2APairCard <A2APairCard
@@ -3,6 +3,7 @@
import { Loader2, WifiOff, X } from "lucide-react"; import { Loader2, WifiOff, X } from "lucide-react";
import type { ConnectionState } from "@/lib/websocket/connection"; import type { ConnectionState } from "@/lib/websocket/connection";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
import { connectionDotClasses, connectionStateLabel } from "./a2a-utils"; import { connectionDotClasses, connectionStateLabel } from "./a2a-utils";
/** Pane-header connection indicator: dot + label, plus a spinner/offline icon /** Pane-header connection indicator: dot + label, plus a spinner/offline icon
@@ -54,14 +55,17 @@ export function A2AConnectionBanner({
? "Disconnected — reconnecting automatically" ? "Disconnected — reconnecting automatically"
: "Reconnecting — messages may be out of date"} : "Reconnecting — messages may be out of date"}
</span> </span>
<button <HelpTip label="Dismiss">
type="button" <button
onClick={onDismiss} type="button"
aria-label="Dismiss" onClick={onDismiss}
className="shrink-0 opacity-70 hover:opacity-100" aria-label="Dismiss"
> title="Dismiss"
<X className="h-3 w-3" /> className="shrink-0 opacity-70 hover:opacity-100"
</button> >
<X className="h-3 w-3" />
</button>
</HelpTip>
</div> </div>
); );
} }
+13 -11
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
getAgentDisplayName, getAgentDisplayName,
getAgentInitials, getAgentInitials,
@@ -23,17 +24,18 @@ function IdentityCard({ slug }: { slug: string }) {
href={`/agents/${slug}`} href={`/agents/${slug}`}
className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors" className="flex items-center gap-2 rounded-lg border p-2 hover:bg-muted/50 transition-colors"
> >
<div <HelpTip label={getAgentDisplayName(slug)}>
className={cn( <div
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0", className={cn(
TEAM_COLOR_CLASSES[teamColor], "h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
)} TEAM_COLOR_CLASSES[teamColor],
title={getAgentDisplayName(slug)} )}
> >
<span className="text-[10px] font-bold tracking-tight"> <span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(slug)} {getAgentInitials(slug)}
</span> </span>
</div> </div>
</HelpTip>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate"> <div className="text-sm font-medium truncate">
{getAgentDisplayName(slug)} {getAgentDisplayName(slug)}
+18 -14
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
getAgentDisplayName, getAgentDisplayName,
getAgentInitials, getAgentInitials,
@@ -27,17 +28,18 @@ interface A2APairCardProps {
* identically across the switchboard and the classic list. */ * identically across the switchboard and the classic list. */
export function PairAvatar({ slug }: { slug: string }) { export function PairAvatar({ slug }: { slug: string }) {
return ( return (
<div <HelpTip label={getAgentDisplayName(slug)}>
className={cn( <div
"h-7 w-7 rounded-full border flex items-center justify-center shrink-0", className={cn(
TEAM_COLOR_CLASSES[getAgentTeamColor(slug)], "h-7 w-7 rounded-full border flex items-center justify-center shrink-0",
)} TEAM_COLOR_CLASSES[getAgentTeamColor(slug)],
title={getAgentDisplayName(slug)} )}
> >
<span className="text-[9px] font-bold tracking-tight"> <span className="text-[9px] font-bold tracking-tight">
{getAgentInitials(slug)} {getAgentInitials(slug)}
</span> </span>
</div> </div>
</HelpTip>
); );
} }
@@ -94,9 +96,11 @@ export function A2APairCard({
</div> </div>
</div> </div>
{hasHistory && ( {hasHistory && (
<Badge variant="secondary" className="text-[10px] shrink-0"> <HelpTip label="Total messages exchanged in this conversation">
{pair.message_count} <Badge variant="secondary" className="text-[10px] shrink-0">
</Badge> {pair.message_count}
</Badge>
</HelpTip>
)} )}
</div> </div>
</button> </button>
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AgentDefinition } from "@/lib/agent-definitions"; import type { AgentDefinition } from "@/lib/agent-definitions";
import type { AgentStatusResponse } from "@/types"; import type { AgentStatusResponse } from "@/types";
@@ -130,6 +131,28 @@ describe("AgentCard", () => {
).toBeInTheDocument(); ).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(<AgentCard agent={AGENT} agentStatus={statusOf()} />);
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(
<AgentCard agent={AGENT} agentStatus={statusOf({ state: "active" })} />,
);
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", () => { it("shows a compact one-line token/cost readout when usage data is present", () => {
render( render(
<AgentCard <AgentCard
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OrchestratorStatusCards } from "../orchestrator-status"; import { OrchestratorStatusCards } from "../orchestrator-status";
// CEO feedback: "Total Agents shows 0 even though the full 25-agent roster // CEO feedback: "Total Agents shows 0 even though the full 25-agent roster
@@ -90,4 +91,22 @@ describe("OrchestratorStatusCards", () => {
// Active isn't gated by rosterLoading — it already resolved. // Active isn't gated by rosterLoading — it already resolved.
expect(screen.getByTestId("stat-active")).toHaveTextContent("1"); expect(screen.getByTestId("stat-active")).toHaveTextContent("1");
}); });
it("explains what each stat cell counts via a hover tooltip", async () => {
const user = userEvent.setup();
render(
<OrchestratorStatusCards
status={{
total_agents: 5,
by_state: { active: 3 },
waiting_count: 1,
agents: [],
}}
isLoading={false}
rosterCount={25}
/>,
);
await user.hover(screen.getByTestId("stat-total-agents"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(/roster/i);
});
}); });
+13 -10
View File
@@ -79,16 +79,19 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
{agent.name || "Unknown Agent"} {agent.name || "Unknown Agent"}
</CardTitle> </CardTitle>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <HelpTip label="Agent actions">
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-6 w-6 shrink-0" size="icon"
aria-label="Agent actions" className="h-6 w-6 shrink-0"
> aria-label="Agent actions"
<MoreHorizontal className="h-3.5 w-3.5" /> title="Agent actions"
</Button> >
</DropdownMenuTrigger> <MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!isActive && ( {!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} /> <SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
@@ -2,6 +2,7 @@ import { OrchestratorStatus as OrchestratorStatusType } from "@/types";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { Server, Users, Clock, Activity } from "lucide-react"; import { Server, Users, Clock, Activity } from "lucide-react";
interface OrchestratorStatusCardsProps { interface OrchestratorStatusCardsProps {
@@ -39,64 +40,72 @@ export function OrchestratorStatusCards({
return ( return (
<Card className="py-0"> <Card className="py-0">
<CardContent className="grid grid-cols-1 divide-y divide-border sm:grid-cols-4 sm:divide-x sm:divide-y-0"> <CardContent className="grid grid-cols-1 divide-y divide-border sm:grid-cols-4 sm:divide-x sm:divide-y-0">
<div className="flex items-center justify-between gap-2 p-4"> <HelpTip label="Whether the orchestrator process itself is reachable — independent of how many agents are currently spawned">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center justify-between gap-2 p-4">
<Server className="h-4 w-4" /> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Orchestrator <Server className="h-4 w-4" />
Orchestrator
</div>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<Badge
data-testid="stat-orchestrator"
className={isRunning ? "bg-green-500" : "bg-red-500"}
>
{isRunning ? "Running" : "Stopped"}
</Badge>
)}
</div> </div>
{isLoading ? ( </HelpTip>
<Skeleton className="h-6 w-16" />
) : (
<Badge
data-testid="stat-orchestrator"
className={isRunning ? "bg-green-500" : "bg-red-500"}
>
{isRunning ? "Running" : "Stopped"}
</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2 p-4"> <HelpTip label="Full agent roster size — everyone the org could spawn, not just who's running now">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center justify-between gap-2 p-4">
<Users className="h-4 w-4" /> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Total Agents <Users className="h-4 w-4" />
Total Agents
</div>
{rosterLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-total-agents" className="text-xl font-bold">
{rosterCount}
</span>
)}
</div> </div>
{rosterLoading ? ( </HelpTip>
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-total-agents" className="text-xl font-bold">
{rosterCount}
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 p-4"> <HelpTip label="Agents currently spawned and actively working a task right now">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center justify-between gap-2 p-4">
<Activity className="h-4 w-4" /> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Active <Activity className="h-4 w-4" />
Active
</div>
{isLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-active" className="text-xl font-bold">
{activeCount}
</span>
)}
</div> </div>
{isLoading ? ( </HelpTip>
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-active" className="text-xl font-bold">
{activeCount}
</span>
)}
</div>
<div className="flex items-center justify-between gap-2 p-4"> <HelpTip label="Agents blocked and waiting on human input or an external resolution">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center justify-between gap-2 p-4">
<Clock className="h-4 w-4" /> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
Waiting <Clock className="h-4 w-4" />
Waiting
</div>
{isLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-waiting" className="text-xl font-bold">
{waitingCount}
</span>
)}
</div> </div>
{isLoading ? ( </HelpTip>
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-waiting" className="text-xl font-bold">
{waitingCount}
</span>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -85,7 +85,13 @@ export function AgentStreamViewer({
{streamChunks.length > 0 && ( {streamChunks.length > 0 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={clearMessages}> <Button
variant="ghost"
size="icon"
onClick={clearMessages}
aria-label="Clear stream output"
title="Clear stream output"
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { HelpTip } from "@/components/ui/help-tip";
import { ListChecks } from "lucide-react"; import { ListChecks } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -83,12 +84,16 @@ export function FindingsQueuePanel({
> >
{finding.severity} {finding.severity}
</Badge> </Badge>
<Badge variant="outline" className="text-xs"> <HelpTip label="Where this finding was raised — QA review, PR gate, PM review, or CEO approval">
{finding.origin} <Badge variant="outline" className="text-xs">
</Badge> {finding.origin}
<span className="text-xs text-muted-foreground"> </Badge>
round {finding.round} </HelpTip>
</span> <HelpTip label="Revision round this finding was raised in — round 1 is the first pass">
<span className="text-xs text-muted-foreground">
round {finding.round}
</span>
</HelpTip>
</div> </div>
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
{finding.actual ?? finding.expected ?? finding.criterion ?? "—"} {finding.actual ?? finding.expected ?? finding.criterion ?? "—"}
@@ -104,9 +109,11 @@ export function FindingsQueuePanel({
href={"/tasks/" + finding.task_id} href={"/tasks/" + finding.task_id}
prefetch={false} prefetch={false}
> >
<span className="text-primary hover:underline"> <HelpTip label="Short task ID — first 8 characters of the full task identifier">
Task #{finding.task_id.slice(0, 8)} <span className="text-primary hover:underline">
</span> Task #{finding.task_id.slice(0, 8)}
</span>
</HelpTip>
</Link> </Link>
</div> </div>
</div> </div>
@@ -78,9 +78,11 @@ export function FlaggedItem({
</span> </span>
{flag.related_task_id && ( {flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}> <Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
<span className="text-primary hover:underline"> <HelpTip label="Short task ID — first 8 characters of the full task identifier">
Task #{flag.related_task_id.slice(0, 8)} <span className="text-primary hover:underline">
</span> Task #{flag.related_task_id.slice(0, 8)}
</span>
</HelpTip>
</Link> </Link>
)} )}
</div> </div>
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
BarChart3, BarChart3,
CheckCircle, CheckCircle,
@@ -22,6 +23,7 @@ interface MetricDisplay {
icon: React.ReactNode; icon: React.ReactNode;
format: (value: number) => string; format: (value: number) => string;
isPercent?: boolean; isPercent?: boolean;
tip: string;
} }
const METRICS: MetricDisplay[] = [ const METRICS: MetricDisplay[] = [
@@ -30,6 +32,7 @@ const METRICS: MetricDisplay[] = [
label: "Tasks Completed (24h)", label: "Tasks Completed (24h)",
icon: <CheckCircle className="h-4 w-4 text-green-500" />, icon: <CheckCircle className="h-4 w-4 text-green-500" />,
format: (v) => String(v), format: (v) => String(v),
tip: "Tasks that reached completed in the last 24 hours",
}, },
{ {
key: "qa_pass_rate", key: "qa_pass_rate",
@@ -37,6 +40,7 @@ const METRICS: MetricDisplay[] = [
icon: <BarChart3 className="h-4 w-4 text-blue-500" />, icon: <BarChart3 className="h-4 w-4 text-blue-500" />,
format: (v) => `${Math.round(v * 100)}%`, format: (v) => `${Math.round(v * 100)}%`,
isPercent: true, isPercent: true,
tip: "Share of QA reviews that passed on the first attempt, no fail bounce",
}, },
{ {
key: "avg_completion_time", key: "avg_completion_time",
@@ -44,6 +48,7 @@ const METRICS: MetricDisplay[] = [
icon: <Clock className="h-4 w-4 text-purple-500" />, icon: <Clock className="h-4 w-4 text-purple-500" />,
format: (v) => format: (v) =>
`${(typeof v === "number" ? v : parseFloat(v) || 0).toFixed(1)}h`, `${(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", key: "documentation_rate",
@@ -51,18 +56,21 @@ const METRICS: MetricDisplay[] = [
icon: <FileText className="h-4 w-4 text-indigo-500" />, icon: <FileText className="h-4 w-4 text-indigo-500" />,
format: (v) => `${Math.round(v * 100)}%`, format: (v) => `${Math.round(v * 100)}%`,
isPercent: true, isPercent: true,
tip: "Share of completed tasks that passed through a documentation step",
}, },
{ {
key: "active_blockers", key: "active_blockers",
label: "Active Blockers", label: "Active Blockers",
icon: <AlertTriangle className="h-4 w-4 text-red-500" />, icon: <AlertTriangle className="h-4 w-4 text-red-500" />,
format: (v) => String(v), format: (v) => String(v),
tip: "Tasks currently in the blocked status right now",
}, },
{ {
key: "longest_block_hours", key: "longest_block_hours",
label: "Longest Block", label: "Longest Block",
icon: <Clock className="h-4 w-4 text-orange-500" />, icon: <Clock className="h-4 w-4 text-orange-500" />,
format: (v) => `${v}h`, 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]; const value = metrics?.[m.key];
return ( return (
<div key={m.key}> <div key={m.key}>
<div className="flex items-center justify-between text-sm mb-1"> <HelpTip label={m.tip}>
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center justify-between text-sm mb-1">
{m.icon} <div className="flex items-center gap-2 text-muted-foreground">
{m.label} {m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null ? m.format(value) : "-"}
</span>
</div> </div>
<span className="font-medium"> </HelpTip>
{value != null ? m.format(value) : "-"}
</span>
</div>
{m.isPercent && value != null && ( {m.isPercent && value != null && (
<Progress value={value * 100} className="h-1.5" /> <Progress value={value * 100} className="h-1.5" />
)} )}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { mockOrg } = vi.hoisted(() => ({ mockOrg: vi.fn() })); const { mockOrg } = vi.hoisted(() => ({ mockOrg: vi.fn() }));
@@ -44,6 +45,13 @@ describe("ScorecardOverviewPanel", () => {
expect(link).toHaveAttribute("href", "/metrics?tab=scorecards"); expect(link).toHaveAttribute("href", "/metrics?tab=scorecards");
}); });
it("explains a metric row's derivation via a hover tooltip", async () => {
const user = userEvent.setup();
render(<ScorecardOverviewPanel />);
await user.hover(screen.getByText("First-pass yield"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(/bounce/i);
});
it("shows a skeleton while loading", () => { it("shows a skeleton while loading", () => {
mockOrg.mockReturnValue({ data: undefined, isLoading: true }); mockOrg.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<ScorecardOverviewPanel />); const { container } = render(<ScorecardOverviewPanel />);
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
TrendingUp, TrendingUp,
CheckCircle, CheckCircle,
@@ -19,6 +20,7 @@ interface MetricItem {
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
format?: (value: number) => string; format?: (value: number) => string;
tip: string;
} }
// Keys must match DashboardService.get_key_metrics() — the shape /dashboard/ceo // Keys must match DashboardService.get_key_metrics() — the shape /dashboard/ceo
@@ -30,24 +32,28 @@ const METRIC_CONFIG: MetricItem[] = [
label: "Velocity (7d)", label: "Velocity (7d)",
icon: <TrendingUp className="h-4 w-4" />, icon: <TrendingUp className="h-4 w-4" />,
format: (v) => `${v} tasks`, format: (v) => `${v} tasks`,
tip: "Tasks completed in the last 7 days",
}, },
{ {
key: "completion_rate", key: "completion_rate",
label: "Completion Rate", label: "Completion Rate",
icon: <CheckCircle className="h-4 w-4" />, icon: <CheckCircle className="h-4 w-4" />,
format: (v) => `${Math.round(v * 100)}%`, format: (v) => `${Math.round(v * 100)}%`,
tip: "Share of tasks started in the last 7 days that reached completed",
}, },
{ {
key: "documentation_coverage", key: "documentation_coverage",
label: "Documentation Coverage", label: "Documentation Coverage",
icon: <BarChart3 className="h-4 w-4" />, icon: <BarChart3 className="h-4 w-4" />,
format: (v) => `${Math.round(v * 100)}%`, format: (v) => `${Math.round(v * 100)}%`,
tip: "Share of completed tasks that passed through a documentation step",
}, },
{ {
key: "active_blockers", key: "active_blockers",
label: "Active Blockers", label: "Active Blockers",
icon: <AlertTriangle className="h-4 w-4" />, icon: <AlertTriangle className="h-4 w-4" />,
format: (v) => `${v}`, 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 rawValue = metrics?.[m.key];
const value = typeof rawValue === "number" ? rawValue : null; const value = typeof rawValue === "number" ? rawValue : null;
return ( return (
<div key={m.key} className="flex items-center justify-between"> <HelpTip key={m.key} label={m.tip}>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-between">
{m.icon} <div className="flex items-center gap-2 text-sm text-muted-foreground">
{m.label} {m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null ? (m.format ? m.format(value) : value) : "-"}
</span>
</div> </div>
<span className="font-medium"> </HelpTip>
{value != null ? (m.format ? m.format(value) : value) : "-"}
</span>
</div>
); );
})} })}
</div> </div>
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { useOrgScorecard } from "@/hooks/use-observability"; import { useOrgScorecard } from "@/hooks/use-observability";
import { import {
Trophy, Trophy,
@@ -25,17 +26,20 @@ interface MetricRowProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: string;
tip: string;
} }
function MetricRow({ icon, label, value }: MetricRowProps) { function MetricRow({ icon, label, value, tip }: MetricRowProps) {
return ( return (
<div className="flex items-center justify-between py-1"> <HelpTip label={tip}>
<div className="text-muted-foreground flex items-center gap-2 text-sm"> <div className="flex items-center justify-between py-1">
{icon} <div className="text-muted-foreground flex items-center gap-2 text-sm">
{label} {icon}
{label}
</div>
<span className="text-sm font-semibold">{value}</span>
</div> </div>
<span className="text-sm font-semibold">{value}</span> </HelpTip>
</div>
); );
} }
@@ -81,26 +85,31 @@ export function ScorecardOverviewPanel() {
icon={<CheckCircle2 className="h-4 w-4" />} icon={<CheckCircle2 className="h-4 w-4" />}
label="Tasks completed (30d)" label="Tasks completed (30d)"
value={String(data.tasks_completed)} value={String(data.tasks_completed)}
tip="Tasks that reached completed org-wide in the last 30 days"
/> />
<MetricRow <MetricRow
icon={<Gauge className="h-4 w-4" />} icon={<Gauge className="h-4 w-4" />}
label="First-pass yield" label="First-pass yield"
value={pctOrNa(data.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"
/> />
<MetricRow <MetricRow
icon={<Gauge className="h-4 w-4 text-blue-500" />} icon={<Gauge className="h-4 w-4 text-blue-500" />}
label="Throughput / hr" label="Throughput / hr"
value={numOrNa(data.effort_throughput_per_hour)} value={numOrNa(data.effort_throughput_per_hour)}
tip="Tasks completed per hour of active (non-idle) agent runtime"
/> />
<MetricRow <MetricRow
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
label="Active effort" label="Active effort"
value={data.active_runtime_hours.toFixed(1) + "h"} value={data.active_runtime_hours.toFixed(1) + "h"}
tip="Total hours agents spent actively working — idle/waiting time excluded"
/> />
<MetricRow <MetricRow
icon={<Coins className="h-4 w-4" />} icon={<Coins className="h-4 w-4" />}
label="Cost" label="Cost"
value={"$" + data.cost_usd.toFixed(2)} value={"$" + data.cost_usd.toFixed(2)}
tip="Total provider-priced token cost across all sessions in the period"
/> />
</div> </div>
)} )}
@@ -3,6 +3,7 @@
import { TeamHealth } from "@/types"; import { TeamHealth } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { HealthIndicator } from "./health-indicator"; import { HealthIndicator } from "./health-indicator";
import { Users, AlertTriangle, TrendingUp } from "lucide-react"; import { Users, AlertTriangle, TrendingUp } from "lucide-react";
@@ -10,6 +11,12 @@ interface TeamHealthCardProps {
health: TeamHealth; health: TeamHealth;
} }
const HEALTH_STATUS_TIP: Record<TeamHealth["status"], string> = {
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) { export function TeamHealthCard({ health }: TeamHealthCardProps) {
const teamName = health.team.replace(/_/g, " "); const teamName = health.team.replace(/_/g, " ");
@@ -18,7 +25,11 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg capitalize">{teamName}</CardTitle> <CardTitle className="text-lg capitalize">{teamName}</CardTitle>
<HealthIndicator status={health.status} size="sm" /> <HelpTip label={HEALTH_STATUS_TIP[health.status]}>
<span>
<HealthIndicator status={health.status} size="sm" />
</span>
</HelpTip>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -56,17 +67,19 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) {
{/* Blocked Ratio */} {/* Blocked Ratio */}
{health.blocked_ratio > 0 && ( {health.blocked_ratio > 0 && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<Badge <HelpTip label="Share of this team's active tasks currently blocked">
variant={ <Badge
health.blocked_ratio > 0.3 variant={
? "destructive" health.blocked_ratio > 0.3
: health.blocked_ratio > 0.1 ? "destructive"
? "secondary" : health.blocked_ratio > 0.1
: "outline" ? "secondary"
} : "outline"
> }
{Math.round(health.blocked_ratio * 100)}% blocked >
</Badge> {Math.round(health.blocked_ratio * 100)}% blocked
</Badge>
</HelpTip>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { useUsageSummary } from "@/hooks/use-usage"; import { useUsageSummary } from "@/hooks/use-usage";
import { useUsageStore } from "@/store/usage-store"; import { useUsageStore } from "@/store/usage-store";
import type { ConnectionState } from "@/lib/websocket/connection"; import type { ConnectionState } from "@/lib/websocket/connection";
@@ -32,22 +33,25 @@ interface MetricRowProps {
label: string; label: string;
value: string; value: string;
sub?: React.ReactNode; sub?: React.ReactNode;
tip?: string;
} }
function MetricRow({ icon, label, value, sub }: MetricRowProps) { function MetricRow({ icon, label, value, sub, tip }: MetricRowProps) {
return ( return (
<div className="flex items-center justify-between py-1"> <HelpTip label={tip}>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center justify-between py-1">
{icon} <div className="flex items-center gap-2 text-sm text-muted-foreground">
{label} {icon}
{label}
</div>
<div className="flex items-center gap-1">
<span className="font-semibold text-sm transition-all duration-300 ease-in-out">
{value}
</span>
{sub}
</div>
</div> </div>
<div className="flex items-center gap-1"> </HelpTip>
<span className="font-semibold text-sm transition-all duration-300 ease-in-out">
{value}
</span>
{sub}
</div>
</div>
); );
} }
@@ -113,10 +117,12 @@ export function UsageOverviewPanel() {
<Coins className="h-5 w-5" /> <Coins className="h-5 w-5" />
Token Usage &amp; Cost Token Usage &amp; Cost
</CardTitle> </CardTitle>
<Badge className={badge.className}> <HelpTip label="Connection to the live /ws/system stream — Polling means it's down and figures refresh via periodic HTTP fetch instead">
{badge.icon} <Badge className={badge.className}>
{badge.label} {badge.icon}
</Badge> {badge.label}
</Badge>
</HelpTip>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -132,16 +138,19 @@ export function UsageOverviewPanel() {
icon={<Zap className="h-4 w-4" />} icon={<Zap className="h-4 w-4" />}
label="Tokens (input)" label="Tokens (input)"
value={tokensInput != null ? fmt(tokensInput) : "—"} value={tokensInput != null ? fmt(tokensInput) : "—"}
tip="Prompt/context tokens sent to the model across all agent sessions in this period"
/> />
<MetricRow <MetricRow
icon={<Zap className="h-4 w-4 text-muted-foreground" />} icon={<Zap className="h-4 w-4 text-muted-foreground" />}
label="Tokens (output)" label="Tokens (output)"
value={tokensOutput != null ? fmt(tokensOutput) : "—"} value={tokensOutput != null ? fmt(tokensOutput) : "—"}
tip="Tokens generated by the model in response, across all agent sessions in this period"
/> />
<MetricRow <MetricRow
icon={<Coins className="h-4 w-4" />} icon={<Coins className="h-4 w-4" />}
label="Total cost" label="Total cost"
value={totalCost != null ? fmtCost(totalCost) : "—"} value={totalCost != null ? fmtCost(totalCost) : "—"}
tip="Provider-priced cost for input + output tokens this period (local/Ollama sessions cost $0)"
/> />
<MetricRow <MetricRow
icon={ icon={
@@ -166,11 +175,13 @@ export function UsageOverviewPanel() {
</span> </span>
) : undefined ) : undefined
} }
tip="Change in total cost compared to the immediately preceding period of the same length"
/> />
<MetricRow <MetricRow
icon={<Activity className="h-4 w-4 text-blue-500" />} icon={<Activity className="h-4 w-4 text-blue-500" />}
label="Period" label="Period"
value={periodLabel ?? "—"} value={periodLabel ?? "—"}
tip="The rolling time window these figures are aggregated over"
/> />
</div> </div>
)} )}
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EntryCard } from "../entry-card"; import { EntryCard } from "../entry-card";
import { JournalEntryType, type JournalEntry } from "@/types"; 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(<EntryCard entry={baseEntry} />);
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", () => { it("omits the task row entirely when there is no related task", () => {
render(<EntryCard entry={{ ...baseEntry, task_id: null }} />); render(<EntryCard entry={{ ...baseEntry, task_id: null }} />);
expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument(); expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument();
+14 -9
View File
@@ -2,6 +2,7 @@
import { Agent } from "@/types"; import { Agent } from "@/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
import { getAgentDisplayName } from "@/lib/agent-utils"; import { getAgentDisplayName } from "@/lib/agent-utils";
interface AgentItemProps { interface AgentItemProps {
@@ -52,16 +53,20 @@ export function AgentItem({
)} )}
> >
<div className="relative shrink-0"> <div className="relative shrink-0">
<div <HelpTip label={name}>
className={cn( <div
"flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold transition-colors", className={cn(
isSelected ? "bg-primary/15 text-primary" : avatarTint, "flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold transition-colors",
)} isSelected ? "bg-primary/15 text-primary" : avatarTint,
> )}
{initialsFor(name)} >
</div> {initialsFor(name)}
</div>
</HelpTip>
{hasEntries && ( {hasEntries && (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-background" /> <HelpTip label="Has journal entries">
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-background" />
</HelpTip>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
+10 -8
View File
@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { CopyButton } from "@/components/ui/copy-button"; import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import { EntryTypeBadge } from "./entry-type-badge"; import { EntryTypeBadge } from "./entry-type-badge";
import { Clock, Tag, Link2, ChevronRight } from "lucide-react"; import { Clock, Tag, Link2, ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -86,14 +87,15 @@ export function EntryCard({ entry }: EntryCardProps) {
{entry.task_id && ( {entry.task_id && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link href={`/tasks/${entry.task_id}`} prefetch={false}> <Link href={`/tasks/${entry.task_id}`} prefetch={false}>
<Badge <HelpTip label={entry.task_id}>
variant="outline" <Badge
className="text-xs cursor-pointer hover:bg-muted" variant="outline"
title={entry.task_id} className="text-xs cursor-pointer hover:bg-muted"
> >
<Link2 className="h-3 w-3 mr-1" /> <Link2 className="h-3 w-3 mr-1" />
Task #{entry.task_id.slice(0, 8)} Task #{entry.task_id.slice(0, 8)}
</Badge> </Badge>
</HelpTip>
</Link> </Link>
<CopyButton value={entry.task_id} className="px-1 py-0.5" /> <CopyButton value={entry.task_id} className="px-1 py-0.5" />
</div> </div>
@@ -4,6 +4,7 @@ import { Journal, GrowthMetrics } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
BookOpen, BookOpen,
Lightbulb, Lightbulb,
@@ -104,30 +105,34 @@ export function GrowthSummary({
{/* Struggle Resolution Rate */} {/* Struggle Resolution Rate */}
{growth && growth.struggle_resolution_rate > 0 && ( {growth && growth.struggle_resolution_rate > 0 && (
<div className="pt-3 border-t"> <HelpTip label="Share of logged struggles that a later entry marked resolved">
<div className="flex items-center justify-between text-sm mb-2"> <div className="pt-3 border-t">
<span className="text-muted-foreground">Struggle Resolution</span> <div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium"> <span className="text-muted-foreground">Struggle Resolution</span>
{Math.round(growth.struggle_resolution_rate * 100)}% <span className="font-medium">
</span> {Math.round(growth.struggle_resolution_rate * 100)}%
</span>
</div>
<Progress
value={growth.struggle_resolution_rate * 100}
className="h-2"
/>
</div> </div>
<Progress </HelpTip>
value={growth.struggle_resolution_rate * 100}
className="h-2"
/>
</div>
)} )}
{/* Sentiment Trend */} {/* Sentiment Trend */}
{growth?.sentiment_trend && ( {growth?.sentiment_trend && (
<div className="pt-3 border-t"> <HelpTip label="Direction of the agent's self-reported sentiment across recent entries">
<div className="flex items-center justify-between text-sm"> <div className="pt-3 border-t">
<span className="text-muted-foreground">Sentiment Trend</span> <div className="flex items-center justify-between text-sm">
<span className="font-medium capitalize"> <span className="text-muted-foreground">Sentiment Trend</span>
{growth.sentiment_trend} <span className="font-medium capitalize">
</span> {growth.sentiment_trend}
</span>
</div>
</div> </div>
</div> </HelpTip>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentRole, AgentState, type Agent } from "@/types"; import { AgentRole, AgentState, type Agent } from "@/types";
import type { MemberScorecard } from "@/types"; import type { MemberScorecard } from "@/types";
@@ -139,6 +140,15 @@ describe("ScorecardsTabContent", () => {
expect(screen.queryByText("system")).not.toBeInTheDocument(); expect(screen.queryByText("system")).not.toBeInTheDocument();
}); });
it("explains an abbreviated member-table column via a hover tooltip", async () => {
const user = userEvent.setup();
render(<ScorecardsTabContent />);
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", () => { it("surfaces load errors instead of an endless skeleton", () => {
mockOrg.mockReturnValue({ mockOrg.mockReturnValue({
data: undefined, data: undefined,
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskStatusTiles } from "../task-status-tiles"; import { TaskStatusTiles } from "../task-status-tiles";
describe("TaskStatusTiles", () => { describe("TaskStatusTiles", () => {
@@ -34,4 +35,24 @@ describe("TaskStatusTiles", () => {
expect(screen.getByText(label)).toBeInTheDocument(); 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(
<TaskStatusTiles
tiles={[
{
label: "Blocked",
value: 2,
icon: <span>icon</span>,
tip: "Tasks stuck on an external dependency",
},
]}
/>,
);
await user.hover(screen.getByText("Blocked"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"external dependency",
);
});
}); });
@@ -13,6 +13,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
import { HelpTip } from "@/components/ui/help-tip";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import type { AgentUsageRow } from "@/types"; import type { AgentUsageRow } from "@/types";
@@ -70,7 +71,11 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
<tr> <tr>
<th className="text-left font-medium py-1">Agent</th> <th className="text-left font-medium py-1">Agent</th>
<th className="text-right font-medium py-1">Tokens</th> <th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th> <th className="text-right font-medium py-1">
<HelpTip label="Share of total tokens across all agents in this window">
<span>%</span>
</HelpTip>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+36 -16
View File
@@ -12,6 +12,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
ResponsiveTable, ResponsiveTable,
ResponsiveTableCardList, ResponsiveTableCardList,
@@ -121,25 +122,31 @@ function BottlenecksCard() {
) : ( ) : (
<> <>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Worst stage:</span> <HelpTip label="The status stage that has accumulated the most total time across all tasks in the window">
<span className="text-muted-foreground">Worst stage:</span>
</HelpTip>
{data?.worst_stage ? ( {data?.worst_stage ? (
<Badge variant="destructive">{label(data.worst_stage)}</Badge> <Badge variant="destructive">{label(data.worst_stage)}</Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
<span className="ml-auto text-muted-foreground"> <HelpTip label="Tasks currently in the blocked status right now, across all teams">
{data?.active_blockers ?? 0} active blockers <span className="ml-auto text-muted-foreground">
</span> {data?.active_blockers ?? 0} active blockers
</span>
</HelpTip>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{(data?.by_stage ?? []).slice(0, 6).map((s) => ( {(data?.by_stage ?? []).slice(0, 6).map((s) => (
<div key={s.status} className="space-y-1"> <div key={s.status} className="space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span>{label(s.status)}</span> <span>{label(s.status)}</span>
<span className="text-muted-foreground"> <HelpTip label="Cumulative time spent in this stage across all tasks · how many tasks are sitting in it right now">
{fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "} <span className="text-muted-foreground">
parked {fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "}
</span> parked
</span>
</HelpTip>
</div> </div>
<div className="h-2 w-full rounded bg-muted"> <div className="h-2 w-full rounded bg-muted">
<div <div
@@ -169,7 +176,9 @@ function ReworkCard() {
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base">Rework (30d)</CardTitle> <HelpTip label="A completed task 'bounces' when it's sent to needs_revision at least once — by QA, the PR gate, the PM, or the CEO">
<CardTitle className="text-base">Rework (30d)</CardTitle>
</HelpTip>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{isLoading ? ( {isLoading ? (
@@ -197,7 +206,11 @@ function ReworkCard() {
<thead className="text-muted-foreground"> <thead className="text-muted-foreground">
<tr className="text-left"> <tr className="text-left">
<th className="py-1 font-medium">Agent</th> <th className="py-1 font-medium">Agent</th>
<th className="py-1 font-medium text-right">Rate</th> <th className="py-1 font-medium text-right">
<HelpTip label="Share of this agent's completed tasks that bounced back for revision at least once">
<span>Rate</span>
</HelpTip>
</th>
<th className="py-1 font-medium text-right"> <th className="py-1 font-medium text-right">
QA fails QA fails
</th> </th>
@@ -287,11 +300,13 @@ function CellScorecard({ team }: { team: string }) {
} }
function ScorecardBody({ card }: { card: Scorecard | undefined }) { function ScorecardBody({ card }: { card: Scorecard | undefined }) {
const stat = (k: string, v: string) => ( const stat = (k: string, v: string, tip?: string) => (
<div className="flex justify-between text-sm"> <HelpTip label={tip}>
<span className="text-muted-foreground">{k}</span> <div className="flex justify-between text-sm">
<span className="font-medium">{v}</span> <span className="text-muted-foreground">{k}</span>
</div> <span className="font-medium">{v}</span>
</div>
</HelpTip>
); );
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -301,8 +316,13 @@ function ScorecardBody({ card }: { card: Scorecard | undefined }) {
card?.avg_cycle_hours != null card?.avg_cycle_hours != null
? card.avg_cycle_hours.toFixed(1) + "h" ? 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))} {stat("Cost", "$" + (card?.cost_usd ?? 0).toFixed(2))}
</div> </div>
); );
+92 -27
View File
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
Table, Table,
TableBody, TableBody,
@@ -85,21 +86,35 @@ function OrgSummary() {
</div> </div>
); );
if (isLoading || !data) return <Skeleton className="h-24 w-full" />; if (isLoading || !data) return <Skeleton className="h-24 w-full" />;
const cells: [string, string][] = [ const cells: [string, string, string?][] = [
["Members", String(data.member_count)], ["Members", String(data.member_count)],
["Completed", String(data.tasks_completed)], ["Completed", String(data.tasks_completed)],
["First-pass yield", pctOrNa(data.first_pass_yield)], [
["Throughput/hr", numOrNa(data.effort_throughput_per_hour, 2)], "First-pass yield",
["Active effort", data.active_runtime_hours.toFixed(1) + "h"], 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)], ["Cost", "$" + data.cost_usd.toFixed(2)],
]; ];
return ( return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{cells.map(([k, v]) => ( {cells.map(([k, v, tip]) => (
<div key={k}> <HelpTip key={k} label={tip}>
<div className="text-2xl font-semibold">{v}</div> <div>
<div className="text-muted-foreground text-sm">{k}</div> <div className="text-2xl font-semibold">{v}</div>
</div> <div className="text-muted-foreground text-sm">{k}</div>
</div>
</HelpTip>
))} ))}
</div> </div>
); );
@@ -114,21 +129,39 @@ function CeoCard() {
</div> </div>
); );
if (isLoading || !data) return <Skeleton className="h-24 w-full" />; if (isLoading || !data) return <Skeleton className="h-24 w-full" />;
const cells: [string, string][] = [ const cells: [string, string, string?][] = [
["Approvals", String(data.approval_count)], ["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)], ["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 ( return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{cells.map(([k, v]) => ( {cells.map(([k, v, tip]) => (
<div key={k}> <HelpTip key={k} label={tip}>
<div className="text-2xl font-semibold">{v}</div> <div>
<div className="text-muted-foreground text-sm">{k}</div> <div className="text-2xl font-semibold">{v}</div>
</div> <div className="text-muted-foreground text-sm">{k}</div>
</div>
</HelpTip>
))} ))}
</div> </div>
); );
@@ -168,14 +201,46 @@ export function ScorecardsTabContent() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Member</TableHead> <TableHead>Member</TableHead>
<TableHead>Done</TableHead> <TableHead>
<TableHead>FPY</TableHead> <HelpTip label="Tasks completed">
<TableHead>Effort</TableHead> <span>Done</span>
<TableHead>Turns/task</TableHead> </HelpTip>
<TableHead>QA pass</TableHead> </TableHead>
<TableHead>Escal.</TableHead> <TableHead>
<TableHead>Blocked others</TableHead> <HelpTip label="First-pass yield — share of completed tasks shipped without a QA/PR-gate/PM/CEO bounce">
<TableHead>Util.</TableHead> <span>FPY</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Total hours actively working — idle/waiting time excluded">
<span>Effort</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Average number of agent turns spent per completed task">
<span>Turns/task</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Share of this agent's QA reviews that passed on the first attempt">
<span>QA pass</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Escalations — times this agent's work was escalated up the chain">
<span>Escal.</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Times this agent's work blocked another agent's progress">
<span>Blocked others</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="Utilization — share of this agent's spawned time spent actively working, not idle">
<span>Util.</span>
</HelpTip>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -1,12 +1,15 @@
"use client"; "use client";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { HelpTip } from "@/components/ui/help-tip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface TaskStatusTileData { export interface TaskStatusTileData {
label: string; label: string;
value: number; value: number;
icon: React.ReactNode; icon: React.ReactNode;
/** What this status means — shown on hover. Omit for a self-explanatory label. */
tip?: string;
} }
interface TaskStatusTilesProps { interface TaskStatusTilesProps {
@@ -23,13 +26,15 @@ export function TaskStatusTiles({ tiles, className }: TaskStatusTilesProps) {
return ( return (
<div className={cn("grid grid-cols-2 sm:grid-cols-3 gap-2", className)}> <div className={cn("grid grid-cols-2 sm:grid-cols-3 gap-2", className)}>
{tiles.map((tile) => ( {tiles.map((tile) => (
<Card key={tile.label} className="gap-1 py-3"> <HelpTip key={tile.label} label={tile.tip}>
<div className="flex items-center gap-1.5 px-3 text-xs text-muted-foreground"> <Card className="gap-1 py-3">
{tile.icon} <div className="flex items-center gap-1.5 px-3 text-xs text-muted-foreground">
<span className="truncate">{tile.label}</span> {tile.icon}
</div> <span className="truncate">{tile.label}</span>
<div className="px-3 text-xl font-bold">{tile.value}</div> </div>
</Card> <div className="px-3 text-xl font-bold">{tile.value}</div>
</Card>
</HelpTip>
))} ))}
</div> </div>
); );
@@ -13,6 +13,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
import { HelpTip } from "@/components/ui/help-tip";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import type { TeamUsageRow } from "@/types"; import type { TeamUsageRow } from "@/types";
@@ -67,7 +68,11 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<tr> <tr>
<th className="text-left font-medium py-1">Team</th> <th className="text-left font-medium py-1">Team</th>
<th className="text-right font-medium py-1">Tokens</th> <th className="text-right font-medium py-1">Tokens</th>
<th className="text-right font-medium py-1">%</th> <th className="text-right font-medium py-1">
<HelpTip label="Share of total tokens across all teams in this window">
<span>%</span>
</HelpTip>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>