W9-5: Tooltip sweep — HelpTip helper + per-view decode (#533)

* [W9-5a] Tooltip sweep foundation: HelpTip helper + shared cryptic badges

HelpTip: DRY wrapper over the verbose 3-element Radix Tooltip pattern so a
broad sweep stays a one-line wrap per site (falsy label short-circuits to the
bare child). Unit-tested (3 cases).

TaskStatusBadge + AgentStateBadge: the panel's most cryptic, most-frequent
elements (15 task lifecycle states, 11 agent states) had no explanation
anywhere. Add a per-state tooltip via HelpTip, with the canonical text in one
description map and exported as taskStatusDescription / agentStateDescription
so the per-view inline renderers (kanban, task header) reuse it instead of
re-declaring. This is part 1 of the W9-5 tooltip sweep; the per-view inline
surfaces follow in subsequent PRs.

* [W9-5b] Per-view tooltip sweep: decode cryptic badges, icon-only buttons, status dots

35 HelpTip additions across 22 panel components, reusing the W9-5a
helper plus taskStatusDescription/agentStateDescription. Tipped:
task-id/commit-hash/branch/PR badges, severity/origin/status badges,
priority (P0-P3), migration/shared flags, MegaTask umbrella badge,
Review Gate / For Resumption / Confidential note badges, icon-only
view/delete/edit/clear/show-hide buttons, semver bump + gate-state
badges, ahead-not-pushed badge. Skipped self-explanatory labeled
buttons and elements already carrying title=.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:34:17 +02:00
committed by GitHub
co-authored by Renn F
parent f07e2420a8
commit b7f2d84c77
26 changed files with 438 additions and 207 deletions
+6 -3
View File
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { A2AChatMessage } from "@/lib/api/a2a"; import type { A2AChatMessage } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { AlertTriangle, MessagesSquare } from "lucide-react"; import { AlertTriangle, MessagesSquare } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface A2ATranscriptProps { interface A2ATranscriptProps {
messages: A2AChatMessage[]; messages: A2AChatMessage[];
@@ -227,9 +228,11 @@ export function A2ATranscript({
{getAgentDisplayName(message.from_agent)} {getAgentDisplayName(message.from_agent)}
</span> </span>
{message.message_kind && ( {message.message_kind && (
<Badge variant="outline" className="text-[10px]"> <HelpTip label="Type of agent-to-agent message">
{message.message_kind} <Badge variant="outline" className="text-[10px]">
</Badge> {message.message_kind}
</Badge>
</HelpTip>
)} )}
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.created_at))} ago {formatDistanceToNow(new Date(message.created_at))} ago
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
Clock, Clock,
RefreshCw, RefreshCw,
@@ -53,12 +54,32 @@ const stateIcons: Record<string, React.ReactNode> = {
terminated: <Square className="h-4 w-4" />, terminated: <Square className="h-4 w-4" />,
}; };
const stateDescriptions: Record<string, string> = {
active: "Agent is actively working a task.",
running: "Agent is running — currently executing.",
ready: "Agent is ready and waiting for work.",
starting: "Agent container is starting up.",
idle: "Agent is idle — no task currently claimed.",
waiting_long: "Agent has been waiting a long time; may need attention.",
paused: "Agent is paused; can resume.",
stopped: "Agent container is stopped; can be restarted.",
terminated: "Agent container has been terminated.",
offline: "Agent is offline.",
error: "Agent hit an error state; needs attention.",
};
interface AgentStateBadgeProps { interface AgentStateBadgeProps {
state: AgentStateString | string; state: AgentStateString | string;
showIcon?: boolean; showIcon?: boolean;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
} }
/** Plain-language explanation for an agent state. Reused by the shared badge
* and by inline agent-state renderers. Empty for an unknown state. */
export function agentStateDescription(state: string): string {
return stateDescriptions[state] ?? "";
}
export function AgentStateBadge({ export function AgentStateBadge({
state, state,
showIcon = true, showIcon = true,
@@ -74,10 +95,12 @@ export function AgentStateBadge({
const icon = stateIcons[state] || <Square className="h-4 w-4" />; const icon = stateIcons[state] || <Square className="h-4 w-4" />;
return ( return (
<Badge className={`${color} text-white ${sizeClasses[size]}`}> <HelpTip label={stateDescriptions[state]}>
{showIcon && <span className="mr-1">{icon}</span>} <Badge className={`${color} text-white ${sizeClasses[size]}`}>
{state.replace(/_/g, " ")} {showIcon && <span className="mr-1">{icon}</span>}
</Badge> {state.replace(/_/g, " ")}
</Badge>
</HelpTip>
); );
} }
@@ -3,6 +3,7 @@
import { AuditorFlag, FlagSeverity } from "@/types"; import { AuditorFlag, FlagSeverity } from "@/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Eye, CheckCircle, Send, Clock } from "lucide-react"; import { Eye, CheckCircle, Send, Clock } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -89,9 +90,11 @@ export function FlaggedItem({
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{flag.related_task_id && ( {flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}> <Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
<Button variant="ghost" size="sm"> <HelpTip label="View related task">
<Eye className="h-4 w-4" /> <Button variant="ghost" size="sm">
</Button> <Eye className="h-4 w-4" />
</Button>
</HelpTip>
</Link> </Link>
)} )}
<Button <Button
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { FileText, Send, Eye, Clock, Plus } from "lucide-react"; import { FileText, Send, Eye, Clock, Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -122,9 +123,11 @@ export function ReportsPanel({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm"> <HelpTip label="View report">
<Eye className="h-4 w-4" /> <Button variant="ghost" size="sm">
</Button> <Eye className="h-4 w-4" />
</Button>
</HelpTip>
{isDraft && ( {isDraft && (
<Button <Button
variant="outline" variant="outline"
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Check, Loader2, Send, X } from "lucide-react"; import { Check, Loader2, Send, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { HelpTip } from "@/components/ui/help-tip";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
@@ -271,15 +272,17 @@ export function SecretaryTab() {
}} }}
/> />
{sessionId ? ( {sessionId ? (
<Button <HelpTip label="Send message">
onClick={() => void handleSend()} <Button
disabled={!input.trim() || streaming} onClick={() => void handleSend()}
size="icon" disabled={!input.trim() || streaming}
className="h-11 w-11 shrink-0" size="icon"
aria-label="Send message" className="h-11 w-11 shrink-0"
> aria-label="Send message"
<Send className="h-4 w-4" /> >
</Button> <Send className="h-4 w-4" />
</Button>
</HelpTip>
) : ( ) : (
<Button <Button
onClick={() => void handleStart()} onClick={() => void handleStart()}
@@ -21,6 +21,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -395,11 +396,19 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</code>{" "} </code>{" "}
<span className="text-muted-foreground">{finding.message}</span> <span className="text-muted-foreground">{finding.message}</span>
</div> </div>
<Badge <HelpTip
variant={finding.level === "block" ? "destructive" : "secondary"} label={
finding.level === "block"
? "Block-level: refuses the gate"
: "Warning: advisory only"
}
> >
{finding.rule} <Badge
</Badge> variant={finding.level === "block" ? "destructive" : "secondary"}
>
{finding.rule}
</Badge>
</HelpTip>
</div> </div>
))} ))}
</CardContent> </CardContent>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AlertTriangle, Clock, ArrowRight } from "lucide-react"; import { AlertTriangle, Clock, ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { HelpTip } from "@/components/ui/help-tip";
interface ActiveBlockersPanelProps { interface ActiveBlockersPanelProps {
tasks: Task[] | undefined; tasks: Task[] | undefined;
@@ -71,9 +72,11 @@ export function ActiveBlockersPanel({
<span className="text-lg">\uD83D\uDD34</span> <span className="text-lg">\uD83D\uDD34</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate"> <HelpTip label="Short task ID — first 8 characters of the full task identifier">
Task #{task.id.slice(0, 8)} <span className="font-medium text-sm truncate">
</span> Task #{task.id.slice(0, 8)}
</span>
</HelpTip>
<Badge variant="outline" className="text-xs capitalize"> <Badge variant="outline" className="text-xs capitalize">
{task.team.replace(/_/g, " ")} {task.team.replace(/_/g, " ")}
</Badge> </Badge>
@@ -34,6 +34,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { TaskStatus, Team, type Task } from "@/types"; import { TaskStatus, Team, type Task } from "@/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface CeoApprovalQueueProps { interface CeoApprovalQueueProps {
className?: string; className?: string;
@@ -171,18 +172,24 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
{ {
label: string; label: string;
variant: "default" | "secondary" | "destructive" | "outline"; variant: "default" | "secondary" | "destructive" | "outline";
desc: string;
} }
> = { > = {
0: { label: "P0", variant: "destructive" }, 0: { label: "P0", variant: "destructive", desc: "Critical priority — blocks all other work" },
1: { label: "P1", variant: "destructive" }, 1: { label: "P1", variant: "destructive", desc: "High priority — should be done soon" },
2: { label: "P2", variant: "secondary" }, 2: { label: "P2", variant: "secondary", desc: "Medium priority — normal scheduling" },
3: { label: "P3", variant: "outline" }, 3: { label: "P3", variant: "outline", desc: "Low priority — can wait" },
}; };
const { label, variant } = variants[priority] || { const { label, variant, desc } = variants[priority] || {
label: `P${priority}`, label: `P${priority}`,
variant: "outline" as const, variant: "outline" as const,
desc: `Priority level ${priority}`,
}; };
return <Badge variant={variant}>{label}</Badge>; return (
<HelpTip label={desc}>
<Badge variant={variant}>{label}</Badge>
</HelpTip>
);
}; };
if (isLoading) { if (isLoading) {
@@ -237,9 +244,11 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
clipping against it (mirrors the dialog footer's flex-col sm:flex-row). */} clipping against it (mirrors the dialog footer's flex-col sm:flex-row). */}
<div className="flex flex-wrap items-center gap-2 sm:ml-4 sm:shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:ml-4 sm:shrink-0">
<Link href={`/tasks/${task.id}`} prefetch={false}> <Link href={`/tasks/${task.id}`} prefetch={false}>
<Button variant="ghost" size="sm"> <HelpTip label="View task details">
<FileText className="h-4 w-4" /> <Button variant="ghost" size="sm">
</Button> <FileText className="h-4 w-4" />
</Button>
</HelpTip>
</Link> </Link>
<Button <Button
variant="outline" variant="outline"
@@ -26,6 +26,7 @@ import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react"; import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { usePageRefresh } from "@/hooks"; import { usePageRefresh } from "@/hooks";
import { HelpTip } from "@/components/ui/help-tip";
const _MIN_REJECT_CHARS = 10; const _MIN_REJECT_CHARS = 10;
@@ -166,10 +167,14 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
<Rocket className="h-5 w-5" /> <Rocket className="h-5 w-5" />
Release Proposal Release Proposal
<Badge variant="outline">v{report.proposed_version}</Badge> <Badge variant="outline">v{report.proposed_version}</Badge>
<Badge variant="secondary">{report.bump_kind}</Badge> <HelpTip label="Semver bump type — how the version number increases (major, minor, or patch)">
<Badge variant={gateBadgeVariant(report.gate_state)}> <Badge variant="secondary">{report.bump_kind}</Badge>
gate: {report.gate_state} </HelpTip>
</Badge> <HelpTip label="Quality gate status — green means all checks pass, red means failures must be fixed before release">
<Badge variant={gateBadgeVariant(report.gate_state)}>
gate: {report.gate_state}
</Badge>
</HelpTip>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{report.change_summary.length} change(s) since the last tag · review {report.change_summary.length} change(s) since the last tag · review
@@ -25,6 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CheckCircle2, Map, XCircle } from "lucide-react"; import { CheckCircle2, Map, XCircle } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
const _MIN_REASON_CHARS = 4; const _MIN_REASON_CHARS = 4;
@@ -68,8 +69,12 @@ function RoadmapItemRow({
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium">{item.title}</span> <span className="font-medium">{item.title}</span>
<Badge variant="outline">{item.team}</Badge> <Badge variant="outline">{item.team}</Badge>
<Badge variant="outline">{item.project_slug}</Badge> <HelpTip label="The project (repository) this roadmap item targets">
<Badge variant="secondary">P{item.priority}</Badge> <Badge variant="outline">{item.project_slug}</Badge>
</HelpTip>
<HelpTip label={`Priority P${item.priority}${item.priority === 0 ? "critical" : item.priority === 1 ? "high" : item.priority === 2 ? "medium" : "low"}`}>
<Badge variant="secondary">P{item.priority}</Badge>
</HelpTip>
{itemStatusBadge(item)} {itemStatusBadge(item)}
</div> </div>
<p className="text-sm text-muted-foreground">{item.description}</p> <p className="text-sm text-muted-foreground">{item.description}</p>
@@ -37,6 +37,7 @@ import {
RefreshCcw, RefreshCcw,
GitGraph, GitGraph,
} from "lucide-react"; } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitActionsPanelProps { interface GitActionsPanelProps {
status: GitStatusResponse | undefined; status: GitStatusResponse | undefined;
@@ -228,10 +229,12 @@ export function GitActionsPanel({
)} )}
Push to Remote Push to Remote
{status?.ahead !== undefined && status.ahead > 0 && ( {status?.ahead !== undefined && status.ahead > 0 && (
<Badge variant="secondary" className="ml-auto"> <HelpTip label="Commits not yet pushed to the remote repository">
<ArrowUp className="h-3 w-3 mr-1" /> <Badge variant="secondary" className="ml-auto">
{status.ahead} <ArrowUp className="h-3 w-3 mr-1" />
</Badge> {status.ahead}
</Badge>
</HelpTip>
)} )}
</Button> </Button>
@@ -23,6 +23,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react"; import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitBranchPanelProps { interface GitBranchPanelProps {
branches: GitBranchListResponse | undefined; branches: GitBranchListResponse | undefined;
@@ -183,9 +184,11 @@ export function GitBranchPanel({
</span> </span>
</div> </div>
{branch.last_commit && ( {branch.last_commit && (
<span className="text-xs text-muted-foreground font-mono shrink-0"> <HelpTip label="Short commit hash on this branch">
{branch.last_commit.slice(0, 7)} <span className="text-xs text-muted-foreground font-mono shrink-0">
</span> {branch.last_commit.slice(0, 7)}
</span>
</HelpTip>
)} )}
</Button> </Button>
))} ))}
+9 -6
View File
@@ -9,6 +9,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { GitCommit, User, Calendar } from "lucide-react"; import { GitCommit, User, Calendar } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { HelpTip } from "@/components/ui/help-tip";
interface GitLogPanelProps { interface GitLogPanelProps {
log: GitLogResponse | undefined; log: GitLogResponse | undefined;
@@ -105,12 +106,14 @@ export function GitLogPanel({
<p className="text-sm font-medium leading-snug line-clamp-2"> <p className="text-sm font-medium leading-snug line-clamp-2">
{commit.message} {commit.message}
</p> </p>
<Badge <HelpTip label="Short commit hash">
variant="outline" <Badge
className="font-mono text-xs shrink-0" variant="outline"
> className="font-mono text-xs shrink-0"
{commit.short_hash} >
</Badge> {commit.short_hash}
</Badge>
</HelpTip>
</div> </div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground"> <div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, X, Loader2 } from "lucide-react"; import { Search, X, Loader2 } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface KBSearchBarProps { interface KBSearchBarProps {
value: string; value: string;
@@ -64,15 +65,17 @@ export function KBSearchBar({
className="pl-9 pr-9" className="pl-9 pr-9"
/> />
{localValue && ( {localValue && (
<Button <HelpTip label="Clear search">
variant="ghost" <Button
size="icon-sm" variant="ghost"
onClick={handleClear} size="icon-sm"
aria-label="Clear search" onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" aria-label="Clear search"
> className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
<X className="h-4 w-4" /> >
</Button> <X className="h-4 w-4" />
</Button>
</HelpTip>
)} )}
</div> </div>
{onSearch && ( {onSearch && (
@@ -46,6 +46,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state"; import { OfflineState } from "@/components/ui/offline-state";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { getErrorMessage } from "@/lib/api/client"; import { getErrorMessage } from "@/lib/api/client";
@@ -593,29 +594,33 @@ function KnowledgeBaseBrowserContent() {
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <HelpTip label="Refresh this index">
size="sm" <Button
variant="outline" size="sm"
onClick={() => variant="outline"
handleRefreshIndex(indexType) onClick={() =>
} handleRefreshIndex(indexType)
disabled={refreshIndex.isPending} }
> disabled={refreshIndex.isPending}
{refreshIndex.isPending ? ( >
<RefreshCw className="h-3 w-3 animate-spin" /> {refreshIndex.isPending ? (
) : ( <RefreshCw className="h-3 w-3 animate-spin" />
<RefreshCw className="h-3 w-3" /> ) : (
)} <RefreshCw className="h-3 w-3" />
</Button> )}
</Button>
</HelpTip>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <HelpTip label="Delete this index and all its documents">
size="sm" <Button
variant="outline" size="sm"
className="text-red-600" variant="outline"
> className="text-red-600"
<Trash2 className="h-3 w-3" /> >
</Button> <Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -17,6 +17,7 @@ import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
import type { ProjectSummary } from "@/types"; import type { ProjectSummary } from "@/types";
import type { CellWork, DraftProposal } from "@/lib/api/prompter"; import type { CellWork, DraftProposal } from "@/lib/api/prompter";
import { Team } from "@/types"; import { Team } from "@/types";
import { HelpTip } from "@/components/ui/help-tip";
/** The delivery cells a multi-cell draft fans out to (one the_work entry each). */ /** The delivery cells a multi-cell draft fans out to (one the_work entry each). */
const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI]; const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI];
@@ -158,16 +159,20 @@ export function BatchReviewCard({
</span> </span>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{draft.adds_migration && ( {draft.adds_migration && (
<Badge variant="outline" className="gap-1 text-xs"> <HelpTip label="This task adds a database migration that must be applied in order">
<Database className="h-3 w-3" /> <Badge variant="outline" className="gap-1 text-xs">
migration <Database className="h-3 w-3" />
</Badge> migration
</Badge>
</HelpTip>
)} )}
{draft.touches_shared && ( {draft.touches_shared && (
<Badge variant="outline" className="gap-1 text-xs"> <HelpTip label="This task modifies shared code that other tasks may also touch">
<Share2 className="h-3 w-3" /> <Badge variant="outline" className="gap-1 text-xs">
shared <Share2 className="h-3 w-3" />
</Badge> shared
</Badge>
</HelpTip>
)} )}
</div> </div>
</div> </div>
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
@@ -194,20 +195,22 @@ export function SelfHostedSection({
} }
className="pr-10" className="pr-10"
/> />
<Button <HelpTip label={showToken ? "Hide token" : "Show token"}>
type="button" <Button
variant="ghost" type="button"
size="icon-sm" variant="ghost"
onClick={() => setShowToken((v) => !v)} size="icon-sm"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" onClick={() => setShowToken((v) => !v)}
aria-label={showToken ? "Hide token" : "Show token"} className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> aria-label={showToken ? "Hide token" : "Show token"}
{showToken ? ( >
<EyeOff className="h-4 w-4" /> {showToken ? (
) : ( <EyeOff className="h-4 w-4" />
<Eye className="h-4 w-4" /> ) : (
)} <Eye className="h-4 w-4" />
</Button> )}
</Button>
</HelpTip>
</div> </div>
<Button onClick={handleSave} disabled={saveConfig.isPending}> <Button onClick={handleSave} disabled={saveConfig.isPending}>
{saveConfig.isPending ? "Saving…" : "Save"} {saveConfig.isPending ? "Saving…" : "Save"}
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, Edit3, Check, X } from "lucide-react"; import { Plus, Trash2, Edit3, Check, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
import { CollapsibleSection } from "./collapsible-section"; import { CollapsibleSection } from "./collapsible-section";
interface AcceptanceCriteriaProps { interface AcceptanceCriteriaProps {
@@ -257,22 +258,26 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
> >
{text} {text}
</span> </span>
<Button <HelpTip label="Edit this criterion">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => startEditing(idx)} variant="ghost"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => startEditing(idx)}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
<Edit3 className="h-3 w-3" /> >
</Button> <Edit3 className="h-3 w-3" />
<Button </Button>
size="sm" </HelpTip>
variant="ghost" <HelpTip label="Delete this criterion">
onClick={() => handleDeleteCriterion(idx)} <Button
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive" size="sm"
> variant="ghost"
<Trash2 className="h-3 w-3" /> onClick={() => handleDeleteCriterion(idx)}
</Button> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</> </>
)} )}
</li> </li>
@@ -9,6 +9,8 @@ import { ListTree, ExternalLink, Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getAgentDisplayName } from "@/lib/agent-utils"; import { getAgentDisplayName } from "@/lib/agent-utils";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
interface SubtasksListProps { interface SubtasksListProps {
task: Task; task: Task;
@@ -128,12 +130,14 @@ export function SubtasksList({ task }: SubtasksListProps) {
> >
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Badge <HelpTip label={taskStatusDescription(subtask.status)}>
variant="secondary" <Badge
className={`text-xs shrink-0 ${STATUS_COLORS[subtask.status]}`} variant="secondary"
> className={`text-xs shrink-0 ${STATUS_COLORS[subtask.status]}`}
{subtask.status.replace(/_/g, " ")} >
</Badge> {subtask.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
<span className="text-sm truncate">{subtask.title}</span> <span className="text-sm truncate">{subtask.title}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils"; import { getAgentDisplayName } from "@/lib/agent-utils";
import { HelpTip } from "@/components/ui/help-tip";
interface TabCommitsProps { interface TabCommitsProps {
task: Task; task: Task;
@@ -122,24 +123,28 @@ export function TabCommits({ task }: TabCommitsProps) {
</CardTitle> </CardTitle>
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{task.branch_name && ( {task.branch_name && (
<Badge <HelpTip label="Git branch this task is being worked on">
variant="outline" <Badge
className="gap-1.5 py-1 px-2.5 font-mono text-sm" variant="outline"
> className="gap-1.5 py-1 px-2.5 font-mono text-sm"
<GitBranch className="h-4 w-4" /> >
{task.branch_name} <GitBranch className="h-4 w-4" />
</Badge> {task.branch_name}
</Badge>
</HelpTip>
)} )}
{task.pr_url && ( {task.pr_url && (
<a href={task.pr_url} target="_blank" rel="noopener noreferrer"> <HelpTip label="Pull Request — click to open on GitHub">
<Badge <a href={task.pr_url} target="_blank" rel="noopener noreferrer">
variant="secondary" <Badge
className="gap-1.5 py-1 px-2.5 hover:bg-secondary/80" variant="secondary"
> className="gap-1.5 py-1 px-2.5 hover:bg-secondary/80"
PR #{task.pr_number} >
<ExternalLink className="h-3.5 w-3.5" /> PR #{task.pr_number}
</Badge> <ExternalLink className="h-3.5 w-3.5" />
</a> </Badge>
</a>
</HelpTip>
)} )}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -243,12 +248,14 @@ export function TabCommits({ task }: TabCommitsProps) {
{/* Meta info */} {/* Meta info */}
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* Hash */} {/* Hash */}
<Badge <HelpTip label="Short git commit hash (first 7 characters)">
variant="outline" <Badge
className="font-mono text-xs" variant="outline"
> className="font-mono text-xs"
{commit.hash.slice(0, 7)} >
</Badge> {commit.hash.slice(0, 7)}
</Badge>
</HelpTip>
{/* Author */} {/* Author */}
{commit.author_agent_id && ( {commit.author_agent_id && (
@@ -266,14 +273,16 @@ export function TabCommits({ task }: TabCommitsProps) {
</div> </div>
</div> </div>
</div> </div>
<Button <HelpTip label="Unlink this commit from the task">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => handleDelete(commit.hash)} variant="ghost"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive" onClick={() => handleDelete(commit.hash)}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
<Trash2 className="h-3 w-3" /> >
</Button> <Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface TabDependenciesProps { interface TabDependenciesProps {
task: Task; task: Task;
@@ -155,27 +156,31 @@ function DependencyList({
className={`flex items-center gap-2 p-3 rounded-lg ${itemBorderClass} ${itemBgClass} transition-colors`} className={`flex items-center gap-2 p-3 rounded-lg ${itemBorderClass} ${itemBgClass} transition-colors`}
> >
<Link2 className="h-4 w-4 text-muted-foreground shrink-0" /> <Link2 className="h-4 w-4 text-muted-foreground shrink-0" />
<Link <HelpTip label="Task ID (first 8 characters) — click to open">
href={`/tasks/${depId}`} <Link
className="flex-1" href={`/tasks/${depId}`}
prefetch={false} className="flex-1"
> prefetch={false}
<span className="font-mono text-sm hover:underline"> >
{depId.slice(0, 8)}... <span className="font-mono text-sm hover:underline">
</span> {depId.slice(0, 8)}...
</Link> </span>
</Link>
</HelpTip>
<Badge variant="outline" className={badgeClass}> <Badge variant="outline" className={badgeClass}>
{badgeLabel} {badgeLabel}
</Badge> </Badge>
<Button <HelpTip label="Remove this dependency">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => handleRemove(depId)} variant="ghost"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive" onClick={() => handleRemove(depId)}
disabled={updateTask.isPending} className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
> disabled={updateTask.isPending}
<Trash2 className="h-3 w-3" /> >
</Button> <Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div> </div>
</li> </li>
))} ))}
@@ -24,6 +24,7 @@ import {
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface TabNotesProps { interface TabNotesProps {
task: Task; task: Task;
@@ -47,9 +48,11 @@ function prReviewBadge(task: Task): React.ReactNode {
)?.pr_review?.verdict; )?.pr_review?.verdict;
if (!verdict) { if (!verdict) {
return ( return (
<Badge variant="outline" className="ml-2 text-teal-600 border-teal-300"> <HelpTip label="PR review has not been submitted yet">
Review Gate <Badge variant="outline" className="ml-2 text-teal-600 border-teal-300">
</Badge> Review Gate
</Badge>
</HelpTip>
); );
} }
const map: Record<string, { label: string; cls: string }> = { const map: Record<string, { label: string; cls: string }> = {
@@ -345,9 +348,11 @@ export function TabNotes({ task }: TabNotesProps) {
title="Quick Context" title="Quick Context"
icon={<FileText className="h-5 w-5" />} icon={<FileText className="h-5 w-5" />}
badge={ badge={
<Badge variant="outline" className="ml-2"> <HelpTip label="Short context notes for quickly resuming work after an interruption">
For Resumption <Badge variant="outline" className="ml-2">
</Badge> For Resumption
</Badge>
</HelpTip>
} }
bgClass="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" bgClass="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
/> />
@@ -420,12 +425,14 @@ export function TabNotes({ task }: TabNotesProps) {
title="Auditor Notes" title="Auditor Notes"
icon={<Shield className="h-5 w-5" />} icon={<Shield className="h-5 w-5" />}
badge={ badge={
<Badge <HelpTip label="Visible only to the Auditor and CEO">
variant="outline" <Badge
className="ml-2 text-purple-600 border-purple-300" variant="outline"
> className="ml-2 text-purple-600 border-purple-300"
Confidential >
</Badge> Confidential
</Badge>
</HelpTip>
} }
bgClass="bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800" bgClass="bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800"
/> />
@@ -1,5 +1,6 @@
import { TaskStatus } from "@/types"; import { TaskStatus } from "@/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
const statusColors: Record<TaskStatus, string> = { const statusColors: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]: "bg-slate-500", [TaskStatus.BACKLOG]: "bg-slate-500",
@@ -19,14 +20,44 @@ const statusColors: Record<TaskStatus, string> = {
[TaskStatus.CANCELLED]: "bg-gray-400", [TaskStatus.CANCELLED]: "bg-gray-400",
}; };
const statusDescriptions: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]: "PM setup phase — dependencies or session not ready yet.",
[TaskStatus.PENDING]: "Ready for work — the orchestrator can spawn an agent.",
[TaskStatus.CLAIMED]: "An agent has locked this task.",
[TaskStatus.IN_PROGRESS]: "Active development in progress.",
[TaskStatus.BLOCKED]: "An external dependency is blocking progress.",
[TaskStatus.PAUSED]: "Temporarily stopped; can resume.",
[TaskStatus.VERIFYING]: "The developer is self-verifying their work.",
[TaskStatus.NEEDS_REVISION]:
"QA / PR-review / PM / CEO requested changes — back with the dev.",
[TaskStatus.AWAITING_QA]: "Submitted for QA review (PR already open).",
[TaskStatus.AWAITING_DOCUMENTATION]:
"Documentation phase — the documenter is writing docs.",
[TaskStatus.AWAITING_PR_REVIEW]:
"PR-review gate: a reviewer checks the assembled PR before the PM merges.",
[TaskStatus.AWAITING_PM_REVIEW]: "Docs complete; the PM is reviewing and merging.",
[TaskStatus.AWAITING_CEO_APPROVAL]: "Escalated for the CEO's final approval.",
[TaskStatus.COMPLETED]: "Terminal — work done and merged.",
[TaskStatus.CANCELLED]: "Terminal — work cancelled.",
};
interface TaskStatusBadgeProps { interface TaskStatusBadgeProps {
status: TaskStatus; status: TaskStatus;
} }
/** Plain-language explanation for a task lifecycle state. Reused by the
* shared badge and by inline status renderers (kanban, task header) so the
* canonical text lives in one place. Empty for an unknown status. */
export function taskStatusDescription(status: TaskStatus): string {
return statusDescriptions[status] ?? "";
}
export function TaskStatusBadge({ status }: TaskStatusBadgeProps) { export function TaskStatusBadge({ status }: TaskStatusBadgeProps) {
return ( return (
<Badge className={`${statusColors[status] ?? "bg-slate-600"} text-white`}> <HelpTip label={statusDescriptions[status]}>
{status.replace(/_/g, " ")} <Badge className={`${statusColors[status] ?? "bg-slate-600"} text-white`}>
</Badge> {status.replace(/_/g, " ")}
</Badge>
</HelpTip>
); );
} }
+17 -12
View File
@@ -30,6 +30,7 @@ import {
ResponsiveTableCardEmpty, ResponsiveTableCardEmpty,
} from "@/components/ui/responsive-table"; } from "@/components/ui/responsive-table";
import { TaskStatusBadge } from "./task-status-badge"; import { TaskStatusBadge } from "./task-status-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskActions } from "./task-actions"; import { TaskActions } from "./task-actions";
import { GitStatusBadge } from "./git-status-badge"; import { GitStatusBadge } from "./git-status-badge";
import Link from "next/link"; import Link from "next/link";
@@ -614,12 +615,14 @@ export function TaskTable({
{task.title} {task.title}
</span> </span>
{task.batch_id && !task.parent_task_id && ( {task.batch_id && !task.parent_task_id && (
<Badge <HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
variant="outline" <Badge
className="text-xs shrink-0 border-primary/50 text-primary" variant="outline"
> className="text-xs shrink-0 border-primary/50 text-primary"
MegaTask >
</Badge> MegaTask
</Badge>
</HelpTip>
)} )}
{childCount > 0 && ( {childCount > 0 && (
<Badge <Badge
@@ -744,12 +747,14 @@ export function TaskTable({
childCount > 0 ? ( childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && ( {task.batch_id && !task.parent_task_id && (
<Badge <HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
variant="outline" <Badge
className="border-primary/50 text-xs text-primary" variant="outline"
> className="border-primary/50 text-xs text-primary"
MegaTask >
</Badge> MegaTask
</Badge>
</HelpTip>
)} )}
{childCount > 0 && ( {childCount > 0 && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
// Radix Tooltip renders content into a portal only when open; for a static
// render we assert on the trigger passthrough and the short-circuit, not the
// (hidden) portal content.
import { HelpTip } from "../help-tip";
describe("HelpTip", () => {
it("wraps the child so the child still renders", () => {
render(
<HelpTip label="reload">
<button type="button">Refresh</button>
</HelpTip>,
);
expect(screen.getByText("Refresh")).toBeInTheDocument();
});
it("renders the child bare when label is falsy", () => {
const { container } = render(
<HelpTip label={null}>
<button type="button">Refresh</button>
</HelpTip>,
);
// No Tooltip wrapper: just the button.
expect(container.querySelector("button")).not.toBeNull();
expect(container.firstChild).toBe(container.querySelector("button"));
});
it("renders the child bare when label is an empty string", () => {
const { container } = render(
<HelpTip label="">
<span>x</span>
</HelpTip>,
);
expect(container.firstChild).toBe(container.querySelector("span"));
});
});
+34
View File
@@ -0,0 +1,34 @@
"use client";
import type { ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface HelpTipProps {
/** The explanation shown on hover/focus. Falsy → renders the child bare. */
label: ReactNode;
/** Tooltip side relative to the trigger. */
side?: "top" | "bottom" | "left" | "right";
/** The element to hover; must forward its ref (Button, Badge, span, etc.). */
children: ReactNode;
}
/**
* DRY wrapper for the verbose three-element Tooltip pattern. Use for any
* element that benefits from a hover/focus explanation — icon-only buttons,
* status/severity/origin badges, health dots, metric numbers, abbreviations.
* A falsy `label` short-circuits to the bare child, so callers can gate a tip
* on whether an explanation is available without conditional markup.
*/
export function HelpTip({ label, side = "top", children }: HelpTipProps) {
if (!label) return <>{children}</>;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side}>{label}</TooltipContent>
</Tooltip>
);
}