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
+57 -4
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
data-state with the tooltip's — re-assert the real selection
state explicitly so data-[state=active] styling survives
(same fix as task-detail/task-tabs.tsx). */}
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="dev"
data-state={view === "dev" ? "active" : "inactive"}
className="gap-2"
>
<Code className="h-4 w-4" /> <Code className="h-4 w-4" />
Developer Developer
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="qa" className="gap-2"> </TooltipTrigger>
<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" /> <TestTube className="h-4 w-4" />
QA QA
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pr-review" className="gap-2"> </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" /> <GitPullRequest className="h-4 w-4" />
PR Review PR Review
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pm" className="gap-2"> </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" /> <ClipboardList className="h-4 w-4" />
PM PM
</TabsTrigger> </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">
+21 -2
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">
<HelpTip label="Files changed on disk that haven't been staged for the next commit yet">
<span className="inline-flex items-center gap-1">
Working Directory Working Directory
{unstagedCount > 0 && ( {unstagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]"> <Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{unstagedCount} {unstagedCount}
</Badge> </Badge>
)} )}
</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="staged" className="text-xs gap-1"> <TabsTrigger value="staged" className="text-xs gap-1">
<HelpTip label="Files staged and ready to be included in the next commit">
<span className="inline-flex items-center gap-1">
Staged Staged
{stagedCount > 0 && ( {stagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]"> <Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{stagedCount} {stagedCount}
</Badge> </Badge>
)} )}
</span>
</HelpTip>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
+23 -3
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 && (
<HelpTip label="Local commits not yet pushed to the remote.">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<ArrowUp className="h-3 w-3 mr-1" /> <ArrowUp className="h-3 w-3 mr-1" />
{status.ahead} ahead {status.ahead} ahead
</Badge> </Badge>
</HelpTip>
)} )}
{status.behind > 0 && ( {status.behind > 0 && (
<HelpTip label="Remote commits not yet merged into your local branch.">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<ArrowDown className="h-3 w-3 mr-1" /> <ArrowDown className="h-3 w-3 mr-1" />
{status.behind} behind {status.behind} behind
</Badge> </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}>
<h4 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-1 w-fit">
Staged ({status.staged_files.length}) Staged ({status.staged_files.length})
</h4> </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}>
<h4 className="text-xs font-semibold text-orange-600 uppercase tracking-wider mb-1 w-fit">
Modified ({status.unstaged_files.length}) Modified ({status.unstaged_files.length})
</h4> </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}>
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-1 w-fit">
Untracked ({status.untracked_files.length}) Untracked ({status.untracked_files.length})
</h4> </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 (
<HelpTip label={taskStatusDescription(TaskStatus.BLOCKED)}>
<Badge variant="destructive" className="text-xs gap-1"> <Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
Blocked Blocked
</Badge> </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 (
<HelpTip label={priorityDescriptions[priority] ?? priorityDescriptions[2]}>
<Badge className={priorityColors[priority] ?? priorityColors[2]}> <Badge className={priorityColors[priority] ?? priorityColors[2]}>
{priorityLabels[priority] ?? "P2 - Medium"} {priorityLabels[priority] ?? "P2 - Medium"}
</Badge> </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>
<HelpTip label="Each becomes a real root-subtask with its own project, branch, and PR.">
<Badge variant="secondary" className="shrink-0 text-xs"> <Badge variant="secondary" className="shrink-0 text-xs">
{batch.drafts.length} tasks {batch.drafts.length} tasks
</Badge> </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.">
<p className="mb-1 w-fit text-xs font-medium text-muted-foreground">
Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"}) Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"})
</p> </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,6 +277,7 @@ 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 */}
<HelpTip label="Routes the whole MegaTask through the Product Owner and Head of Marketing for review before any work starts.">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -280,7 +291,9 @@ export function BatchReviewCard({
)} )}
Board review &amp; Start Board review &amp; Start
</Button> </Button>
</HelpTip>
{/* Approve & Start → straight to the Main PM, waves dispatch at once */} {/* Approve & Start → straight to the Main PM, waves dispatch at once */}
<HelpTip label="Skips board review — dispatches wave 1 to the Main PM immediately.">
<Button <Button
size="sm" size="sm"
onClick={() => onConfirm("main_pm")} onClick={() => onConfirm("main_pm")}
@@ -293,6 +306,7 @@ export function BatchReviewCard({
)} )}
Approve &amp; Start Approve &amp; Start
</Button> </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">
<HelpTip label="Each is a real root-subtask with its own project, branch, and PR, coordinated by the Main PM.">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"} {rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
</Badge> </Badge>
</HelpTip>
<HelpTip label="Tasks in the same wave don't overlap and run in parallel; later waves wait for their dependencies to land first.">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{waveCount} wave{waveCount === 1 ? "" : "s"} {waveCount} wave{waveCount === 1 ? "" : "s"}
</Badge> </Badge>
</HelpTip>
<HelpTip label={`Full umbrella task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)} ID: {taskId.slice(0, 8)}
</span> </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,6 +171,7 @@ 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 */}
<HelpTip label="Routes this task through the Product Owner and Head of Marketing for review before any work starts.">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -177,7 +185,9 @@ export function DraftProposalCard({
)} )}
Board review &amp; Start Board review &amp; Start
</Button> </Button>
</HelpTip>
{/* Approve & Start → PENDING, straight to Main PM (skip the board) */} {/* Approve & Start → PENDING, straight to Main PM (skip the board) */}
<HelpTip label="Skips board review — dispatches this task immediately.">
<Button <Button
size="sm" size="sm"
onClick={() => onStart("main_pm")} onClick={() => onStart("main_pm")}
@@ -190,6 +200,7 @@ export function DraftProposalCard({
)} )}
Approve &amp; Start Approve &amp; Start
</Button> </Button>
</HelpTip>
</CardFooter> </CardFooter>
</Card> </Card>
); );
+29 -3
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,6 +239,20 @@ export function IntakeForm({
/> />
</div> </div>
{/* Button's own disabled:pointer-events-none would swallow hover, so
the tip sits on a wrapping span (a well-worn disabled-tooltip
workaround) rather than the Button itself. */}
<HelpTip
label={
!isValid && !isPreparing
? "Pick a scope above and describe what you want to build to continue."
: null
}
>
<span
className="block w-full"
tabIndex={!isValid && !isPreparing ? 0 : undefined}
>
<Button <Button
className="w-full" className="w-full"
onClick={onStart} onClick={onStart}
@@ -243,6 +267,8 @@ export function IntakeForm({
"Start chatting" "Start chatting"
)} )}
</Button> </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>
<HelpTip label={`Full task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)} ID: {taskId.slice(0, 8)}
</span> </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">
<HelpTip label="Minimum 20 characters — this is the permanent audit record for starting the work.">
<Label htmlFor="approve-and-start-notes"> <Label htmlFor="approve-and-start-notes">
Approval notes (required) Approval notes (required)
</Label> </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">
<HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label> <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,6 +37,11 @@ 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 && (
<HelpTip
label={
docsComplete ? DOCS_DESCRIPTIONS.complete : DOCS_DESCRIPTIONS.pending
}
>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -36,8 +54,12 @@ export function DocsStatusBadge({
<FileCheck className="h-3 w-3 mr-1" /> <FileCheck className="h-3 w-3 mr-1" />
{docsComplete ? "Docs" : "Pending"} {docsComplete ? "Docs" : "Pending"}
</Badge> </Badge>
</HelpTip>
)} )}
{prCreated !== undefined && ( {prCreated !== undefined && (
<HelpTip
label={prCreated ? PR_DESCRIPTIONS.created : PR_DESCRIPTIONS.pending}
>
<Badge <Badge
variant="outline" variant="outline"
className={cn( className={cn(
@@ -50,6 +72,7 @@ export function DocsStatusBadge({
<GitPullRequest className="h-3 w-3 mr-1" /> <GitPullRequest className="h-3 w-3 mr-1" />
{prCreated ? "PR" : "Pending"} {prCreated ? "PR" : "Pending"}
</Badge> </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">
<HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label> <Label>Task Type</Label>
</HelpTip>
<Select <Select
value={taskType} value={taskType}
onValueChange={(v) => setTaskType(v as TaskType)} onValueChange={(v) => setTaskType(v as TaskType)}
@@ -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,12 +60,22 @@ 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">
<HelpTip label={docsLabel}>
<Badge <Badge
variant={task.docs_complete ? "default" : "outline"} variant={task.docs_complete ? "default" : "outline"}
aria-label={docsLabel}
className={`gap-1 text-xs ${ className={`gap-1 text-xs ${
task.docs_complete task.docs_complete
? "bg-green-500/10 text-green-600 dark:text-green-400" ? "bg-green-500/10 text-green-600 dark:text-green-400"
@@ -74,8 +85,11 @@ export function GitStatusBadge({
<FileCheck className="h-3 w-3" /> <FileCheck className="h-3 w-3" />
{compact ? "" : "Docs"} {compact ? "" : "Docs"}
</Badge> </Badge>
</HelpTip>
<HelpTip label={prLabel}>
<Badge <Badge
variant={task.pr_created ? "default" : "outline"} variant={task.pr_created ? "default" : "outline"}
aria-label={prLabel}
className={`gap-1 text-xs ${ className={`gap-1 text-xs ${
task.pr_created task.pr_created
? "bg-green-500/10 text-green-600 dark:text-green-400" ? "bg-green-500/10 text-green-600 dark:text-green-400"
@@ -85,27 +99,35 @@ export function GitStatusBadge({
<GitPullRequest className="h-3 w-3" /> <GitPullRequest className="h-3 w-3" />
{compact ? "" : "PR"} {compact ? "" : "PR"}
</Badge> </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)}>
<HelpTip label={compact ? task.branch_name : null}>
<Badge variant="outline" className="gap-1 text-xs"> <Badge variant="outline" className="gap-1 text-xs">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{compact ? "Branch" : task.branch_name} {compact ? "Branch" : task.branch_name}
</Badge> </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 (
<HelpTip label="No branch yet — created automatically once the task is claimed.">
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground"> <Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{compact ? "Git" : "No branch"} {compact ? "Git" : "No branch"}
</Badge> </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">
<HelpTip label="GitHub Flavored Markdown: headings, bold/italic, lists, tables, checkboxes, links, code blocks.">
<span>Markdown supported</span> <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 */}
<HelpTip label="Short git commit hash (first 7 characters)">
<Badge variant="outline" className="font-mono text-xs"> <Badge variant="outline" className="font-mono text-xs">
{commit.hash.slice(0, 7)} {commit.hash.slice(0, 7)}
</Badge> </Badge>
</HelpTip>
{/* Author */} {/* Author */}
{commit.author_agent_id && ( {commit.author_agent_id && (
@@ -57,10 +61,12 @@ export function CommitCard({ commit }: CommitCardProps) {
)} )}
{/* Time */} {/* Time */}
<HelpTip label={formatAbsoluteTimestamp(commit.timestamp)}>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{formatTime(commit.timestamp)} {formatTime(commit.timestamp)}
</span> </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>
)} )}
<HelpTip label={taskStatusDescription(sib.status as TaskStatus)}>
<Badge <Badge
className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending} className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending}
> >
{sib.status} {sib.status}
</Badge> </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 && (
<HelpTip label={MIGRATION_TIP}>
<Badge variant="outline" className="text-amber-700"> <Badge variant="outline" className="text-amber-700">
+migration +migration
</Badge> </Badge>
</HelpTip>
)} )}
{sib.touches_shared && ( {sib.touches_shared && (
<HelpTip label={SHARED_TIP}>
<Badge variant="outline" className="text-orange-700"> <Badge variant="outline" className="text-orange-700">
shared shared
</Badge> </Badge>
</HelpTip>
)} )}
{sib.sequence != null && ( {sib.sequence != null && (
<HelpTip label="Delegation sequence — siblings with a lower sequence must reach a terminal state before this one can be claimed.">
<span className="ml-auto text-xs text-muted-foreground"> <span className="ml-auto text-xs text-muted-foreground">
seq {sib.sequence} seq {sib.sequence}
</span> </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 && (
<HelpTip label={MIGRATION_TIP}>
<Badge variant="outline" className="text-amber-700"> <Badge variant="outline" className="text-amber-700">
adds migration adds migration
</Badge> </Badge>
</HelpTip>
)} )}
{data.touches_shared && ( {data.touches_shared && (
<HelpTip label={SHARED_TIP}>
<Badge variant="outline" className="text-orange-700"> <Badge variant="outline" className="text-orange-700">
touches shared touches shared
</Badge> </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">
<HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}> <Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
{finding.severity} {finding.severity}
</Badge> </Badge>
</HelpTip>
<HelpTip label={STATUS_DESCRIPTIONS[finding.status]}>
<Badge variant="outline" className={STATUS_CLASS[finding.status]}> <Badge variant="outline" className={STATUS_CLASS[finding.status]}>
{finding.status} {finding.status}
</Badge> </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 && (
<HelpTip label="Short git commit hash (first 7 characters) that addressed this finding">
<code className="ml-auto text-xs text-muted-foreground"> <code className="ml-auto text-xs text-muted-foreground">
{finding.addressed_by_commit.slice(0, 7)} {finding.addressed_by_commit.slice(0, 7)}
</code> </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,6 +24,7 @@ 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>
<HelpTip label={task.parent_task_id}>
<Link <Link
prefetch={false} prefetch={false}
href={`/tasks/${task.parent_task_id}`} href={`/tasks/${task.parent_task_id}`}
@@ -30,6 +32,7 @@ export function TabOverview({ task }: TabOverviewProps) {
> >
Parent Task #{task.parent_task_id.slice(0, 8)} Parent Task #{task.parent_task_id.slice(0, 8)}
</Link> </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">
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
aria-label="Cancel"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </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>
<HelpTip label="Delete this sub-task">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleDelete(subtask.id)} onClick={() => handleDelete(subtask.id)}
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"
aria-label="Delete sub-task"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</HelpTip>
</> </>
)} )}
</div> </div>
@@ -369,6 +389,7 @@ 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"
/> />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -377,18 +398,23 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
setIsAdding(false); setIsAdding(false);
}} }}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Add sub-task">
<Button <Button
size="sm" size="sm"
onClick={handleAdd} onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={!newTitle.trim()} disabled={!newTitle.trim()}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
aria-label="Add sub-task"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </Button>
</HelpTip>
</div> </div>
)} )}
{subTasks.length === 0 && !isAdding && ( {subTasks.length === 0 && !isAdding && (
@@ -519,14 +545,17 @@ function TechConsiderationsSection({
> >
{item} {item}
</span> </span>
<HelpTip label="Delete this consideration">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleDelete(idx)} onClick={() => 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"
aria-label="Delete consideration"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</HelpTip>
</> </>
)} )}
</li> </li>
@@ -548,6 +577,7 @@ function TechConsiderationsSection({
placeholder="Add consideration..." placeholder="Add consideration..."
className="h-8 text-sm flex-1" className="h-8 text-sm flex-1"
/> />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -556,18 +586,23 @@ function TechConsiderationsSection({
setIsAdding(false); setIsAdding(false);
}} }}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Add consideration">
<Button <Button
size="sm" size="sm"
onClick={handleAdd} onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={!newItem.trim()} disabled={!newItem.trim()}
className="h-7 w-7 p-0" className="h-7 w-7 p-0"
aria-label="Add consideration"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </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" />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setEditingIdx(null)} onClick={() => setEditingIdx(null)}
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Save changes">
<Button <Button
size="sm" size="sm"
onClick={handleEdit} onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
) : ( ) : (
@@ -740,10 +781,13 @@ 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 && (
<HelpTip label={severityDescriptions[risk.severity]}>
<Badge className={severityColors[risk.severity]}> <Badge className={severityColors[risk.severity]}>
{risk.severity} {risk.severity}
</Badge> </Badge>
</HelpTip>
)} )}
<HelpTip label="Delete this risk">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -752,9 +796,11 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
handleDelete(idx); 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"
aria-label="Delete risk"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -791,6 +837,7 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex-1" /> <div className="flex-1" />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -799,17 +846,22 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
setNewMit(""); setNewMit("");
setIsAdding(false); setIsAdding(false);
}} }}
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Add risk">
<Button <Button
size="sm" size="sm"
onClick={handleAdd} onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={!newDesc.trim()} disabled={!newDesc.trim()}
aria-label="Add risk"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </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" />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setEditingIdx(null)} onClick={() => setEditingIdx(null)}
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Save changes">
<Button <Button
size="sm" size="sm"
onClick={handleEdit} onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </Button>
</HelpTip>
</div> </div>
</div> </div>
) : ( ) : (
@@ -967,6 +1025,7 @@ 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>
<HelpTip label="Delete this question">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -975,9 +1034,11 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
handleDelete(idx); 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"
aria-label="Delete question"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</HelpTip>
</div> </div>
{q.answer ? ( {q.answer ? (
<div className="ml-7 text-sm"> <div className="ml-7 text-sm">
@@ -1019,6 +1080,7 @@ 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" />
<HelpTip label="Discard and cancel">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -1026,17 +1088,22 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
setNewQuestion(""); setNewQuestion("");
setIsAdding(false); setIsAdding(false);
}} }}
aria-label="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</HelpTip>
<HelpTip label="Add question">
<Button <Button
size="sm" size="sm"
onClick={handleAdd} onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
disabled={!newQuestion.trim()} disabled={!newQuestion.trim()}
aria-label="Add question"
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</Button> </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>
<HelpTip label={remainingCharsTip(notes.trim().length, _CEO_NOTES_MIN)}>
<Button onClick={handleConfirm} disabled={tooShort || isPending}> <Button onClick={handleConfirm} disabled={tooShort || isPending}>
{isPending ? "Approving..." : "Approve & Merge"} {isPending ? "Approving..." : "Approve & Merge"}
</Button> </Button>
</HelpTip>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -335,6 +347,7 @@ export function RequiredNotesDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)}> <Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<HelpTip label={remainingCharsTip(text.trim().length, minChars)}>
<Button <Button
variant={destructive ? "destructive" : "default"} variant={destructive ? "destructive" : "default"}
onClick={handleConfirm} onClick={handleConfirm}
@@ -342,6 +355,7 @@ export function RequiredNotesDialog({
> >
{isPending ? "Working..." : confirmLabel} {isPending ? "Working..." : confirmLabel}
</Button> </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>
<HelpTip label="Discard changes without saving">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={handleCancel} onClick={handleCancel}
disabled={updateTask.isPending} disabled={updateTask.isPending}
aria-label="Cancel edit"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </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>
<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> <span className="font-medium">#{task.sequence}</span>
</HelpTip>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -425,6 +428,13 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Briefcase className="h-4 w-4" /> <Briefcase className="h-4 w-4" />
Nature Nature
</div> </div>
<HelpTip
label={
task.nature === TaskNature.TECHNICAL
? "Code/dev work — routes through the normal PM chain."
: "Strategic work (product/marketing) — routes to the Board for review."
}
>
<Badge <Badge
variant="outline" variant="outline"
className={ className={
@@ -437,6 +447,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
? "Technical" ? "Technical"
: "Non-Technical"} : "Non-Technical"}
</Badge> </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 (
<HelpTip label={tip}>
<Badge className="bg-blue-500/10 text-blue-500"> <Badge className="bg-blue-500/10 text-blue-500">
<Clock className="h-3 w-3 mr-1" /> <Clock className="h-3 w-3 mr-1" />
Active Active
</Badge> </Badge>
</HelpTip>
); );
case WorkSessionStatus.COMPLETED: case WorkSessionStatus.COMPLETED:
return ( return (
<HelpTip label={tip}>
<Badge className="bg-green-500/10 text-green-500"> <Badge className="bg-green-500/10 text-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" /> <CheckCircle2 className="h-3 w-3 mr-1" />
Completed Completed
</Badge> </Badge>
</HelpTip>
); );
case WorkSessionStatus.ABANDONED: case WorkSessionStatus.ABANDONED:
return ( return (
<HelpTip label={tip}>
<Badge variant="destructive"> <Badge variant="destructive">
<XCircle className="h-3 w-3 mr-1" /> <XCircle className="h-3 w-3 mr-1" />
Abandoned Abandoned
</Badge> </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 (
<HelpTip label={tip}>
<Badge className="bg-green-500/10 text-green-500"> <Badge className="bg-green-500/10 text-green-500">
<GitPullRequest className="h-3 w-3 mr-1" /> <GitPullRequest className="h-3 w-3 mr-1" />
Open Open
</Badge> </Badge>
</HelpTip>
); );
case "merged": case "merged":
return ( return (
<HelpTip label={tip}>
<Badge className="bg-purple-500/10 text-purple-500"> <Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" /> <GitPullRequest className="h-3 w-3 mr-1" />
Merged Merged
</Badge> </Badge>
</HelpTip>
); );
case "closed": case "closed":
return ( return (
<HelpTip label={tip}>
<Badge variant="destructive"> <Badge variant="destructive">
<GitPullRequest className="h-3 w-3 mr-1" /> <GitPullRequest className="h-3 w-3 mr-1" />
Closed Closed
</Badge> </Badge>
</HelpTip>
); );
case "draft": case "draft":
return ( return (
<HelpTip label={tip}>
<Badge variant="outline"> <Badge variant="outline">
<GitPullRequest className="h-3 w-3 mr-1" /> <GitPullRequest className="h-3 w-3 mr-1" />
Draft Draft
</Badge> </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)}>
<p className="text-xs text-muted-foreground w-fit">
Created{" "} Created{" "}
{formatDistanceToNow(new Date(session.pr_created_at), { {formatDistanceToNow(new Date(session.pr_created_at), {
addSuffix: true, 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)}>
<div className="text-sm text-muted-foreground ml-auto w-fit">
Started{" "} Started{" "}
{formatDistanceToNow(new Date(session.started_at), { {formatDistanceToNow(new Date(session.started_at), {
addSuffix: true, addSuffix: true,
})} })}
</div> </div>
</HelpTip>
</div> </div>
{/* View Full Session Link */} {/* View Full Session Link */}
@@ -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]}
<HelpTip label="Remove this filter">
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleStatus(status)} 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]}
<HelpTip label="Remove this filter">
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTeam(team)} 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]}
<HelpTip label="Remove this filter">
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTaskType(type)} 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)}
<HelpTip label="Remove this filter">
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProject(id)} 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)}
<HelpTip label="Remove this filter">
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProduct(id)} onClick={() => toggleProduct(id)}
aria-label={`Remove ${productLabel(id)} filter`}
/> />
</HelpTip>
</Badge> </Badge>
))} ))}
{(statusFilter.length > 0 || {(statusFilter.length > 0 ||
+35 -26
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>
))} ))}