mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user