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 { 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). */}
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
onClick={toggleContext}
title={contextOpen ? "Hide context panel" : "Show context panel"}
<HelpTip
label={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>
<Button
type="button"
variant="ghost"
size="sm"
className="hidden h-7 px-2 xl:inline-flex"
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>
@@ -328,30 +336,34 @@ function A2APageContent() {
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
<HelpTip label="Switch between the org-chart switchboard and the classic conversation list">
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
aria-label="Switchboard: org-chart pair cards"
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
aria-label="Classic conversation list"
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</HelpTip>
</div>
<A2AFilterBar
filters={filters}
@@ -9,6 +9,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -112,7 +113,12 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
<Tooltip>
<TooltipTrigger asChild>
<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" />
</Button>
</Link>
@@ -149,11 +155,10 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
<User className="h-4 w-4" />
<span>Journal</span>
</div>
<p
className="font-medium font-mono flex items-center gap-1"
title={entry.journal_id}
>
{entry.journal_id.slice(0, 8)}
<p className="font-medium font-mono flex items-center gap-1">
<HelpTip label={entry.journal_id}>
<span>{entry.journal_id.slice(0, 8)}</span>
</HelpTip>
<CopyButton value={entry.journal_id} className="px-1 py-0.5" />
</p>
</CardContent>
@@ -168,13 +173,14 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
</div>
<div className="flex items-center gap-1">
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
<Badge
variant="outline"
className="hover:bg-muted cursor-pointer"
title={entry.task_id}
>
Task #{entry.task_id.slice(0, 8)}
</Badge>
<HelpTip label={entry.task_id}>
<Badge
variant="outline"
className="hover:bg-muted cursor-pointer"
>
Task #{entry.task_id.slice(0, 8)}
</Badge>
</HelpTip>
</Link>
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
</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 { 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({
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<Progress value={healthScore} className="flex-1" />
<span className="text-sm font-medium">{healthScore}%</span>
</div>
<HelpTip label="100 minus 20 points per blocked task — a rough at-a-glance score, not a precise metric">
<div className="flex items-center gap-2 mb-3">
<Progress value={healthScore} className="flex-1" />
<span className="text-sm font-medium">{healthScore}%</span>
</div>
</HelpTip>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<div className="font-semibold text-blue-600">{activeTasks}</div>
@@ -338,26 +341,31 @@ function PerformanceTabContent() {
label: "Pending",
value: pending,
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",
value: inProgress,
icon: <Activity className="h-3.5 w-3.5 text-blue-500" />,
tip: "Claimed and actively being worked",
},
{
label: "Blocked",
value: blocked,
icon: <AlertTriangle className="h-3.5 w-3.5 text-red-500" />,
tip: "Stuck on an external dependency, not making progress",
},
{
label: "Awaiting QA",
value: awaitingQa,
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",
value: completed,
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">
{/* Time window selector — drives every period-scoped hook below */}
<div className="flex justify-end">
<SegmentedControl
options={TIME_WINDOW_OPTIONS}
value={period}
onValueChange={(v) => setPeriod(v as UsagePeriod)}
aria-label="Usage time window"
/>
<HelpTip label="Time window — narrows every card, chart, and table below to this period">
<div>
<SegmentedControl
options={TIME_WINDOW_OPTIONS}
value={period}
onValueChange={(v) => setPeriod(v as UsagePeriod)}
aria-label="Usage time window"
/>
</div>
</HelpTip>
</div>
{/* Row 1 — Summary cards */}
@@ -467,18 +479,21 @@ function TokenUsageCostsSection() {
value={summary ? fmtTokens(summary.tokens_input) : undefined}
icon={<Zap className="h-4 w-4 text-yellow-500" />}
isLoading={loadingSnap}
tip="Prompt/context tokens sent to the model in the selected window"
/>
<SummaryCard
title="Tokens Output"
value={summary ? fmtTokens(summary.tokens_output) : undefined}
icon={<Zap className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
tip="Tokens generated by the model in response, in the selected window"
/>
<SummaryCard
title="Total Cost"
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
icon={<Coins className="h-4 w-4 text-green-500" />}
isLoading={loadingSnap}
tip="Provider-priced cost for input + output tokens in the selected window"
/>
<SummaryCard
title="Trend vs Prior"
@@ -495,12 +510,14 @@ function TokenUsageCostsSection() {
)
}
isLoading={loadingSnap}
tip="Change in total cost vs. the immediately preceding window of the same length"
/>
<SummaryCard
title="Total Tokens"
value={summary ? fmtTokens(summary.total_tokens) : undefined}
icon={<Activity className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
tip="Input + output tokens combined in the selected window"
/>
<SummaryCard
title="Cache Saved"
@@ -511,6 +528,7 @@ function TokenUsageCostsSection() {
}
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
isLoading={loadingCache}
tip="Cost avoided by serving cached tokens instead of a fresh model call"
/>
</div>
@@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
<HelpTip label={tip}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
</HelpTip>
<CardContent>
{isLoading ? (
<Skeleton className="h-7 w-24" />
@@ -652,15 +674,17 @@ function CacheEfficiencyCard({
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<div>
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
reads · saved $
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
</p>
<Progress value={pct} className="mt-2" />
</div>
<HelpTip label="Share of tokens served from the prompt cache instead of a fresh model call">
<div>
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache
reads · saved $
{cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
</p>
<Progress value={pct} className="mt-2" />
</div>
</HelpTip>
)}
</CardContent>
</Card>
@@ -697,7 +721,11 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
<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">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>
</thead>
<tbody>
@@ -754,10 +782,12 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
Spawn Waste
</CardTitle>
<HelpTip label="An agent spawn that made no commit and left no trace of progress before exiting">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
Spawn Waste
</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -813,11 +843,13 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
/>
)}
{data.respawn_strikes.length > 0 && (
<p className="text-xs text-muted-foreground">
{data.respawn_strikes.length} wedged task
{data.respawn_strikes.length === 1 ? "" : "s"} with open respawn
strikes
</p>
<HelpTip label="Tasks the respawn-loop breaker has flagged for repeatedly re-spawning the same agent with no progress">
<p className="text-xs text-muted-foreground">
{data.respawn_strikes.length} wedged task
{data.respawn_strikes.length === 1 ? "" : "s"} with open
respawn strikes
</p>
</HelpTip>
)}
</div>
)}
@@ -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(<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 { 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> = {},
): 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(
<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", () => {
const onSelect = vi.fn();
render(
@@ -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(<PairAvatar slug="fe-dev-1" />);
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(<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", () => {
render(
<A2APairCard
@@ -3,6 +3,7 @@
import { Loader2, WifiOff, X } from "lucide-react";
import type { ConnectionState } from "@/lib/websocket/connection";
import { cn } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
import { connectionDotClasses, connectionStateLabel } from "./a2a-utils";
/** Pane-header connection indicator: dot + label, plus a spinner/offline icon
@@ -54,14 +55,17 @@ export function A2AConnectionBanner({
? "Disconnected — reconnecting automatically"
: "Reconnecting — messages may be out of date"}
</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
<HelpTip label="Dismiss">
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
title="Dismiss"
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</HelpTip>
</div>
);
}
+13 -11
View File
@@ -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"
>
<div
className={cn(
"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">
{getAgentInitials(slug)}
</span>
</div>
<HelpTip label={getAgentDisplayName(slug)}>
<div
className={cn(
"h-9 w-9 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[teamColor],
)}
>
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(slug)}
</span>
</div>
</HelpTip>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{getAgentDisplayName(slug)}
+18 -14
View File
@@ -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 (
<div
className={cn(
"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">
{getAgentInitials(slug)}
</span>
</div>
<HelpTip label={getAgentDisplayName(slug)}>
<div
className={cn(
"h-7 w-7 rounded-full border flex items-center justify-center shrink-0",
TEAM_COLOR_CLASSES[getAgentTeamColor(slug)],
)}
>
<span className="text-[9px] font-bold tracking-tight">
{getAgentInitials(slug)}
</span>
</div>
</HelpTip>
);
}
@@ -94,9 +96,11 @@ export function A2APairCard({
</div>
</div>
{hasHistory && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{pair.message_count}
</Badge>
<HelpTip label="Total messages exchanged in this conversation">
<Badge variant="secondary" className="text-[10px] shrink-0">
{pair.message_count}
</Badge>
</HelpTip>
)}
</div>
</button>
@@ -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(<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", () => {
render(
<AgentCard
@@ -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 { OrchestratorStatusCards } from "../orchestrator-status";
// 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.
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"}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<HelpTip label="Agent actions">
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
aria-label="Agent actions"
title="Agent actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="end">
{!isActive && (
<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 { 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 (
<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">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Server className="h-4 w-4" />
Orchestrator
<HelpTip label="Whether the orchestrator process itself is reachable — independent of how many agents are currently spawned">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<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>
{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>
</HelpTip>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Users className="h-4 w-4" />
Total Agents
<HelpTip label="Full agent roster size — everyone the org could spawn, not just who's running now">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<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>
{rosterLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-total-agents" className="text-xl font-bold">
{rosterCount}
</span>
)}
</div>
</HelpTip>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Activity className="h-4 w-4" />
Active
<HelpTip label="Agents currently spawned and actively working a task right now">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<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>
{isLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-active" className="text-xl font-bold">
{activeCount}
</span>
)}
</div>
</HelpTip>
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
Waiting
<HelpTip label="Agents blocked and waiting on human input or an external resolution">
<div className="flex items-center justify-between gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<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>
{isLoading ? (
<Skeleton className="h-6 w-8" />
) : (
<span data-testid="stat-waiting" className="text-xl font-bold">
{waitingCount}
</span>
)}
</div>
</HelpTip>
</CardContent>
</Card>
);
@@ -85,7 +85,13 @@ export function AgentStreamViewer({
{streamChunks.length > 0 && (
<Tooltip>
<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" />
</Button>
</TooltipTrigger>
@@ -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}
</Badge>
<Badge variant="outline" className="text-xs">
{finding.origin}
</Badge>
<span className="text-xs text-muted-foreground">
round {finding.round}
</span>
<HelpTip label="Where this finding was raised — QA review, PR gate, PM review, or CEO approval">
<Badge variant="outline" className="text-xs">
{finding.origin}
</Badge>
</HelpTip>
<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>
<p className="text-sm text-muted-foreground mb-2">
{finding.actual ?? finding.expected ?? finding.criterion ?? "—"}
@@ -104,9 +109,11 @@ export function FindingsQueuePanel({
href={"/tasks/" + finding.task_id}
prefetch={false}
>
<span className="text-primary hover:underline">
Task #{finding.task_id.slice(0, 8)}
</span>
<HelpTip label="Short task ID — first 8 characters of the full task identifier">
<span className="text-primary hover:underline">
Task #{finding.task_id.slice(0, 8)}
</span>
</HelpTip>
</Link>
</div>
</div>
@@ -78,9 +78,11 @@ export function FlaggedItem({
</span>
{flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
<span className="text-primary hover:underline">
Task #{flag.related_task_id.slice(0, 8)}
</span>
<HelpTip label="Short task ID — first 8 characters of the full task identifier">
<span className="text-primary hover:underline">
Task #{flag.related_task_id.slice(0, 8)}
</span>
</HelpTip>
</Link>
)}
</div>
@@ -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: <CheckCircle className="h-4 w-4 text-green-500" />,
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: <BarChart3 className="h-4 w-4 text-blue-500" />,
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: <Clock className="h-4 w-4 text-purple-500" />,
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: <FileText className="h-4 w-4 text-indigo-500" />,
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: <AlertTriangle className="h-4 w-4 text-red-500" />,
format: (v) => String(v),
tip: "Tasks currently in the blocked status right now",
},
{
key: "longest_block_hours",
label: "Longest Block",
icon: <Clock className="h-4 w-4 text-orange-500" />,
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 (
<div key={m.key}>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 text-muted-foreground">
{m.icon}
{m.label}
<HelpTip label={m.tip}>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 text-muted-foreground">
{m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null ? m.format(value) : "-"}
</span>
</div>
<span className="font-medium">
{value != null ? m.format(value) : "-"}
</span>
</div>
</HelpTip>
{m.isPercent && value != null && (
<Progress value={value * 100} className="h-1.5" />
)}
@@ -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(<ScorecardOverviewPanel />);
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(<ScorecardOverviewPanel />);
@@ -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: <TrendingUp className="h-4 w-4" />,
format: (v) => `${v} tasks`,
tip: "Tasks completed in the last 7 days",
},
{
key: "completion_rate",
label: "Completion Rate",
icon: <CheckCircle className="h-4 w-4" />,
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: <BarChart3 className="h-4 w-4" />,
format: (v) => `${Math.round(v * 100)}%`,
tip: "Share of completed tasks that passed through a documentation step",
},
{
key: "active_blockers",
label: "Active Blockers",
icon: <AlertTriangle className="h-4 w-4" />,
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 (
<div key={m.key} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{m.icon}
{m.label}
<HelpTip key={m.key} label={m.tip}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null ? (m.format ? m.format(value) : value) : "-"}
</span>
</div>
<span className="font-medium">
{value != null ? (m.format ? m.format(value) : value) : "-"}
</span>
</div>
</HelpTip>
);
})}
</div>
@@ -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 (
<div className="flex items-center justify-between py-1">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
{icon}
{label}
<HelpTip label={tip}>
<div className="flex items-center justify-between py-1">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
{icon}
{label}
</div>
<span className="text-sm font-semibold">{value}</span>
</div>
<span className="text-sm font-semibold">{value}</span>
</div>
</HelpTip>
);
}
@@ -81,26 +85,31 @@ export function ScorecardOverviewPanel() {
icon={<CheckCircle2 className="h-4 w-4" />}
label="Tasks completed (30d)"
value={String(data.tasks_completed)}
tip="Tasks that reached completed org-wide in the last 30 days"
/>
<MetricRow
icon={<Gauge className="h-4 w-4" />}
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"
/>
<MetricRow
icon={<Gauge className="h-4 w-4 text-blue-500" />}
label="Throughput / hr"
value={numOrNa(data.effort_throughput_per_hour)}
tip="Tasks completed per hour of active (non-idle) agent runtime"
/>
<MetricRow
icon={<Clock className="h-4 w-4" />}
label="Active effort"
value={data.active_runtime_hours.toFixed(1) + "h"}
tip="Total hours agents spent actively working — idle/waiting time excluded"
/>
<MetricRow
icon={<Coins className="h-4 w-4" />}
label="Cost"
value={"$" + data.cost_usd.toFixed(2)}
tip="Total provider-priced token cost across all sessions in the period"
/>
</div>
)}
@@ -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<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) {
const teamName = health.team.replace(/_/g, " ");
@@ -18,7 +25,11 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) {
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<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>
</CardHeader>
<CardContent className="space-y-3">
@@ -56,17 +67,19 @@ export function TeamHealthCard({ health }: TeamHealthCardProps) {
{/* Blocked Ratio */}
{health.blocked_ratio > 0 && (
<div className="pt-2 border-t">
<Badge
variant={
health.blocked_ratio > 0.3
? "destructive"
: health.blocked_ratio > 0.1
? "secondary"
: "outline"
}
>
{Math.round(health.blocked_ratio * 100)}% blocked
</Badge>
<HelpTip label="Share of this team's active tasks currently blocked">
<Badge
variant={
health.blocked_ratio > 0.3
? "destructive"
: health.blocked_ratio > 0.1
? "secondary"
: "outline"
}
>
{Math.round(health.blocked_ratio * 100)}% blocked
</Badge>
</HelpTip>
</div>
)}
</CardContent>
@@ -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 (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
<HelpTip label={tip}>
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{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 className="flex items-center gap-1">
<span className="font-semibold text-sm transition-all duration-300 ease-in-out">
{value}
</span>
{sub}
</div>
</div>
</HelpTip>
);
}
@@ -113,10 +117,12 @@ export function UsageOverviewPanel() {
<Coins className="h-5 w-5" />
Token Usage &amp; Cost
</CardTitle>
<Badge className={badge.className}>
{badge.icon}
{badge.label}
</Badge>
<HelpTip label="Connection to the live /ws/system stream — Polling means it's down and figures refresh via periodic HTTP fetch instead">
<Badge className={badge.className}>
{badge.icon}
{badge.label}
</Badge>
</HelpTip>
</div>
</CardHeader>
<CardContent>
@@ -132,16 +138,19 @@ export function UsageOverviewPanel() {
icon={<Zap className="h-4 w-4" />}
label="Tokens (input)"
value={tokensInput != null ? fmt(tokensInput) : "—"}
tip="Prompt/context tokens sent to the model across all agent sessions in this period"
/>
<MetricRow
icon={<Zap className="h-4 w-4 text-muted-foreground" />}
label="Tokens (output)"
value={tokensOutput != null ? fmt(tokensOutput) : "—"}
tip="Tokens generated by the model in response, across all agent sessions in this period"
/>
<MetricRow
icon={<Coins className="h-4 w-4" />}
label="Total cost"
value={totalCost != null ? fmtCost(totalCost) : "—"}
tip="Provider-priced cost for input + output tokens this period (local/Ollama sessions cost $0)"
/>
<MetricRow
icon={
@@ -166,11 +175,13 @@ export function UsageOverviewPanel() {
</span>
) : undefined
}
tip="Change in total cost compared to the immediately preceding period of the same length"
/>
<MetricRow
icon={<Activity className="h-4 w-4 text-blue-500" />}
label="Period"
value={periodLabel ?? "—"}
tip="The rolling time window these figures are aggregated over"
/>
</div>
)}
@@ -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(<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", () => {
render(<EntryCard entry={{ ...baseEntry, task_id: null }} />);
expect(screen.queryByText(/^Task #/)).not.toBeInTheDocument();
+14 -9
View File
@@ -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({
)}
>
<div className="relative shrink-0">
<div
className={cn(
"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>
<HelpTip label={name}>
<div
className={cn(
"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>
</HelpTip>
{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 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 { 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 && (
<div className="flex items-center gap-1">
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
<Badge
variant="outline"
className="text-xs cursor-pointer hover:bg-muted"
title={entry.task_id}
>
<Link2 className="h-3 w-3 mr-1" />
Task #{entry.task_id.slice(0, 8)}
</Badge>
<HelpTip label={entry.task_id}>
<Badge
variant="outline"
className="text-xs cursor-pointer hover:bg-muted"
>
<Link2 className="h-3 w-3 mr-1" />
Task #{entry.task_id.slice(0, 8)}
</Badge>
</HelpTip>
</Link>
<CopyButton value={entry.task_id} className="px-1 py-0.5" />
</div>
@@ -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 && (
<div className="pt-3 border-t">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Struggle Resolution</span>
<span className="font-medium">
{Math.round(growth.struggle_resolution_rate * 100)}%
</span>
<HelpTip label="Share of logged struggles that a later entry marked resolved">
<div className="pt-3 border-t">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Struggle Resolution</span>
<span className="font-medium">
{Math.round(growth.struggle_resolution_rate * 100)}%
</span>
</div>
<Progress
value={growth.struggle_resolution_rate * 100}
className="h-2"
/>
</div>
<Progress
value={growth.struggle_resolution_rate * 100}
className="h-2"
/>
</div>
</HelpTip>
)}
{/* Sentiment Trend */}
{growth?.sentiment_trend && (
<div className="pt-3 border-t">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Sentiment Trend</span>
<span className="font-medium capitalize">
{growth.sentiment_trend}
</span>
<HelpTip label="Direction of the agent's self-reported sentiment across recent entries">
<div className="pt-3 border-t">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Sentiment Trend</span>
<span className="font-medium capitalize">
{growth.sentiment_trend}
</span>
</div>
</div>
</div>
</HelpTip>
)}
</CardContent>
</Card>
@@ -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(<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", () => {
mockOrg.mockReturnValue({
data: undefined,
@@ -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(
<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 { 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) {
<tr>
<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">%</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>
</thead>
<tbody>
+36 -16
View File
@@ -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() {
) : (
<>
<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 ? (
<Badge variant="destructive">{label(data.worst_stage)}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
<span className="ml-auto text-muted-foreground">
{data?.active_blockers ?? 0} active blockers
</span>
<HelpTip label="Tasks currently in the blocked status right now, across all teams">
<span className="ml-auto text-muted-foreground">
{data?.active_blockers ?? 0} active blockers
</span>
</HelpTip>
</div>
<div className="space-y-2">
{(data?.by_stage ?? []).slice(0, 6).map((s) => (
<div key={s.status} className="space-y-1">
<div className="flex justify-between text-xs">
<span>{label(s.status)}</span>
<span className="text-muted-foreground">
{fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "}
parked
</span>
<HelpTip label="Cumulative time spent in this stage across all tasks · how many tasks are sitting in it right now">
<span className="text-muted-foreground">
{fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "}
parked
</span>
</HelpTip>
</div>
<div className="h-2 w-full rounded bg-muted">
<div
@@ -169,7 +176,9 @@ function ReworkCard() {
return (
<Card>
<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>
<CardContent className="space-y-4">
{isLoading ? (
@@ -197,7 +206,11 @@ function ReworkCard() {
<thead className="text-muted-foreground">
<tr className="text-left">
<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">
QA fails
</th>
@@ -287,11 +300,13 @@ function CellScorecard({ team }: { team: string }) {
}
function ScorecardBody({ card }: { card: Scorecard | undefined }) {
const stat = (k: string, v: string) => (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium">{v}</span>
</div>
const stat = (k: string, v: string, tip?: string) => (
<HelpTip label={tip}>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium">{v}</span>
</div>
</HelpTip>
);
return (
<div className="space-y-1">
@@ -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))}
</div>
);
+92 -27
View File
@@ -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() {
</div>
);
if (isLoading || !data) return <Skeleton className="h-24 w-full" />;
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 (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{cells.map(([k, v]) => (
<div key={k}>
<div className="text-2xl font-semibold">{v}</div>
<div className="text-muted-foreground text-sm">{k}</div>
</div>
{cells.map(([k, v, tip]) => (
<HelpTip key={k} label={tip}>
<div>
<div className="text-2xl font-semibold">{v}</div>
<div className="text-muted-foreground text-sm">{k}</div>
</div>
</HelpTip>
))}
</div>
);
@@ -114,21 +129,39 @@ function CeoCard() {
</div>
);
if (isLoading || !data) return <Skeleton className="h-24 w-full" />;
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 (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{cells.map(([k, v]) => (
<div key={k}>
<div className="text-2xl font-semibold">{v}</div>
<div className="text-muted-foreground text-sm">{k}</div>
</div>
{cells.map(([k, v, tip]) => (
<HelpTip key={k} label={tip}>
<div>
<div className="text-2xl font-semibold">{v}</div>
<div className="text-muted-foreground text-sm">{k}</div>
</div>
</HelpTip>
))}
</div>
);
@@ -168,14 +201,46 @@ export function ScorecardsTabContent() {
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Done</TableHead>
<TableHead>FPY</TableHead>
<TableHead>Effort</TableHead>
<TableHead>Turns/task</TableHead>
<TableHead>QA pass</TableHead>
<TableHead>Escal.</TableHead>
<TableHead>Blocked others</TableHead>
<TableHead>Util.</TableHead>
<TableHead>
<HelpTip label="Tasks completed">
<span>Done</span>
</HelpTip>
</TableHead>
<TableHead>
<HelpTip label="First-pass yield — share of completed tasks shipped without a QA/PR-gate/PM/CEO bounce">
<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>
</TableHeader>
<TableBody>
@@ -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 (
<div className={cn("grid grid-cols-2 sm:grid-cols-3 gap-2", className)}>
{tiles.map((tile) => (
<Card key={tile.label} className="gap-1 py-3">
<div className="flex items-center gap-1.5 px-3 text-xs text-muted-foreground">
{tile.icon}
<span className="truncate">{tile.label}</span>
</div>
<div className="px-3 text-xl font-bold">{tile.value}</div>
</Card>
<HelpTip key={tile.label} label={tile.tip}>
<Card className="gap-1 py-3">
<div className="flex items-center gap-1.5 px-3 text-xs text-muted-foreground">
{tile.icon}
<span className="truncate">{tile.label}</span>
</div>
<div className="px-3 text-xl font-bold">{tile.value}</div>
</Card>
</HelpTip>
))}
</div>
);
@@ -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) {
<tr>
<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">%</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>
</thead>
<tbody>