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
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { A2AChatMessage } from "@/lib/api/a2a";
import { formatDistanceToNow } from "date-fns";
import { AlertTriangle, MessagesSquare } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface A2ATranscriptProps {
messages: A2AChatMessage[];
@@ -227,9 +228,11 @@ export function A2ATranscript({
{getAgentDisplayName(message.from_agent)}
</span>
{message.message_kind && (
<HelpTip label="Type of agent-to-agent message">
<Badge variant="outline" className="text-[10px]">
{message.message_kind}
</Badge>
</HelpTip>
)}
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.created_at))} ago
@@ -1,4 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import {
Clock,
RefreshCw,
@@ -53,12 +54,32 @@ const stateIcons: Record<string, React.ReactNode> = {
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 {
state: AgentStateString | string;
showIcon?: boolean;
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({
state,
showIcon = true,
@@ -74,10 +95,12 @@ export function AgentStateBadge({
const icon = stateIcons[state] || <Square className="h-4 w-4" />;
return (
<HelpTip label={stateDescriptions[state]}>
<Badge className={`${color} text-white ${sizeClasses[size]}`}>
{showIcon && <span className="mr-1">{icon}</span>}
{state.replace(/_/g, " ")}
</Badge>
</HelpTip>
);
}
@@ -3,6 +3,7 @@
import { AuditorFlag, FlagSeverity } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Eye, CheckCircle, Send, Clock } from "lucide-react";
import Link from "next/link";
@@ -89,9 +90,11 @@ export function FlaggedItem({
<div className="flex items-center gap-2 shrink-0">
{flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
<HelpTip label="View related task">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</HelpTip>
</Link>
)}
<Button
@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileText, Send, Eye, Clock, Plus } from "lucide-react";
import { toast } from "sonner";
@@ -122,9 +123,11 @@ export function ReportsPanel({
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<HelpTip label="View report">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</HelpTip>
{isDraft && (
<Button
variant="outline"
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Check, Loader2, Send, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { HelpTip } from "@/components/ui/help-tip";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
@@ -271,6 +272,7 @@ export function SecretaryTab() {
}}
/>
{sessionId ? (
<HelpTip label="Send message">
<Button
onClick={() => void handleSend()}
disabled={!input.trim() || streaming}
@@ -280,6 +282,7 @@ export function SecretaryTab() {
>
<Send className="h-4 w-4" />
</Button>
</HelpTip>
) : (
<Button
onClick={() => void handleStart()}
@@ -21,6 +21,7 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
@@ -395,11 +396,19 @@ export function ConventionsTab({ projectId }: { projectId: string }) {
</code>{" "}
<span className="text-muted-foreground">{finding.message}</span>
</div>
<HelpTip
label={
finding.level === "block"
? "Block-level: refuses the gate"
: "Warning: advisory only"
}
>
<Badge
variant={finding.level === "block" ? "destructive" : "secondary"}
>
{finding.rule}
</Badge>
</HelpTip>
</div>
))}
</CardContent>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertTriangle, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
import { HelpTip } from "@/components/ui/help-tip";
interface ActiveBlockersPanelProps {
tasks: Task[] | undefined;
@@ -71,9 +72,11 @@ export function ActiveBlockersPanel({
<span className="text-lg">\uD83D\uDD34</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<HelpTip label="Short task ID — first 8 characters of the full task identifier">
<span className="font-medium text-sm truncate">
Task #{task.id.slice(0, 8)}
</span>
</HelpTip>
<Badge variant="outline" className="text-xs capitalize">
{task.team.replace(/_/g, " ")}
</Badge>
@@ -34,6 +34,7 @@ import {
import Link from "next/link";
import { TaskStatus, Team, type Task } from "@/types";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface CeoApprovalQueueProps {
className?: string;
@@ -171,18 +172,24 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
desc: string;
}
> = {
0: { label: "P0", variant: "destructive" },
1: { label: "P1", variant: "destructive" },
2: { label: "P2", variant: "secondary" },
3: { label: "P3", variant: "outline" },
0: { label: "P0", variant: "destructive", desc: "Critical priority — blocks all other work" },
1: { label: "P1", variant: "destructive", desc: "High priority — should be done soon" },
2: { label: "P2", variant: "secondary", desc: "Medium priority — normal scheduling" },
3: { label: "P3", variant: "outline", desc: "Low priority — can wait" },
};
const { label, variant } = variants[priority] || {
const { label, variant, desc } = variants[priority] || {
label: `P${priority}`,
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) {
@@ -237,9 +244,11 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
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">
<Link href={`/tasks/${task.id}`} prefetch={false}>
<HelpTip label="View task details">
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
</Button>
</HelpTip>
</Link>
<Button
variant="outline"
@@ -26,6 +26,7 @@ import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { usePageRefresh } from "@/hooks";
import { HelpTip } from "@/components/ui/help-tip";
const _MIN_REJECT_CHARS = 10;
@@ -166,10 +167,14 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
<Rocket className="h-5 w-5" />
Release Proposal
<Badge variant="outline">v{report.proposed_version}</Badge>
<HelpTip label="Semver bump type — how the version number increases (major, minor, or patch)">
<Badge variant="secondary">{report.bump_kind}</Badge>
</HelpTip>
<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>
<CardDescription>
{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 { CheckCircle2, Map, XCircle } from "lucide-react";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
const _MIN_REASON_CHARS = 4;
@@ -68,8 +69,12 @@ function RoadmapItemRow({
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium">{item.title}</span>
<Badge variant="outline">{item.team}</Badge>
<HelpTip label="The project (repository) this roadmap item targets">
<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)}
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
@@ -37,6 +37,7 @@ import {
RefreshCcw,
GitGraph,
} from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitActionsPanelProps {
status: GitStatusResponse | undefined;
@@ -228,10 +229,12 @@ export function GitActionsPanel({
)}
Push to Remote
{status?.ahead !== undefined && status.ahead > 0 && (
<HelpTip label="Commits not yet pushed to the remote repository">
<Badge variant="secondary" className="ml-auto">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead}
</Badge>
</HelpTip>
)}
</Button>
@@ -23,6 +23,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { GitBranch, Check, Cloud, Plus, RefreshCw } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitBranchPanelProps {
branches: GitBranchListResponse | undefined;
@@ -183,9 +184,11 @@ export function GitBranchPanel({
</span>
</div>
{branch.last_commit && (
<HelpTip label="Short commit hash on this branch">
<span className="text-xs text-muted-foreground font-mono shrink-0">
{branch.last_commit.slice(0, 7)}
</span>
</HelpTip>
)}
</Button>
))}
@@ -9,6 +9,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { GitCommit, User, Calendar } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { HelpTip } from "@/components/ui/help-tip";
interface GitLogPanelProps {
log: GitLogResponse | undefined;
@@ -105,12 +106,14 @@ export function GitLogPanel({
<p className="text-sm font-medium leading-snug line-clamp-2">
{commit.message}
</p>
<HelpTip label="Short commit hash">
<Badge
variant="outline"
className="font-mono text-xs shrink-0"
>
{commit.short_hash}
</Badge>
</HelpTip>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X, Loader2 } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface KBSearchBarProps {
value: string;
@@ -64,6 +65,7 @@ export function KBSearchBar({
className="pl-9 pr-9"
/>
{localValue && (
<HelpTip label="Clear search">
<Button
variant="ghost"
size="icon-sm"
@@ -73,6 +75,7 @@ export function KBSearchBar({
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
)}
</div>
{onSearch && (
@@ -46,6 +46,7 @@ import {
} from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { getErrorMessage } from "@/lib/api/client";
@@ -593,6 +594,7 @@ function KnowledgeBaseBrowserContent() {
</Badge>
</div>
<div className="flex items-center gap-2">
<HelpTip label="Refresh this index">
<Button
size="sm"
variant="outline"
@@ -607,8 +609,10 @@ function KnowledgeBaseBrowserContent() {
<RefreshCw className="h-3 w-3" />
)}
</Button>
</HelpTip>
<AlertDialog>
<AlertDialogTrigger asChild>
<HelpTip label="Delete this index and all its documents">
<Button
size="sm"
variant="outline"
@@ -616,6 +620,7 @@ function KnowledgeBaseBrowserContent() {
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -17,6 +17,7 @@ import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
import type { ProjectSummary } from "@/types";
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
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). */
const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI];
@@ -158,16 +159,20 @@ export function BatchReviewCard({
</span>
<div className="flex shrink-0 items-center gap-1">
{draft.adds_migration && (
<HelpTip label="This task adds a database migration that must be applied in order">
<Badge variant="outline" className="gap-1 text-xs">
<Database className="h-3 w-3" />
migration
</Badge>
</HelpTip>
)}
{draft.touches_shared && (
<HelpTip label="This task modifies shared code that other tasks may also touch">
<Badge variant="outline" className="gap-1 text-xs">
<Share2 className="h-3 w-3" />
shared
</Badge>
</HelpTip>
)}
</div>
</div>
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import {
AlertTriangle,
CheckCircle2,
@@ -194,6 +195,7 @@ export function SelfHostedSection({
}
className="pr-10"
/>
<HelpTip label={showToken ? "Hide token" : "Show token"}>
<Button
type="button"
variant="ghost"
@@ -208,6 +210,7 @@ export function SelfHostedSection({
<Eye className="h-4 w-4" />
)}
</Button>
</HelpTip>
</div>
<Button onClick={handleSave} disabled={saveConfig.isPending}>
{saveConfig.isPending ? "Saving…" : "Save"}
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, Edit3, Check, X } from "lucide-react";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
import { CollapsibleSection } from "./collapsible-section";
interface AcceptanceCriteriaProps {
@@ -257,6 +258,7 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
>
{text}
</span>
<HelpTip label="Edit this criterion">
<Button
size="sm"
variant="ghost"
@@ -265,6 +267,8 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
>
<Edit3 className="h-3 w-3" />
</Button>
</HelpTip>
<HelpTip label="Delete this criterion">
<Button
size="sm"
variant="ghost"
@@ -273,6 +277,7 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</>
)}
</li>
@@ -9,6 +9,8 @@ import { ListTree, ExternalLink, Plus } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
interface SubtasksListProps {
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 gap-3 min-w-0">
<HelpTip label={taskStatusDescription(subtask.status)}>
<Badge
variant="secondary"
className={`text-xs shrink-0 ${STATUS_COLORS[subtask.status]}`}
>
{subtask.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
<span className="text-sm truncate">{subtask.title}</span>
</div>
<div className="flex items-center gap-2">
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { HelpTip } from "@/components/ui/help-tip";
interface TabCommitsProps {
task: Task;
@@ -122,6 +123,7 @@ export function TabCommits({ task }: TabCommitsProps) {
</CardTitle>
<div className="flex items-center justify-center gap-2">
{task.branch_name && (
<HelpTip label="Git branch this task is being worked on">
<Badge
variant="outline"
className="gap-1.5 py-1 px-2.5 font-mono text-sm"
@@ -129,8 +131,10 @@ export function TabCommits({ task }: TabCommitsProps) {
<GitBranch className="h-4 w-4" />
{task.branch_name}
</Badge>
</HelpTip>
)}
{task.pr_url && (
<HelpTip label="Pull Request — click to open on GitHub">
<a href={task.pr_url} target="_blank" rel="noopener noreferrer">
<Badge
variant="secondary"
@@ -140,6 +144,7 @@ export function TabCommits({ task }: TabCommitsProps) {
<ExternalLink className="h-3.5 w-3.5" />
</Badge>
</a>
</HelpTip>
)}
</div>
<div className="flex justify-end">
@@ -243,12 +248,14 @@ export function TabCommits({ task }: TabCommitsProps) {
{/* Meta info */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* Hash */}
<HelpTip label="Short git commit hash (first 7 characters)">
<Badge
variant="outline"
className="font-mono text-xs"
>
{commit.hash.slice(0, 7)}
</Badge>
</HelpTip>
{/* Author */}
{commit.author_agent_id && (
@@ -266,6 +273,7 @@ export function TabCommits({ task }: TabCommitsProps) {
</div>
</div>
</div>
<HelpTip label="Unlink this commit from the task">
<Button
size="sm"
variant="ghost"
@@ -274,6 +282,7 @@ export function TabCommits({ task }: TabCommitsProps) {
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div>
</CardContent>
</Card>
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface TabDependenciesProps {
task: Task;
@@ -155,6 +156,7 @@ function DependencyList({
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" />
<HelpTip label="Task ID (first 8 characters) — click to open">
<Link
href={`/tasks/${depId}`}
className="flex-1"
@@ -164,9 +166,11 @@ function DependencyList({
{depId.slice(0, 8)}...
</span>
</Link>
</HelpTip>
<Badge variant="outline" className={badgeClass}>
{badgeLabel}
</Badge>
<HelpTip label="Remove this dependency">
<Button
size="sm"
variant="ghost"
@@ -176,6 +180,7 @@ function DependencyList({
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div>
</li>
))}
@@ -24,6 +24,7 @@ import {
Plus,
} from "lucide-react";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface TabNotesProps {
task: Task;
@@ -47,9 +48,11 @@ function prReviewBadge(task: Task): React.ReactNode {
)?.pr_review?.verdict;
if (!verdict) {
return (
<HelpTip label="PR review has not been submitted yet">
<Badge variant="outline" className="ml-2 text-teal-600 border-teal-300">
Review Gate
</Badge>
</HelpTip>
);
}
const map: Record<string, { label: string; cls: string }> = {
@@ -345,9 +348,11 @@ export function TabNotes({ task }: TabNotesProps) {
title="Quick Context"
icon={<FileText className="h-5 w-5" />}
badge={
<HelpTip label="Short context notes for quickly resuming work after an interruption">
<Badge variant="outline" className="ml-2">
For Resumption
</Badge>
</HelpTip>
}
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"
icon={<Shield className="h-5 w-5" />}
badge={
<HelpTip label="Visible only to the Auditor and CEO">
<Badge
variant="outline"
className="ml-2 text-purple-600 border-purple-300"
>
Confidential
</Badge>
</HelpTip>
}
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 { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
const statusColors: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]: "bg-slate-500",
@@ -19,14 +20,44 @@ const statusColors: Record<TaskStatus, string> = {
[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 {
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) {
return (
<HelpTip label={statusDescriptions[status]}>
<Badge className={`${statusColors[status] ?? "bg-slate-600"} text-white`}>
{status.replace(/_/g, " ")}
</Badge>
</HelpTip>
);
}
@@ -30,6 +30,7 @@ import {
ResponsiveTableCardEmpty,
} from "@/components/ui/responsive-table";
import { TaskStatusBadge } from "./task-status-badge";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskActions } from "./task-actions";
import { GitStatusBadge } from "./git-status-badge";
import Link from "next/link";
@@ -614,12 +615,14 @@ export function TaskTable({
{task.title}
</span>
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="text-xs shrink-0 border-primary/50 text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<Badge
@@ -744,12 +747,14 @@ export function TaskTable({
childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<HelpTip label="A multi-task batch — this is the umbrella task for a set of related tasks">
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
</HelpTip>
)}
{childCount > 0 && (
<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>
);
}