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:
@@ -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