feat(panel): tooltip sweep — tasks, kanban, task assistant, git, work sessions (58 tips, 22 files)

Full import-graph walk per page: filter-chip and plan-section icon
buttons gain their first accessible names, per-state description maps
for git/docs/finding/priority badges, truncation-gated full-value tips,
disabled buttons explain their gate. Stateful Radix triggers use the
task-tabs data-state re-assertion pattern.
This commit is contained in:
Renn F
2026-07-15 16:30:26 +02:00
parent 14ee9b3b49
commit d1b02e3747
30 changed files with 1079 additions and 543 deletions
+69 -16
View File
@@ -10,6 +10,11 @@ import {
} from "@/components/kanban"; } from "@/components/kanban";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { pickTab } from "@/lib/tabs"; import { pickTab } from "@/lib/tabs";
import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react"; import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react";
@@ -35,22 +40,70 @@ function KanbanPageContent() {
<div className="space-y-6"> <div className="space-y-6">
<Tabs value={view} onValueChange={handleViewChange}> <Tabs value={view} onValueChange={handleViewChange}>
<TabsList> <TabsList>
<TabsTrigger value="dev" className="gap-2"> {/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's own
<Code className="h-4 w-4" /> data-state with the tooltip's — re-assert the real selection
Developer state explicitly so data-[state=active] styling survives
</TabsTrigger> (same fix as task-detail/task-tabs.tsx). */}
<TabsTrigger value="qa" className="gap-2"> <Tooltip>
<TestTube className="h-4 w-4" /> <TooltipTrigger asChild>
QA <TabsTrigger
</TabsTrigger> value="dev"
<TabsTrigger value="pr-review" className="gap-2"> data-state={view === "dev" ? "active" : "inactive"}
<GitPullRequest className="h-4 w-4" /> className="gap-2"
PR Review >
</TabsTrigger> <Code className="h-4 w-4" />
<TabsTrigger value="pm" className="gap-2"> Developer
<ClipboardList className="h-4 w-4" /> </TabsTrigger>
PM </TooltipTrigger>
</TabsTrigger> <TooltipContent>
Tasks claimed and worked by developers backlog through
completion
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="qa"
data-state={view === "qa" ? "active" : "inactive"}
className="gap-2"
>
<TestTube className="h-4 w-4" />
QA
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>Quality assurance review workflow</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="pr-review"
data-state={view === "pr-review" ? "active" : "inactive"}
className="gap-2"
>
<GitPullRequest className="h-4 w-4" />
PR Review
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>
In-path PR-review gate for assembled PRs, before the PM merges
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="pm"
data-state={view === "pm" ? "active" : "inactive"}
className="gap-2"
>
<ClipboardList className="h-4 w-4" />
PM
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>
Project management overview every lifecycle state, including
recovery states
</TooltipContent>
</Tooltip>
</TabsList> </TabsList>
<TabsContent value="dev" className="mt-6"> <TabsContent value="dev" className="mt-6">
+31 -12
View File
@@ -10,6 +10,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FileCode, FileDiff, WrapText } from "lucide-react"; import { FileCode, FileDiff, WrapText } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitDiffViewerProps { interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined; stagedDiff: GitDiffResponse | undefined;
@@ -124,21 +125,39 @@ export function GitDiffViewer({
<Tabs defaultValue="unstaged"> <Tabs defaultValue="unstaged">
<div className="px-4 border-b"> <div className="px-4 border-b">
<TabsList className="h-9"> <TabsList className="h-9">
{/* HelpTip wraps an inner span, never the TabsTrigger itself —
TooltipTrigger's asChild would clobber the trigger's own
data-state and break the active-tab highlight (see
task-tabs.tsx for the fuller writeup of this bug class). */}
<TabsTrigger value="unstaged" className="text-xs gap-1"> <TabsTrigger value="unstaged" className="text-xs gap-1">
Working Directory <HelpTip label="Files changed on disk that haven't been staged for the next commit yet">
{unstagedCount > 0 && ( <span className="inline-flex items-center gap-1">
<Badge variant="secondary" className="h-4 px-1 text-[10px]"> Working Directory
{unstagedCount} {unstagedCount > 0 && (
</Badge> <Badge
)} variant="secondary"
className="h-4 px-1 text-[10px]"
>
{unstagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="staged" className="text-xs gap-1"> <TabsTrigger value="staged" className="text-xs gap-1">
Staged <HelpTip label="Files staged and ready to be included in the next commit">
{stagedCount > 0 && ( <span className="inline-flex items-center gap-1">
<Badge variant="secondary" className="h-4 px-1 text-[10px]"> Staged
{stagedCount} {stagedCount > 0 && (
</Badge> <Badge
)} variant="secondary"
className="h-4 px-1 text-[10px]"
>
{stagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
+37 -17
View File
@@ -14,12 +14,22 @@ import {
ArrowDown, ArrowDown,
CheckCircle, CheckCircle,
} from "lucide-react"; } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitStatusPanelProps { interface GitStatusPanelProps {
status: GitStatusResponse | undefined; status: GitStatusResponse | undefined;
isLoading: boolean; isLoading: boolean;
} }
// Plain-language explanation per file bucket, mirroring the task-status-badge
// per-state description map so non-git-fluent readers (CEO, PM) know what
// each section means without having to already know git jargon.
const FILE_SECTION_DESCRIPTIONS = {
staged: "Changes staged and ready to be included in the next commit.",
unstaged: "Tracked files with changes not yet staged for commit.",
untracked: "New files git isn't tracking yet.",
} as const;
export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) { export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
if (isLoading) { if (isLoading) {
return ( return (
@@ -77,16 +87,20 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{(status.ahead > 0 || status.behind > 0) && ( {(status.ahead > 0 || status.behind > 0) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{status.ahead > 0 && ( {status.ahead > 0 && (
<Badge variant="secondary" className="text-xs"> <HelpTip label="Local commits not yet pushed to the remote.">
<ArrowUp className="h-3 w-3 mr-1" /> <Badge variant="secondary" className="text-xs">
{status.ahead} ahead <ArrowUp className="h-3 w-3 mr-1" />
</Badge> {status.ahead} ahead
</Badge>
</HelpTip>
)} )}
{status.behind > 0 && ( {status.behind > 0 && (
<Badge variant="secondary" className="text-xs"> <HelpTip label="Remote commits not yet merged into your local branch.">
<ArrowDown className="h-3 w-3 mr-1" /> <Badge variant="secondary" className="text-xs">
{status.behind} behind <ArrowDown className="h-3 w-3 mr-1" />
</Badge> {status.behind} behind
</Badge>
</HelpTip>
)} )}
</div> </div>
)} )}
@@ -99,9 +113,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Staged Files */} {/* Staged Files */}
{status.staged_files.length > 0 && ( {status.staged_files.length > 0 && (
<div> <div>
<h4 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-1"> <HelpTip label={FILE_SECTION_DESCRIPTIONS.staged}>
Staged ({status.staged_files.length}) <h4 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-1 w-fit">
</h4> Staged ({status.staged_files.length})
</h4>
</HelpTip>
<div className="space-y-0.5"> <div className="space-y-0.5">
{status.staged_files.map((file) => ( {status.staged_files.map((file) => (
<div <div
@@ -121,9 +137,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Unstaged Files */} {/* Unstaged Files */}
{status.unstaged_files.length > 0 && ( {status.unstaged_files.length > 0 && (
<div> <div>
<h4 className="text-xs font-semibold text-orange-600 uppercase tracking-wider mb-1"> <HelpTip label={FILE_SECTION_DESCRIPTIONS.unstaged}>
Modified ({status.unstaged_files.length}) <h4 className="text-xs font-semibold text-orange-600 uppercase tracking-wider mb-1 w-fit">
</h4> Modified ({status.unstaged_files.length})
</h4>
</HelpTip>
<div className="space-y-0.5"> <div className="space-y-0.5">
{status.unstaged_files.map((file) => ( {status.unstaged_files.map((file) => (
<div <div
@@ -143,9 +161,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Untracked Files */} {/* Untracked Files */}
{status.untracked_files.length > 0 && ( {status.untracked_files.length > 0 && (
<div> <div>
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-1"> <HelpTip label={FILE_SECTION_DESCRIPTIONS.untracked}>
Untracked ({status.untracked_files.length}) <h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-1 w-fit">
</h4> Untracked ({status.untracked_files.length})
</h4>
</HelpTip>
<div className="space-y-0.5"> <div className="space-y-0.5">
{status.untracked_files.map((file) => ( {status.untracked_files.map((file) => (
<div <div
@@ -1,13 +1,18 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
import { TaskStatus } from "@/types";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
export function BlockedBadge() { export function BlockedBadge() {
return ( return (
<Badge variant="destructive" className="text-xs gap-1"> <HelpTip label={taskStatusDescription(TaskStatus.BLOCKED)}>
<AlertTriangle className="h-3 w-3" /> <Badge variant="destructive" className="text-xs gap-1">
Blocked <AlertTriangle className="h-3 w-3" />
</Badge> Blocked
</Badge>
</HelpTip>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
interface PriorityIndicatorProps { interface PriorityIndicatorProps {
priority: number; priority: number;
@@ -20,10 +21,19 @@ const priorityLabels: Record<number, string> = {
3: "P3 - Low", 3: "P3 - Low",
}; };
const priorityDescriptions: Record<number, string> = {
0: "Highest urgency — work this before anything else.",
1: "High urgency — prioritize over P2/P3 work.",
2: "Standard priority — the default for most tasks.",
3: "Low urgency — fine to defer behind higher-priority work.",
};
export function PriorityIndicator({ priority }: PriorityIndicatorProps) { export function PriorityIndicator({ priority }: PriorityIndicatorProps) {
return ( return (
<Badge className={priorityColors[priority] ?? priorityColors[2]}> <HelpTip label={priorityDescriptions[priority] ?? priorityDescriptions[2]}>
{priorityLabels[priority] ?? "P2 - Medium"} <Badge className={priorityColors[priority] ?? priorityColors[2]}>
</Badge> {priorityLabels[priority] ?? "P2 - Medium"}
</Badge>
</HelpTip>
); );
} }
@@ -124,11 +124,16 @@ export function BatchReviewCard({
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-semibold leading-tight"> <CardTitle className="text-sm font-semibold leading-tight">
MegaTask: {batch.title || "Untitled"} <HelpTip label="A MegaTask sequences several related tasks — even across unrelated repos — into conflict-free waves, coordinated by the Main PM as one umbrella.">
<span>MegaTask</span>
</HelpTip>
: {batch.title || "Untitled"}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs"> <HelpTip label="Each becomes a real root-subtask with its own project, branch, and PR.">
{batch.drafts.length} tasks <Badge variant="secondary" className="shrink-0 text-xs">
</Badge> {batch.drafts.length} tasks
</Badge>
</HelpTip>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
One batch, sequenced into conflict-free waves. Each task keeps its own One batch, sequenced into conflict-free waves. Each task keeps its own
@@ -191,7 +196,10 @@ export function BatchReviewCard({
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
> >
Projects {selected.length === 0 && "— pick at least one"} <HelpTip label="One repo per delivery cell — checking a different repo in the same cell swaps out the one already selected there.">
<span>Projects</span>
</HelpTip>{" "}
{selected.length === 0 && "— pick at least one"}
</p> </p>
{CELL_TEAMS.map((cell) => { {CELL_TEAMS.map((cell) => {
const repos = scopedByCell(cell); const repos = scopedByCell(cell);
@@ -231,9 +239,11 @@ export function BatchReviewCard({
{/* Wave plan — how the batch will be sequenced */} {/* Wave plan — how the batch will be sequenced */}
{waves && waves.length > 0 && ( {waves && waves.length > 0 && (
<div className="rounded-md border border-dashed px-3 py-2"> <div className="rounded-md border border-dashed px-3 py-2">
<p className="mb-1 text-xs font-medium text-muted-foreground"> <HelpTip label="Tasks in the same wave don't overlap and run in parallel; a later wave waits for its dependencies in an earlier wave to land first.">
Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"}) <p className="mb-1 w-fit text-xs font-medium text-muted-foreground">
</p> Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"})
</p>
</HelpTip>
<ol className="space-y-0.5"> <ol className="space-y-0.5">
{waves.map((wave, w) => ( {waves.map((wave, w) => (
<li <li
@@ -267,32 +277,36 @@ export function BatchReviewCard({
Keep chatting Keep chatting
</Button> </Button>
{/* Board review & Start → the Board reviews the whole batch first */} {/* Board review & Start → the Board reviews the whole batch first */}
<Button <HelpTip label="Routes the whole MegaTask through the Product Owner and Head of Marketing for review before any work starts.">
variant="secondary" <Button
size="sm" variant="secondary"
onClick={() => onConfirm("board")} size="sm"
disabled={isLaunching || missingProject} onClick={() => onConfirm("board")}
> disabled={isLaunching || missingProject}
{isLaunching ? ( >
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> {isLaunching ? (
) : ( <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Users className="mr-1.5 h-3.5 w-3.5" /> ) : (
)} <Users className="mr-1.5 h-3.5 w-3.5" />
Board review &amp; Start )}
</Button> Board review &amp; Start
</Button>
</HelpTip>
{/* Approve & Start → straight to the Main PM, waves dispatch at once */} {/* Approve & Start → straight to the Main PM, waves dispatch at once */}
<Button <HelpTip label="Skips board review — dispatches wave 1 to the Main PM immediately.">
size="sm" <Button
onClick={() => onConfirm("main_pm")} size="sm"
disabled={isLaunching || missingProject} onClick={() => onConfirm("main_pm")}
> disabled={isLaunching || missingProject}
{isLaunching ? ( >
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> {isLaunching ? (
) : ( <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Rocket className="mr-1.5 h-3.5 w-3.5" /> ) : (
)} <Rocket className="mr-1.5 h-3.5 w-3.5" />
Approve &amp; Start )}
</Button> Approve &amp; Start
</Button>
</HelpTip>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -11,6 +11,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
interface BoardReviewSentCardProps { interface BoardReviewSentCardProps {
/** The umbrella task id — the single board-review / CEO-approve unit. */ /** The umbrella task id — the single board-review / CEO-approve unit. */
@@ -52,15 +53,21 @@ export function BoardReviewSentCard({
<CardContent className="pb-3 space-y-2"> <CardContent className="pb-3 space-y-2">
<p className="text-sm font-medium">{taskTitle}</p> <p className="text-sm font-medium">{taskTitle}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs"> <HelpTip label="Each is a real root-subtask with its own project, branch, and PR, coordinated by the Main PM.">
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"} <Badge variant="secondary" className="text-xs">
</Badge> {rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
<Badge variant="outline" className="text-xs"> </Badge>
{waveCount} wave{waveCount === 1 ? "" : "s"} </HelpTip>
</Badge> <HelpTip label="Tasks in the same wave don't overlap and run in parallel; later waves wait for their dependencies to land first.">
<span className="text-xs text-muted-foreground"> <Badge variant="outline" className="text-xs">
ID: {taskId.slice(0, 8)} {waveCount} wave{waveCount === 1 ? "" : "s"}
</span> </Badge>
</HelpTip>
<HelpTip label={`Full umbrella task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
</span>
</HelpTip>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The Product Owner and Head of Marketing are reviewing this MegaTask. The Product Owner and Head of Marketing are reviewing this MegaTask.
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CopyButton } from "@/components/ui/copy-button"; import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import type { DraftProposal } from "@/lib/api/prompter"; import type { DraftProposal } from "@/lib/api/prompter";
import type { StartRoute } from "@/hooks/use-prompter"; import type { StartRoute } from "@/hooks/use-prompter";
@@ -116,7 +117,13 @@ export function DraftProposalCard({
{distinctTeams.length > 0 && ( {distinctTeams.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
{distinctTeams.length > 1 ? "Board-led across" : "Cell:"} {distinctTeams.length > 1 ? (
<HelpTip label="This feature spans multiple delivery cells of one product — the Board reviews it before delivery starts.">
<span>Board-led across</span>
</HelpTip>
) : (
"Cell:"
)}
</span> </span>
{distinctTeams.map((team) => ( {distinctTeams.map((team) => (
<Badge key={team} variant="outline" className="text-xs"> <Badge key={team} variant="outline" className="text-xs">
@@ -164,32 +171,36 @@ export function DraftProposalCard({
Keep chatting Keep chatting
</Button> </Button>
{/* Board review & Start → PENDING, assigned to PO + HoM for review */} {/* Board review & Start → PENDING, assigned to PO + HoM for review */}
<Button <HelpTip label="Routes this task through the Product Owner and Head of Marketing for review before any work starts.">
variant="secondary" <Button
size="sm" variant="secondary"
onClick={() => onStart("board")} size="sm"
disabled={isLaunching} onClick={() => onStart("board")}
> disabled={isLaunching}
{isLaunching ? ( >
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> {isLaunching ? (
) : ( <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Users className="mr-1.5 h-3.5 w-3.5" /> ) : (
)} <Users className="mr-1.5 h-3.5 w-3.5" />
Board review &amp; Start )}
</Button> Board review &amp; Start
</Button>
</HelpTip>
{/* Approve & Start → PENDING, straight to Main PM (skip the board) */} {/* Approve & Start → PENDING, straight to Main PM (skip the board) */}
<Button <HelpTip label="Skips board review — dispatches this task immediately.">
size="sm" <Button
onClick={() => onStart("main_pm")} size="sm"
disabled={isLaunching} onClick={() => onStart("main_pm")}
> disabled={isLaunching}
{isLaunching ? ( >
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> {isLaunching ? (
) : ( <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Rocket className="mr-1.5 h-3.5 w-3.5" /> ) : (
)} <Rocket className="mr-1.5 h-3.5 w-3.5" />
Approve &amp; Start )}
</Button> Approve &amp; Start
</Button>
</HelpTip>
</CardFooter> </CardFooter>
</Card> </Card>
); );
+42 -16
View File
@@ -17,6 +17,7 @@ import {
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { useProducts } from "@/hooks/use-products"; import { useProducts } from "@/hooks/use-products";
import type { TargetKind } from "@/hooks/use-prompter"; import type { TargetKind } from "@/hooks/use-prompter";
import { HelpTip } from "@/components/ui/help-tip";
interface IntakeFormProps { interface IntakeFormProps {
targetKind: TargetKind; targetKind: TargetKind;
@@ -111,14 +112,23 @@ export function IntakeForm({
onValueChange={(v) => onTargetKind(v as TargetKind)} onValueChange={(v) => onTargetKind(v as TargetKind)}
> >
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
{/* Tooltip goes on the inner span, not TabsTrigger itself —
TooltipTrigger's asChild merge would clobber the trigger's
own data-state and break the active-tab highlight. */}
<TabsTrigger value="project" disabled={isPreparing}> <TabsTrigger value="project" disabled={isPreparing}>
Single cell <HelpTip label="One task delegated to a single delivery cell (Backend, Frontend, or UX/UI) — the common case.">
<span>Single cell</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="product" disabled={isPreparing}> <TabsTrigger value="product" disabled={isPreparing}>
Board-led <HelpTip label="A feature spanning a product's cells — the Product Owner and Head of Marketing review it before delivery starts.">
<span>Board-led</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="megatask" disabled={isPreparing}> <TabsTrigger value="megatask" disabled={isPreparing}>
MegaTask <HelpTip label="Several related tasks across one or more repos, sequenced into conflict-free waves so independent ones run in parallel.">
<span>MegaTask</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@@ -229,20 +239,36 @@ export function IntakeForm({
/> />
</div> </div>
<Button {/* Button's own disabled:pointer-events-none would swallow hover, so
className="w-full" the tip sits on a wrapping span (a well-worn disabled-tooltip
onClick={onStart} workaround) rather than the Button itself. */}
disabled={!isValid || isPreparing} <HelpTip
label={
!isValid && !isPreparing
? "Pick a scope above and describe what you want to build to continue."
: null
}
> >
{isPreparing ? ( <span
<> className="block w-full"
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> tabIndex={!isValid && !isPreparing ? 0 : undefined}
Preparing the agent >
</> <Button
) : ( className="w-full"
"Start chatting" onClick={onStart}
)} disabled={!isValid || isPreparing}
</Button> >
{isPreparing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Preparing the agent
</>
) : (
"Start chatting"
)}
</Button>
</span>
</HelpTip>
{isPreparing && ( {isPreparing && (
<div className="space-y-1.5" aria-live="polite"> <div className="space-y-1.5" aria-live="polite">
@@ -11,6 +11,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import type { Team } from "@/types"; import type { Team } from "@/types";
interface SuccessCardProps { interface SuccessCardProps {
@@ -43,9 +44,11 @@ export function SuccessCard({
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{team.replace("_", " ")} {team.replace("_", " ")}
</Badge> </Badge>
<span className="text-xs text-muted-foreground"> <HelpTip label={`Full task ID: ${taskId}`}>
ID: {taskId.slice(0, 8)} <span className="text-xs text-muted-foreground">
</span> ID: {taskId.slice(0, 8)}
</span>
</HelpTip>
</div> </div>
</CardContent> </CardContent>
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { mutateAsync } = vi.hoisted(() => ({ const { mutateAsync } = vi.hoisted(() => ({
mutateAsync: vi.fn().mockResolvedValue(undefined), mutateAsync: vi.fn().mockResolvedValue(undefined),
@@ -204,3 +205,19 @@ describe("CreateTaskDialog — project/product mutual exclusivity (F085)", () =>
expect(payload.project_id).toBeUndefined(); expect(payload.project_id).toBeUndefined();
}); });
}); });
describe("CreateTaskDialog — Task Type tooltip (W9-5 follow-up)", () => {
it("explains what the selected task type produces on hover", async () => {
const user = userEvent.setup();
render(<CreateTaskDialog />);
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
// Task Type defaults to CODE; the Collapsible mock renders Advanced
// Options open, so the field is reachable without a pointer toggle.
await user.hover(screen.getByText("Task Type"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
/source code changes/i,
);
});
});
@@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label";
import { Rocket } from "lucide-react"; import { Rocket } from "lucide-react";
import type { Task } from "@/types"; import type { Task } from "@/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface ApproveAndStartButtonProps { interface ApproveAndStartButtonProps {
task: Task; task: Task;
@@ -124,9 +125,11 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="approve-and-start-notes"> <HelpTip label="Minimum 20 characters — this is the permanent audit record for starting the work.">
Approval notes (required) <Label htmlFor="approve-and-start-notes">
</Label> Approval notes (required)
</Label>
</HelpTip>
<Textarea <Textarea
id="approve-and-start-notes" id="approve-and-start-notes"
placeholder="Board review read; requirements are clear. Build it..." placeholder="Board review read; requirements are clear. Build it..."
@@ -35,6 +35,7 @@ import { DependencySelector } from "./dependency-selector";
import { TaskSelector } from "./task-selector"; import { TaskSelector } from "./task-selector";
import { AgentSelector } from "@/components/agents/agent-selector"; import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector"; import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
// Priority options (0=P0 highest, 3=P3 lowest) // Priority options (0=P0 highest, 3=P3 lowest)
const PRIORITY_OPTIONS = [ const PRIORITY_OPTIONS = [
@@ -67,6 +68,21 @@ const TASK_TYPE_OPTIONS = [
{ value: TaskType.ADMINISTRATIVE, label: "Administrative" }, { value: TaskType.ADMINISTRATIVE, label: "Administrative" },
]; ];
// What each task type produces. All types follow the same full git workflow
// (branch, commits, PR) — this only classifies the kind of artifact.
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
[TaskType.DOCUMENTATION]: "Documentation updates. Follows the full git workflow.",
[TaskType.RESEARCH]:
"Research findings, committed as notes. Follows the full git workflow.",
[TaskType.PLANNING]:
"Plans or architecture, committed as docs. Follows the full git workflow.",
[TaskType.DESIGN]:
"Designs or specs, committed as assets. Follows the full git workflow.",
[TaskType.ADMINISTRATIVE]:
"Process docs, committed as notes. Follows the full git workflow.",
};
// Initial status options (only PENDING and BACKLOG for creation) // Initial status options (only PENDING and BACKLOG for creation)
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: TaskStatus.PENDING, label: "Pending (Ready for work)" }, { value: TaskStatus.PENDING, label: "Pending (Ready for work)" },
@@ -406,7 +422,9 @@ export function CreateTaskDialog() {
{/* Task Type */} {/* Task Type */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Task Type</Label> <HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label>
</HelpTip>
<Select <Select
value={taskType} value={taskType}
onValueChange={(v) => setTaskType(v as TaskType)} onValueChange={(v) => setTaskType(v as TaskType)}
@@ -1,6 +1,19 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { FileCheck, GitPullRequest, Check, X } from "lucide-react"; import { FileCheck, GitPullRequest, Check, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
// The compact badges below abbreviate to a single word ("Docs"/"Pending",
// "PR"/"Pending") that reads ambiguously out of context — these spell out
// what each state actually means.
const DOCS_DESCRIPTIONS = {
complete: "Documentation for this task is complete.",
pending: "Documentation has not been written yet.",
};
const PR_DESCRIPTIONS = {
created: "A pull request has been opened for this task.",
pending: "No pull request has been opened yet.",
};
interface DocsStatusBadgeProps { interface DocsStatusBadgeProps {
docsComplete?: boolean; docsComplete?: boolean;
@@ -24,32 +37,42 @@ export function DocsStatusBadge({
return ( return (
<div className={cn("flex items-center gap-1", className)}> <div className={cn("flex items-center gap-1", className)}>
{docsComplete !== undefined && ( {docsComplete !== undefined && (
<Badge <HelpTip
variant="outline" label={
className={cn( docsComplete ? DOCS_DESCRIPTIONS.complete : DOCS_DESCRIPTIONS.pending
"text-xs", }
docsComplete
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}
> >
<FileCheck className="h-3 w-3 mr-1" /> <Badge
{docsComplete ? "Docs" : "Pending"} variant="outline"
</Badge> className={cn(
"text-xs",
docsComplete
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}
>
<FileCheck className="h-3 w-3 mr-1" />
{docsComplete ? "Docs" : "Pending"}
</Badge>
</HelpTip>
)} )}
{prCreated !== undefined && ( {prCreated !== undefined && (
<Badge <HelpTip
variant="outline" label={prCreated ? PR_DESCRIPTIONS.created : PR_DESCRIPTIONS.pending}
className={cn(
"text-xs",
prCreated
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}
> >
<GitPullRequest className="h-3 w-3 mr-1" /> <Badge
{prCreated ? "PR" : "Pending"} variant="outline"
</Badge> className={cn(
"text-xs",
prCreated
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}
>
<GitPullRequest className="h-3 w-3 mr-1" />
{prCreated ? "PR" : "Pending"}
</Badge>
</HelpTip>
)} )}
</div> </div>
); );
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { MarkdownEditor } from "./markdown-editor"; import { MarkdownEditor } from "./markdown-editor";
import { AgentSelector } from "@/components/agents/agent-selector"; import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector"; import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
// Priority options (0=P0 highest, 3=P3 lowest) // Priority options (0=P0 highest, 3=P3 lowest)
const PRIORITY_OPTIONS = [ const PRIORITY_OPTIONS = [
@@ -62,6 +63,21 @@ const TASK_TYPE_OPTIONS = [
{ value: TaskType.ADMINISTRATIVE, label: "Administrative" }, { value: TaskType.ADMINISTRATIVE, label: "Administrative" },
]; ];
// What each task type produces. All types follow the same full git workflow
// (branch, commits, PR) — this only classifies the kind of artifact.
const TASK_TYPE_DESCRIPTIONS: Record<TaskType, string> = {
[TaskType.CODE]: "Source code changes. Follows the full git workflow.",
[TaskType.DOCUMENTATION]: "Documentation updates. Follows the full git workflow.",
[TaskType.RESEARCH]:
"Research findings, committed as notes. Follows the full git workflow.",
[TaskType.PLANNING]:
"Plans or architecture, committed as docs. Follows the full git workflow.",
[TaskType.DESIGN]:
"Designs or specs, committed as assets. Follows the full git workflow.",
[TaskType.ADMINISTRATIVE]:
"Process docs, committed as notes. Follows the full git workflow.",
};
interface EditTaskDialogProps { interface EditTaskDialogProps {
task: Task; task: Task;
open: boolean; open: boolean;
@@ -290,7 +306,9 @@ function EditTaskDialogInner({
{/* Task Type */} {/* Task Type */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Task Type</Label> <HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label>
</HelpTip>
<Select <Select
value={taskType} value={taskType}
onValueChange={(v) => setTaskType(v as TaskType)} onValueChange={(v) => setTaskType(v as TaskType)}
+55 -33
View File
@@ -5,6 +5,7 @@ import { GitBranch, GitPullRequest, FileCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Task, TaskStatus } from "@/types"; import { Task, TaskStatus } from "@/types";
import { branchUrl, pullUrl } from "@/lib/repo-url"; import { branchUrl, pullUrl } from "@/lib/repo-url";
import { HelpTip } from "@/components/ui/help-tip";
interface GitStatusBadgeProps { interface GitStatusBadgeProps {
task: Task; task: Task;
@@ -59,53 +60,74 @@ export function GitStatusBadge({
); );
} }
// Parallel phase indicators (for AWAITING_DOCUMENTATION) // Parallel phase indicators (for AWAITING_DOCUMENTATION). In compact mode
// these render icon-only (no text at all), so they need a tooltip + an
// aria-label to have any accessible name.
if (task.status === TaskStatus.AWAITING_DOCUMENTATION) { if (task.status === TaskStatus.AWAITING_DOCUMENTATION) {
const docsLabel = task.docs_complete
? "Documentation complete"
: "Documentation pending";
const prLabel = task.pr_created
? "Pull request opened"
: "Pull request not yet opened";
return ( return (
<div className="flex gap-1"> <div className="flex gap-1">
<Badge <HelpTip label={docsLabel}>
variant={task.docs_complete ? "default" : "outline"} <Badge
className={`gap-1 text-xs ${ variant={task.docs_complete ? "default" : "outline"}
task.docs_complete aria-label={docsLabel}
? "bg-green-500/10 text-green-600 dark:text-green-400" className={`gap-1 text-xs ${
: "text-muted-foreground" task.docs_complete
}`} ? "bg-green-500/10 text-green-600 dark:text-green-400"
> : "text-muted-foreground"
<FileCheck className="h-3 w-3" /> }`}
{compact ? "" : "Docs"} >
</Badge> <FileCheck className="h-3 w-3" />
<Badge {compact ? "" : "Docs"}
variant={task.pr_created ? "default" : "outline"} </Badge>
className={`gap-1 text-xs ${ </HelpTip>
task.pr_created <HelpTip label={prLabel}>
? "bg-green-500/10 text-green-600 dark:text-green-400" <Badge
: "text-muted-foreground" variant={task.pr_created ? "default" : "outline"}
}`} aria-label={prLabel}
> className={`gap-1 text-xs ${
<GitPullRequest className="h-3 w-3" /> task.pr_created
{compact ? "" : "PR"} ? "bg-green-500/10 text-green-600 dark:text-green-400"
</Badge> : "text-muted-foreground"
}`}
>
<GitPullRequest className="h-3 w-3" />
{compact ? "" : "PR"}
</Badge>
</HelpTip>
</div> </div>
); );
} }
// Show branch badge (when branch exists but no PR yet) // Show branch badge (when branch exists but no PR yet). Compact mode
// collapses the label to "Branch", hiding the actual name — surface it in
// the tooltip instead of only on hover-to-full-width.
if (task.branch_name) { if (task.branch_name) {
return ( return (
<MaybeLink href={branchUrl(repoUrl, task.branch_name)}> <MaybeLink href={branchUrl(repoUrl, task.branch_name)}>
<Badge variant="outline" className="gap-1 text-xs"> <HelpTip label={compact ? task.branch_name : null}>
<GitBranch className="h-3 w-3" /> <Badge variant="outline" className="gap-1 text-xs">
{compact ? "Branch" : task.branch_name} <GitBranch className="h-3 w-3" />
</Badge> {compact ? "Branch" : task.branch_name}
</Badge>
</HelpTip>
</MaybeLink> </MaybeLink>
); );
} }
// Git task without branch yet // Git task without branch yet — branches are created automatically once
// the task is claimed, so this means the task hasn't started work.
return ( return (
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground"> <HelpTip label="No branch yet — created automatically once the task is claimed.">
<GitBranch className="h-3 w-3" /> <Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
{compact ? "Git" : "No branch"} <GitBranch className="h-3 w-3" />
</Badge> {compact ? "Git" : "No branch"}
</Badge>
</HelpTip>
); );
} }
@@ -6,6 +6,7 @@ import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { Eye, Edit3 } from "lucide-react"; import { Eye, Edit3 } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface MarkdownEditorProps { interface MarkdownEditorProps {
label: string; label: string;
@@ -71,7 +72,9 @@ export function MarkdownEditor({
)} )}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Markdown supported</span> <HelpTip label="GitHub Flavored Markdown: headings, bold/italic, lists, tables, checkboxes, links, code blocks.">
<span>Markdown supported</span>
</HelpTip>
{minLength && ( {minLength && (
<span className={value.length < minLength ? "text-destructive" : ""}> <span className={value.length < minLength ? "text-destructive" : ""}>
{value.length}/{minLength} min characters {value.length}/{minLength} min characters
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react"; import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types"; import { TaskStatus, Team, TaskType, type Task } from "@/types";
import type { TaskFindingsResponse } from "@/lib/api/tasks"; import type { TaskFindingsResponse } from "@/lib/api/tasks";
@@ -140,4 +141,42 @@ describe("TabFindings", () => {
screen.getByText("… 500 more not shown (501 total)"), screen.getByText("… 500 more not shown (501 total)"),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("explains a severity badge on hover", async () => {
const response: TaskFindingsResponse = {
findings: [
{
id: "dddddddd-0000-0000-0000-000000000000",
task_id: "t1",
origin: "qa",
round: 1,
author_slug: "be-qa",
file: null,
line: null,
severity: "blocker",
criterion: null,
expected: "x",
actual: "y",
fix: null,
evidence: null,
status: "open",
addressed_by_commit: null,
resolution_note: null,
created_at: "2026-07-11T00:00:00Z",
updated_at: null,
},
],
summary: [],
total: 1,
truncated: false,
};
useTaskFindings.mockReturnValue({ data: response, isLoading: false });
render(<TabFindings task={buildTask()} />);
const user = userEvent.setup();
await user.hover(screen.getByText("blocker"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Must be fixed before this task can pass review.",
);
});
}); });
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react"; import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types"; import { TaskStatus, Team, TaskType, type Task } from "@/types";
@@ -96,4 +97,18 @@ describe("TaskDescription", () => {
fireEvent.click(screen.getByRole("tab", { name: /preview/i })); fireEvent.click(screen.getByRole("tab", { name: /preview/i }));
expect(screen.getByText("Editable text.")).toBeInTheDocument(); expect(screen.getByText("Editable text.")).toBeInTheDocument();
}); });
it("explains the icon-only cancel button on hover", async () => {
const task = buildTask({ description: "Editable text." });
render(<TaskDescription task={task} />);
fireEvent.click(screen.getByRole("button", { name: /^edit$/i }));
const cancelButton = screen.getByRole("button", { name: "Cancel edit" });
const user = userEvent.setup();
await user.hover(cancelButton);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Discard changes without saving",
);
});
}); });
@@ -5,6 +5,8 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GitCommit, Clock, User } from "lucide-react"; import { GitCommit, Clock, User } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils"; import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface CommitCardProps { interface CommitCardProps {
commit: CommitRef; commit: CommitRef;
@@ -44,9 +46,11 @@ export function CommitCard({ commit }: CommitCardProps) {
{/* 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 variant="outline" className="font-mono text-xs"> <HelpTip label="Short git commit hash (first 7 characters)">
{commit.hash.slice(0, 7)} <Badge variant="outline" className="font-mono text-xs">
</Badge> {commit.hash.slice(0, 7)}
</Badge>
</HelpTip>
{/* Author */} {/* Author */}
{commit.author_agent_id && ( {commit.author_agent_id && (
@@ -57,10 +61,12 @@ export function CommitCard({ commit }: CommitCardProps) {
)} )}
{/* Time */} {/* Time */}
<span className="flex items-center gap-1"> <HelpTip label={formatAbsoluteTimestamp(commit.timestamp)}>
<Clock className="h-3 w-3" /> <span className="flex items-center gap-1">
{formatTime(commit.timestamp)} <Clock className="h-3 w-3" />
</span> {formatTime(commit.timestamp)}
</span>
</HelpTip>
</div> </div>
</div> </div>
</div> </div>
@@ -1,13 +1,21 @@
"use client"; "use client";
import { Task } from "@/types"; import { Task, TaskStatus } from "@/types";
import { useTaskCollisionMap } from "@/hooks/use-tasks"; import { useTaskCollisionMap } from "@/hooks/use-tasks";
import type { CollisionMap, CollisionSibling } from "@/lib/api/tasks"; import type { CollisionMap, CollisionSibling } from "@/lib/api/tasks";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
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 { taskStatusDescription } from "../task-status-badge";
import { GitBranch, AlertTriangle } from "lucide-react"; import { GitBranch, AlertTriangle } from "lucide-react";
// Reused verbatim in both the declared-surface card and each sibling card.
const MIGRATION_TIP =
"Adds a database migration. Migrations apply in a fixed order, so colliding siblings that also add one are chained serially.";
const SHARED_TIP =
"Touches a shared/common file surface. Siblings editing the same shared surface are sequenced to avoid collisions.";
interface TabCollisionProps { interface TabCollisionProps {
task: Task; task: Task;
} }
@@ -55,11 +63,13 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
{sib.title && ( {sib.title && (
<span className="text-sm font-medium truncate">{sib.title}</span> <span className="text-sm font-medium truncate">{sib.title}</span>
)} )}
<Badge <HelpTip label={taskStatusDescription(sib.status as TaskStatus)}>
className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending} <Badge
> className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending}
{sib.status} >
</Badge> {sib.status}
</Badge>
</HelpTip>
{sib.branch_name && ( {sib.branch_name && (
<code className="text-xs text-muted-foreground flex items-center gap-1"> <code className="text-xs text-muted-foreground flex items-center gap-1">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
@@ -70,19 +80,25 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
<Badge variant="outline">#{sib.pr_number}</Badge> <Badge variant="outline">#{sib.pr_number}</Badge>
)} )}
{sib.adds_migration && ( {sib.adds_migration && (
<Badge variant="outline" className="text-amber-700"> <HelpTip label={MIGRATION_TIP}>
+migration <Badge variant="outline" className="text-amber-700">
</Badge> +migration
</Badge>
</HelpTip>
)} )}
{sib.touches_shared && ( {sib.touches_shared && (
<Badge variant="outline" className="text-orange-700"> <HelpTip label={SHARED_TIP}>
shared <Badge variant="outline" className="text-orange-700">
</Badge> shared
</Badge>
</HelpTip>
)} )}
{sib.sequence != null && ( {sib.sequence != null && (
<span className="ml-auto text-xs text-muted-foreground"> <HelpTip label="Delegation sequence — siblings with a lower sequence must reach a terminal state before this one can be claimed.">
seq {sib.sequence} <span className="ml-auto text-xs text-muted-foreground">
</span> seq {sib.sequence}
</span>
</HelpTip>
)} )}
</div> </div>
@@ -219,14 +235,18 @@ function DeclaredSurfaceCard({ data }: { data: CollisionMap }) {
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{data.adds_migration && ( {data.adds_migration && (
<Badge variant="outline" className="text-amber-700"> <HelpTip label={MIGRATION_TIP}>
adds migration <Badge variant="outline" className="text-amber-700">
</Badge> adds migration
</Badge>
</HelpTip>
)} )}
{data.touches_shared && ( {data.touches_shared && (
<Badge variant="outline" className="text-orange-700"> <HelpTip label={SHARED_TIP}>
touches shared <Badge variant="outline" className="text-orange-700">
</Badge> touches shared
</Badge>
</HelpTip>
)} )}
</div> </div>
</> </>
@@ -6,6 +6,7 @@ import type { TaskFinding } from "@/lib/api/tasks";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
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 { ListChecks } from "lucide-react"; import { ListChecks } from "lucide-react";
import { CodeSnippet } from "@/components/git/code-snippet"; import { CodeSnippet } from "@/components/git/code-snippet";
@@ -38,6 +39,22 @@ const ORIGIN_LABEL: Record<string, string> = {
ceo: "CEO", ceo: "CEO",
}; };
// Per-state description maps (task-status-badge.tsx idiom), local to the
// findings-ledger domain (roboco/foundation/policy/conventions/findings.py).
const SEVERITY_DESCRIPTIONS: Record<string, string> = {
blocker: "Must be fixed before this task can pass review.",
major: "A significant defect; should be fixed but isn't review-blocking alone.",
minor: "A smaller defect worth fixing.",
nit: "A nitpick — cosmetic or stylistic; fix if convenient.",
};
const STATUS_DESCRIPTIONS: Record<string, string> = {
open: "Not yet addressed by the assignee.",
addressed: "The assignee says this is fixed — awaiting reviewer verification.",
verified: "A reviewer confirmed the fix.",
waived: "Explicitly waived — no fix required.",
};
function FindingCard({ function FindingCard({
finding, finding,
branch, branch,
@@ -49,12 +66,16 @@ function FindingCard({
<Card> <Card>
<CardContent className="pt-4 space-y-2"> <CardContent className="pt-4 space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}> <HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
{finding.severity} <Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
</Badge> {finding.severity}
<Badge variant="outline" className={STATUS_CLASS[finding.status]}> </Badge>
{finding.status} </HelpTip>
</Badge> <HelpTip label={STATUS_DESCRIPTIONS[finding.status]}>
<Badge variant="outline" className={STATUS_CLASS[finding.status]}>
{finding.status}
</Badge>
</HelpTip>
{finding.file && ( {finding.file && (
<code className="text-xs text-muted-foreground"> <code className="text-xs text-muted-foreground">
{finding.file} {finding.file}
@@ -67,9 +88,11 @@ function FindingCard({
</span> </span>
)} )}
{finding.addressed_by_commit && ( {finding.addressed_by_commit && (
<code className="ml-auto text-xs text-muted-foreground"> <HelpTip label="Short git commit hash (first 7 characters) that addressed this finding">
{finding.addressed_by_commit.slice(0, 7)} <code className="ml-auto text-xs text-muted-foreground">
</code> {finding.addressed_by_commit.slice(0, 7)}
</code>
</HelpTip>
)} )}
</div> </div>
{finding.file && ( {finding.file && (
@@ -8,6 +8,7 @@ import { WorkSessionCard } from "./work-session-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { HelpTip } from "@/components/ui/help-tip";
import Link from "next/link"; import Link from "next/link";
interface TabOverviewProps { interface TabOverviewProps {
@@ -23,13 +24,15 @@ export function TabOverview({ task }: TabOverviewProps) {
<CardContent className="py-3"> <CardContent className="py-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Subtask of:</span> <span className="text-muted-foreground">Subtask of:</span>
<Link <HelpTip label={task.parent_task_id}>
prefetch={false} <Link
href={`/tasks/${task.parent_task_id}`} prefetch={false}
className="text-primary hover:underline font-medium" href={`/tasks/${task.parent_task_id}`}
> className="text-primary hover:underline font-medium"
Parent Task #{task.parent_task_id.slice(0, 8)} >
</Link> Parent Task #{task.parent_task_id.slice(0, 8)}
</Link>
</HelpTip>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { HelpTip } from "@/components/ui/help-tip";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { CollapsibleSection } from "./collapsible-section"; import { CollapsibleSection } from "./collapsible-section";
@@ -46,6 +47,15 @@ const severityColors: Record<string, string> = {
high: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", high: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
}; };
// Plain-language explanation per risk severity, in the per-state description
// map idiom (see task-status-badge.tsx) — local to this domain since risk
// severity isn't the shared TaskStatus enum.
const severityDescriptions: Record<string, string> = {
low: "Low risk — unlikely to happen or easy to recover from.",
medium: "Medium risk — worth watching; could cause moderate rework.",
high: "High risk — likely or costly; needs an explicit mitigation plan.",
};
// Generate a simple unique ID // Generate a simple unique ID
function generateId(): string { function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -131,9 +141,16 @@ function ApproachSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button size="sm" variant="ghost" onClick={handleCancel}> <HelpTip label="Discard changes without saving">
<X className="h-4 w-4" /> <Button
</Button> size="sm"
variant="ghost"
onClick={handleCancel}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button <Button
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
@@ -340,14 +357,17 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</Badge> </Badge>
)} )}
</span> </span>
<Button <HelpTip label="Delete this sub-task">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => handleDelete(subtask.id)} variant="ghost"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive" onClick={() => handleDelete(subtask.id)}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
<Trash2 className="h-3 w-3" /> aria-label="Delete sub-task"
</Button> >
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</> </>
)} )}
</div> </div>
@@ -369,26 +389,32 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
placeholder="Add sub-task..." placeholder="Add sub-task..."
className="h-8 text-sm flex-1" className="h-8 text-sm flex-1"
/> />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => { variant="ghost"
setNewTitle(""); onClick={() => {
setIsAdding(false); setNewTitle("");
}} setIsAdding(false);
className="h-7 w-7 p-0" }}
> className="h-7 w-7 p-0"
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleAdd} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Add sub-task">
disabled={!newTitle.trim()} <Button
className="h-7 w-7 p-0" size="sm"
> onClick={handleAdd}
<Check className="h-4 w-4" /> onMouseDown={(e) => e.preventDefault()}
</Button> disabled={!newTitle.trim()}
className="h-7 w-7 p-0"
aria-label="Add sub-task"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div> </div>
)} )}
{subTasks.length === 0 && !isAdding && ( {subTasks.length === 0 && !isAdding && (
@@ -519,14 +545,17 @@ function TechConsiderationsSection({
> >
{item} {item}
</span> </span>
<Button <HelpTip label="Delete this consideration">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => handleDelete(idx)} variant="ghost"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive" onClick={() => handleDelete(idx)}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
<Trash2 className="h-3 w-3" /> aria-label="Delete consideration"
</Button> >
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</> </>
)} )}
</li> </li>
@@ -548,26 +577,32 @@ function TechConsiderationsSection({
placeholder="Add consideration..." placeholder="Add consideration..."
className="h-8 text-sm flex-1" className="h-8 text-sm flex-1"
/> />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => { variant="ghost"
setNewItem(""); onClick={() => {
setIsAdding(false); setNewItem("");
}} setIsAdding(false);
className="h-7 w-7 p-0" }}
> className="h-7 w-7 p-0"
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleAdd} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Add consideration">
disabled={!newItem.trim()} <Button
className="h-7 w-7 p-0" size="sm"
> onClick={handleAdd}
<Check className="h-4 w-4" /> onMouseDown={(e) => e.preventDefault()}
</Button> disabled={!newItem.trim()}
className="h-7 w-7 p-0"
aria-label="Add consideration"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</li> </li>
)} )}
</ul> </ul>
@@ -709,20 +744,26 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex-1" /> <div className="flex-1" />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => setEditingIdx(null)} variant="ghost"
> onClick={() => setEditingIdx(null)}
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleEdit} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Save changes">
> <Button
<Check className="h-4 w-4" /> size="sm"
</Button> onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div> </div>
</div> </div>
) : ( ) : (
@@ -740,21 +781,26 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
<span className="font-medium text-sm">{risk.description}</span> <span className="font-medium text-sm">{risk.description}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{risk.severity && ( {risk.severity && (
<Badge className={severityColors[risk.severity]}> <HelpTip label={severityDescriptions[risk.severity]}>
{risk.severity} <Badge className={severityColors[risk.severity]}>
</Badge> {risk.severity}
</Badge>
</HelpTip>
)} )}
<Button <HelpTip label="Delete this risk">
size="sm" <Button
variant="ghost" size="sm"
onClick={(e) => { variant="ghost"
e.stopPropagation(); onClick={(e) => {
handleDelete(idx); e.stopPropagation();
}} handleDelete(idx);
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive" }}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
<Trash2 className="h-3 w-3" /> aria-label="Delete risk"
</Button> >
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -791,25 +837,31 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex-1" /> <div className="flex-1" />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => { variant="ghost"
setNewDesc(""); onClick={() => {
setNewMit(""); setNewDesc("");
setIsAdding(false); setNewMit("");
}} setIsAdding(false);
> }}
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleAdd} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Add risk">
disabled={!newDesc.trim()} <Button
> size="sm"
<Check className="h-4 w-4" /> onClick={handleAdd}
</Button> onMouseDown={(e) => e.preventDefault()}
disabled={!newDesc.trim()}
aria-label="Add risk"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div> </div>
</div> </div>
)} )}
@@ -938,20 +990,26 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1" /> <div className="flex-1" />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => setEditingIdx(null)} variant="ghost"
> onClick={() => setEditingIdx(null)}
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleEdit} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Save changes">
> <Button
<Check className="h-4 w-4" /> size="sm"
</Button> onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div> </div>
</div> </div>
) : ( ) : (
@@ -967,17 +1025,20 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-2 mb-2">
<HelpCircle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" /> <HelpCircle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<span className="font-medium text-sm flex-1">{q.question}</span> <span className="font-medium text-sm flex-1">{q.question}</span>
<Button <HelpTip label="Delete this question">
size="sm" <Button
variant="ghost" size="sm"
onClick={(e) => { variant="ghost"
e.stopPropagation(); onClick={(e) => {
handleDelete(idx); e.stopPropagation();
}} handleDelete(idx);
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive" }}
> className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
<Trash2 className="h-3 w-3" /> aria-label="Delete question"
</Button> >
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div> </div>
{q.answer ? ( {q.answer ? (
<div className="ml-7 text-sm"> <div className="ml-7 text-sm">
@@ -1019,24 +1080,30 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1" /> <div className="flex-1" />
<Button <HelpTip label="Discard and cancel">
size="sm" <Button
variant="ghost" size="sm"
onClick={() => { variant="ghost"
setNewQuestion(""); onClick={() => {
setIsAdding(false); setNewQuestion("");
}} setIsAdding(false);
> }}
<X className="h-4 w-4" /> aria-label="Cancel"
</Button> >
<Button <X className="h-4 w-4" />
size="sm" </Button>
onClick={handleAdd} </HelpTip>
onMouseDown={(e) => e.preventDefault()} <HelpTip label="Add question">
disabled={!newQuestion.trim()} <Button
> size="sm"
<Check className="h-4 w-4" /> onClick={handleAdd}
</Button> onMouseDown={(e) => e.preventDefault()}
disabled={!newQuestion.trim()}
aria-label="Add question"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div> </div>
</div> </div>
)} )}
@@ -13,6 +13,7 @@ import {
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -194,6 +195,15 @@ export function ApproveAndMergeDialog({
); );
} }
// Explains a minChars-gated disabled submit button — the character counter
// beneath the textarea already states the minimum, but not how many more
// characters are still needed to cross it.
function remainingCharsTip(current: number, min: number): string {
const remaining = min - current;
if (remaining <= 0) return "";
return `Needs ${remaining} more character${remaining === 1 ? "" : "s"} to enable`;
}
// CEO Approve Dialog — the sign-off note is the audit record for merging to // CEO Approve Dialog — the sign-off note is the audit record for merging to
// production, so it is REQUIRED and must be substantive (>= 20 chars), matching // production, so it is REQUIRED and must be substantive (>= 20 chars), matching
// the server's CEO_NOTES_REQUIRED gate. // the server's CEO_NOTES_REQUIRED gate.
@@ -256,9 +266,11 @@ export function CeoApproveDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)}> <Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleConfirm} disabled={tooShort || isPending}> <HelpTip label={remainingCharsTip(notes.trim().length, _CEO_NOTES_MIN)}>
{isPending ? "Approving..." : "Approve & Merge"} <Button onClick={handleConfirm} disabled={tooShort || isPending}>
</Button> {isPending ? "Approving..." : "Approve & Merge"}
</Button>
</HelpTip>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -335,13 +347,15 @@ export function RequiredNotesDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)}> <Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button <HelpTip label={remainingCharsTip(text.trim().length, minChars)}>
variant={destructive ? "destructive" : "default"} <Button
onClick={handleConfirm} variant={destructive ? "destructive" : "default"}
disabled={tooShort || isPending} onClick={handleConfirm}
> disabled={tooShort || isPending}
{isPending ? "Working..." : confirmLabel} >
</Button> {isPending ? "Working..." : confirmLabel}
</Button>
</HelpTip>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { HelpTip } from "@/components/ui/help-tip";
import { CollapsibleSection } from "./collapsible-section"; import { CollapsibleSection } from "./collapsible-section";
import { Edit3, Eye, Check, X, ShieldAlert } from "lucide-react"; import { Edit3, Eye, Check, X, ShieldAlert } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -102,14 +103,17 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button <HelpTip label="Discard changes without saving">
size="sm" <Button
variant="ghost" size="sm"
onClick={handleCancel} variant="ghost"
disabled={updateTask.isPending} onClick={handleCancel}
> disabled={updateTask.isPending}
<X className="h-4 w-4" /> aria-label="Cancel edit"
</Button> >
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button <Button
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
@@ -32,6 +32,7 @@ import { toast } from "sonner";
import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils"; import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils";
import { branchUrl } from "@/lib/repo-url"; import { branchUrl } from "@/lib/repo-url";
import { CopyButton } from "@/components/ui/copy-button"; import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskTypeBadge } from "../task-type-badge"; import { TaskTypeBadge } from "../task-type-badge";
import { DocsStatusBadge } from "../docs-status-badge"; import { DocsStatusBadge } from "../docs-status-badge";
import Link from "next/link"; import Link from "next/link";
@@ -333,7 +334,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Hash className="h-4 w-4" /> <Hash className="h-4 w-4" />
Sequence Sequence
</div> </div>
<span className="font-medium">#{task.sequence}</span> <HelpTip label="Delegation order among same-parent siblings — a sibling with a strictly lower sequence must reach a terminal state before this task can be claimed. Ties run in parallel.">
<span className="font-medium">#{task.sequence}</span>
</HelpTip>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -425,18 +428,26 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Briefcase className="h-4 w-4" /> <Briefcase className="h-4 w-4" />
Nature Nature
</div> </div>
<Badge <HelpTip
variant="outline" label={
className={
task.nature === TaskNature.TECHNICAL task.nature === TaskNature.TECHNICAL
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" ? "Code/dev work — routes through the normal PM chain."
: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" : "Strategic work (product/marketing) — routes to the Board for review."
} }
> >
{task.nature === TaskNature.TECHNICAL <Badge
? "Technical" variant="outline"
: "Non-Technical"} className={
</Badge> task.nature === TaskNature.TECHNICAL
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
}
>
{task.nature === TaskNature.TECHNICAL
? "Technical"
: "Non-Technical"}
</Badge>
</HelpTip>
</CardContent> </CardContent>
</Card> </Card>
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } 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 { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { import {
GitBranch, GitBranch,
GitPullRequest, GitPullRequest,
@@ -17,34 +18,57 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { WorkSessionStatus } from "@/types"; import { WorkSessionStatus } from "@/types";
interface WorkSessionCardProps { interface WorkSessionCardProps {
taskId: string; taskId: string;
} }
// Per-state description maps (task-status-badge.tsx idiom), local to the
// WorkSession domain — a distinct lifecycle from TaskStatus.
const SESSION_STATUS_DESCRIPTIONS: Record<string, string> = {
[WorkSessionStatus.ACTIVE]: "This session's branch is still being worked on.",
[WorkSessionStatus.COMPLETED]: "The branch was merged and the session closed out.",
[WorkSessionStatus.ABANDONED]: "The session was dropped without merging.",
};
const PR_STATUS_DESCRIPTIONS: Record<string, string> = {
open: "The pull request is open and awaiting review or merge.",
merged: "The pull request was merged.",
closed: "The pull request was closed without merging.",
draft: "The pull request is a draft, not yet ready for review.",
};
function getStatusBadge(status: WorkSessionStatus) { function getStatusBadge(status: WorkSessionStatus) {
const tip = SESSION_STATUS_DESCRIPTIONS[status] ?? "";
switch (status) { switch (status) {
case WorkSessionStatus.ACTIVE: case WorkSessionStatus.ACTIVE:
return ( return (
<Badge className="bg-blue-500/10 text-blue-500"> <HelpTip label={tip}>
<Clock className="h-3 w-3 mr-1" /> <Badge className="bg-blue-500/10 text-blue-500">
Active <Clock className="h-3 w-3 mr-1" />
</Badge> Active
</Badge>
</HelpTip>
); );
case WorkSessionStatus.COMPLETED: case WorkSessionStatus.COMPLETED:
return ( return (
<Badge className="bg-green-500/10 text-green-500"> <HelpTip label={tip}>
<CheckCircle2 className="h-3 w-3 mr-1" /> <Badge className="bg-green-500/10 text-green-500">
Completed <CheckCircle2 className="h-3 w-3 mr-1" />
</Badge> Completed
</Badge>
</HelpTip>
); );
case WorkSessionStatus.ABANDONED: case WorkSessionStatus.ABANDONED:
return ( return (
<Badge variant="destructive"> <HelpTip label={tip}>
<XCircle className="h-3 w-3 mr-1" /> <Badge variant="destructive">
Abandoned <XCircle className="h-3 w-3 mr-1" />
</Badge> Abandoned
</Badge>
</HelpTip>
); );
default: default:
return <Badge variant="outline">{status}</Badge>; return <Badge variant="outline">{status}</Badge>;
@@ -53,35 +77,44 @@ function getStatusBadge(status: WorkSessionStatus) {
function getPRStatusBadge(prStatus: string | null) { function getPRStatusBadge(prStatus: string | null) {
if (!prStatus) return null; if (!prStatus) return null;
const tip = PR_STATUS_DESCRIPTIONS[prStatus] ?? "";
switch (prStatus) { switch (prStatus) {
case "open": case "open":
return ( return (
<Badge className="bg-green-500/10 text-green-500"> <HelpTip label={tip}>
<GitPullRequest className="h-3 w-3 mr-1" /> <Badge className="bg-green-500/10 text-green-500">
Open <GitPullRequest className="h-3 w-3 mr-1" />
</Badge> Open
</Badge>
</HelpTip>
); );
case "merged": case "merged":
return ( return (
<Badge className="bg-purple-500/10 text-purple-500"> <HelpTip label={tip}>
<GitPullRequest className="h-3 w-3 mr-1" /> <Badge className="bg-purple-500/10 text-purple-500">
Merged <GitPullRequest className="h-3 w-3 mr-1" />
</Badge> Merged
</Badge>
</HelpTip>
); );
case "closed": case "closed":
return ( return (
<Badge variant="destructive"> <HelpTip label={tip}>
<GitPullRequest className="h-3 w-3 mr-1" /> <Badge variant="destructive">
Closed <GitPullRequest className="h-3 w-3 mr-1" />
</Badge> Closed
</Badge>
</HelpTip>
); );
case "draft": case "draft":
return ( return (
<Badge variant="outline"> <HelpTip label={tip}>
<GitPullRequest className="h-3 w-3 mr-1" /> <Badge variant="outline">
Draft <GitPullRequest className="h-3 w-3 mr-1" />
</Badge> Draft
</Badge>
</HelpTip>
); );
default: default:
return ( return (
@@ -181,12 +214,14 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{getPRStatusBadge(session.pr_status)} {getPRStatusBadge(session.pr_status)}
</div> </div>
{session.pr_created_at && ( {session.pr_created_at && (
<p className="text-xs text-muted-foreground"> <HelpTip label={formatAbsoluteTimestamp(session.pr_created_at)}>
Created{" "} <p className="text-xs text-muted-foreground w-fit">
{formatDistanceToNow(new Date(session.pr_created_at), { Created{" "}
addSuffix: true, {formatDistanceToNow(new Date(session.pr_created_at), {
})} addSuffix: true,
</p> })}
</p>
</HelpTip>
)} )}
</div> </div>
</div> </div>
@@ -221,12 +256,14 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{session.files_modified.length !== 1 ? "s" : ""} {session.files_modified.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
<div className="text-sm text-muted-foreground ml-auto"> <HelpTip label={formatAbsoluteTimestamp(session.started_at)}>
Started{" "} <div className="text-sm text-muted-foreground ml-auto w-fit">
{formatDistanceToNow(new Date(session.started_at), { Started{" "}
addSuffix: true, {formatDistanceToNow(new Date(session.started_at), {
})} addSuffix: true,
</div> })}
</div>
</HelpTip>
</div> </div>
{/* View Full Session Link */} {/* View Full Session Link */}
+36 -20
View File
@@ -12,6 +12,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { ChevronDown, X } from "lucide-react"; import { ChevronDown, X } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface TaskFiltersProps { interface TaskFiltersProps {
searchQuery: string; searchQuery: string;
@@ -403,46 +404,61 @@ export function TaskFilters({
{statusFilter.map((status) => ( {statusFilter.map((status) => (
<Badge key={status} variant="secondary" className="gap-1"> <Badge key={status} variant="secondary" className="gap-1">
{STATUS_LABELS[status]} {STATUS_LABELS[status]}
<X <HelpTip label="Remove this filter">
className="h-3 w-3 cursor-pointer hover:text-destructive" <X
onClick={() => toggleStatus(status)} className="h-3 w-3 cursor-pointer hover:text-destructive"
/> onClick={() => toggleStatus(status)}
aria-label={`Remove ${STATUS_LABELS[status]} filter`}
/>
</HelpTip>
</Badge> </Badge>
))} ))}
{teamFilter.map((team) => ( {teamFilter.map((team) => (
<Badge key={team} variant="secondary" className="gap-1"> <Badge key={team} variant="secondary" className="gap-1">
{TEAM_LABELS[team]} {TEAM_LABELS[team]}
<X <HelpTip label="Remove this filter">
className="h-3 w-3 cursor-pointer hover:text-destructive" <X
onClick={() => toggleTeam(team)} className="h-3 w-3 cursor-pointer hover:text-destructive"
/> onClick={() => toggleTeam(team)}
aria-label={`Remove ${TEAM_LABELS[team]} filter`}
/>
</HelpTip>
</Badge> </Badge>
))} ))}
{taskTypeFilter.map((type) => ( {taskTypeFilter.map((type) => (
<Badge key={type} variant="secondary" className="gap-1"> <Badge key={type} variant="secondary" className="gap-1">
{TASK_TYPE_LABELS[type]} {TASK_TYPE_LABELS[type]}
<X <HelpTip label="Remove this filter">
className="h-3 w-3 cursor-pointer hover:text-destructive" <X
onClick={() => toggleTaskType(type)} className="h-3 w-3 cursor-pointer hover:text-destructive"
/> onClick={() => toggleTaskType(type)}
aria-label={`Remove ${TASK_TYPE_LABELS[type]} filter`}
/>
</HelpTip>
</Badge> </Badge>
))} ))}
{projectFilter.map((id) => ( {projectFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1"> <Badge key={id} variant="secondary" className="gap-1">
{projectLabel(id)} {projectLabel(id)}
<X <HelpTip label="Remove this filter">
className="h-3 w-3 cursor-pointer hover:text-destructive" <X
onClick={() => toggleProject(id)} className="h-3 w-3 cursor-pointer hover:text-destructive"
/> onClick={() => toggleProject(id)}
aria-label={`Remove ${projectLabel(id)} filter`}
/>
</HelpTip>
</Badge> </Badge>
))} ))}
{productFilter.map((id) => ( {productFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1"> <Badge key={id} variant="secondary" className="gap-1">
{productLabel(id)} {productLabel(id)}
<X <HelpTip label="Remove this filter">
className="h-3 w-3 cursor-pointer hover:text-destructive" <X
onClick={() => toggleProduct(id)} className="h-3 w-3 cursor-pointer hover:text-destructive"
/> onClick={() => toggleProduct(id)}
aria-label={`Remove ${productLabel(id)} filter`}
/>
</HelpTip>
</Badge> </Badge>
))} ))}
{(statusFilter.length > 0 || {(statusFilter.length > 0 ||
+71 -62
View File
@@ -14,6 +14,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ListTree, X } from "lucide-react"; import { ListTree, X } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "./task-status-badge";
interface TaskSelectorProps { interface TaskSelectorProps {
value: string | null; value: string | null;
@@ -33,6 +35,20 @@ const STATUS_COLORS: Partial<Record<TaskStatus, string>> = {
[TaskStatus.COMPLETED]: "bg-green-100 text-green-700", [TaskStatus.COMPLETED]: "bg-green-100 text-green-700",
}; };
// Title is truncated for layout; surface the untruncated text on hover
// rather than only when the row happens to be wide enough. No tip when
// nothing was actually cut.
function TaskTitleCell({ title, maxLen }: { title: string; maxLen: number }) {
const cut = title.length > maxLen;
return (
<HelpTip label={cut ? title : null}>
<span className="truncate">
{cut ? `${title.slice(0, maxLen)}...` : title}
</span>
</HelpTip>
);
}
export function TaskSelector({ export function TaskSelector({
value, value,
onChange, onChange,
@@ -114,11 +130,6 @@ export function TaskSelector({
} }
}; };
const truncateTitle = (title: string, maxLen = 40) => {
if (title.length <= maxLen) return title;
return title.slice(0, maxLen) + "...";
};
return ( return (
<Select <Select
value={value || ""} value={value || ""}
@@ -130,9 +141,7 @@ export function TaskSelector({
{selectedTask ? ( {selectedTask ? (
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<ListTree className="h-4 w-4 shrink-0" /> <ListTree className="h-4 w-4 shrink-0" />
<span className="truncate"> <TaskTitleCell title={selectedTask.title} maxLen={40} />
{truncateTitle(selectedTask.title)}
</span>
</div> </div>
) : ( ) : (
placeholder placeholder
@@ -156,15 +165,15 @@ export function TaskSelector({
{groupedTasks.board.slice(0, 10).map((task) => ( {groupedTasks.board.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -178,15 +187,15 @@ export function TaskSelector({
{groupedTasks.main_pm.slice(0, 10).map((task) => ( {groupedTasks.main_pm.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -200,15 +209,15 @@ export function TaskSelector({
{groupedTasks.backend.slice(0, 10).map((task) => ( {groupedTasks.backend.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -222,15 +231,15 @@ export function TaskSelector({
{groupedTasks.frontend.slice(0, 10).map((task) => ( {groupedTasks.frontend.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -244,15 +253,15 @@ export function TaskSelector({
{groupedTasks.ux_ui.slice(0, 10).map((task) => ( {groupedTasks.ux_ui.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@@ -266,15 +275,15 @@ export function TaskSelector({
{groupedTasks.marketing.slice(0, 10).map((task) => ( {groupedTasks.marketing.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}> <SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate"> <TaskTitleCell title={task.title} maxLen={30} />
{truncateTitle(task.title, 30)} <HelpTip label={taskStatusDescription(task.status)}>
</span> <Badge
<Badge variant="secondary"
variant="secondary" className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
className={`text-xs ${STATUS_COLORS[task.status] || ""}`} >
> {task.status.replace(/_/g, " ")}
{task.status.replace(/_/g, " ")} </Badge>
</Badge> </HelpTip>
</div> </div>
</SelectItem> </SelectItem>
))} ))}