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";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { pickTab } from "@/lib/tabs";
import { Code, TestTube, GitPullRequest, ClipboardList } from "lucide-react";
@@ -35,22 +40,70 @@ function KanbanPageContent() {
<div className="space-y-6">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList>
<TabsTrigger value="dev" className="gap-2">
<Code className="h-4 w-4" />
Developer
</TabsTrigger>
<TabsTrigger value="qa" className="gap-2">
<TestTube className="h-4 w-4" />
QA
</TabsTrigger>
<TabsTrigger value="pr-review" className="gap-2">
<GitPullRequest className="h-4 w-4" />
PR Review
</TabsTrigger>
<TabsTrigger value="pm" className="gap-2">
<ClipboardList className="h-4 w-4" />
PM
</TabsTrigger>
{/* 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" />
Developer
</TabsTrigger>
</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" />
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>
<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 { cn } from "@/lib/utils";
import { FileCode, FileDiff, WrapText } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined;
@@ -124,21 +125,39 @@ export function GitDiffViewer({
<Tabs defaultValue="unstaged">
<div className="px-4 border-b">
<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">
Working Directory
{unstagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
{unstagedCount}
</Badge>
)}
<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
{unstagedCount > 0 && (
<Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{unstagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger>
<TabsTrigger value="staged" className="text-xs gap-1">
Staged
{stagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
{stagedCount}
</Badge>
)}
<HelpTip label="Files staged and ready to be included in the next commit">
<span className="inline-flex items-center gap-1">
Staged
{stagedCount > 0 && (
<Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{stagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger>
</TabsList>
</div>
+37 -17
View File
@@ -14,12 +14,22 @@ import {
ArrowDown,
CheckCircle,
} from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitStatusPanelProps {
status: GitStatusResponse | undefined;
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) {
if (isLoading) {
return (
@@ -77,16 +87,20 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{(status.ahead > 0 || status.behind > 0) && (
<div className="flex items-center gap-2">
{status.ahead > 0 && (
<Badge variant="secondary" className="text-xs">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead} ahead
</Badge>
<HelpTip label="Local commits not yet pushed to the remote.">
<Badge variant="secondary" className="text-xs">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead} ahead
</Badge>
</HelpTip>
)}
{status.behind > 0 && (
<Badge variant="secondary" className="text-xs">
<ArrowDown className="h-3 w-3 mr-1" />
{status.behind} behind
</Badge>
<HelpTip label="Remote commits not yet merged into your local branch.">
<Badge variant="secondary" className="text-xs">
<ArrowDown className="h-3 w-3 mr-1" />
{status.behind} behind
</Badge>
</HelpTip>
)}
</div>
)}
@@ -99,9 +113,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Staged Files */}
{status.staged_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-1">
Staged ({status.staged_files.length})
</h4>
<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})
</h4>
</HelpTip>
<div className="space-y-0.5">
{status.staged_files.map((file) => (
<div
@@ -121,9 +137,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Unstaged Files */}
{status.unstaged_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-orange-600 uppercase tracking-wider mb-1">
Modified ({status.unstaged_files.length})
</h4>
<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})
</h4>
</HelpTip>
<div className="space-y-0.5">
{status.unstaged_files.map((file) => (
<div
@@ -143,9 +161,11 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
{/* Untracked Files */}
{status.untracked_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-1">
Untracked ({status.untracked_files.length})
</h4>
<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})
</h4>
</HelpTip>
<div className="space-y-0.5">
{status.untracked_files.map((file) => (
<div
@@ -1,13 +1,18 @@
"use client";
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";
export function BlockedBadge() {
return (
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Blocked
</Badge>
<HelpTip label={taskStatusDescription(TaskStatus.BLOCKED)}>
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Blocked
</Badge>
</HelpTip>
);
}
@@ -1,6 +1,7 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
interface PriorityIndicatorProps {
priority: number;
@@ -20,10 +21,19 @@ const priorityLabels: Record<number, string> = {
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) {
return (
<Badge className={priorityColors[priority] ?? priorityColors[2]}>
{priorityLabels[priority] ?? "P2 - Medium"}
</Badge>
<HelpTip label={priorityDescriptions[priority] ?? priorityDescriptions[2]}>
<Badge className={priorityColors[priority] ?? priorityColors[2]}>
{priorityLabels[priority] ?? "P2 - Medium"}
</Badge>
</HelpTip>
);
}
@@ -124,11 +124,16 @@ export function BatchReviewCard({
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<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>
<Badge variant="secondary" className="shrink-0 text-xs">
{batch.drafts.length} tasks
</Badge>
<HelpTip label="Each becomes a real root-subtask with its own project, branch, and PR.">
<Badge variant="secondary" className="shrink-0 text-xs">
{batch.drafts.length} tasks
</Badge>
</HelpTip>
</div>
<p className="text-xs text-muted-foreground">
One batch, sequenced into conflict-free waves. Each task keeps its own
@@ -191,7 +196,10 @@ export function BatchReviewCard({
: "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>
{CELL_TEAMS.map((cell) => {
const repos = scopedByCell(cell);
@@ -231,9 +239,11 @@ export function BatchReviewCard({
{/* Wave plan — how the batch will be sequenced */}
{waves && waves.length > 0 && (
<div className="rounded-md border border-dashed px-3 py-2">
<p className="mb-1 text-xs font-medium text-muted-foreground">
Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"})
</p>
<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"})
</p>
</HelpTip>
<ol className="space-y-0.5">
{waves.map((wave, w) => (
<li
@@ -267,32 +277,36 @@ export function BatchReviewCard({
Keep chatting
</Button>
{/* Board review & Start → the Board reviews the whole batch first */}
<Button
variant="secondary"
size="sm"
onClick={() => onConfirm("board")}
disabled={isLaunching || missingProject}
>
{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" />
)}
Board review &amp; Start
</Button>
<HelpTip label="Routes the whole MegaTask through the Product Owner and Head of Marketing for review before any work starts.">
<Button
variant="secondary"
size="sm"
onClick={() => onConfirm("board")}
disabled={isLaunching || missingProject}
>
{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" />
)}
Board review &amp; Start
</Button>
</HelpTip>
{/* Approve & Start → straight to the Main PM, waves dispatch at once */}
<Button
size="sm"
onClick={() => onConfirm("main_pm")}
disabled={isLaunching || missingProject}
>
{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" />
)}
Approve &amp; Start
</Button>
<HelpTip label="Skips board review — dispatches wave 1 to the Main PM immediately.">
<Button
size="sm"
onClick={() => onConfirm("main_pm")}
disabled={isLaunching || missingProject}
>
{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" />
)}
Approve &amp; Start
</Button>
</HelpTip>
</div>
</CardContent>
</Card>
@@ -11,6 +11,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
interface BoardReviewSentCardProps {
/** 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">
<p className="text-sm font-medium">{taskTitle}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="text-xs">
{waveCount} wave{waveCount === 1 ? "" : "s"}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
</span>
<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">
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
</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">
{waveCount} wave{waveCount === 1 ? "" : "s"}
</Badge>
</HelpTip>
<HelpTip label={`Full umbrella task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
</span>
</HelpTip>
</div>
<p className="text-xs text-muted-foreground">
The Product Owner and Head of Marketing are reviewing this MegaTask.
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import type { DraftProposal } from "@/lib/api/prompter";
import type { StartRoute } from "@/hooks/use-prompter";
@@ -116,7 +117,13 @@ export function DraftProposalCard({
{distinctTeams.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<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>
{distinctTeams.map((team) => (
<Badge key={team} variant="outline" className="text-xs">
@@ -164,32 +171,36 @@ export function DraftProposalCard({
Keep chatting
</Button>
{/* Board review & Start → PENDING, assigned to PO + HoM for review */}
<Button
variant="secondary"
size="sm"
onClick={() => onStart("board")}
disabled={isLaunching}
>
{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" />
)}
Board review &amp; Start
</Button>
<HelpTip label="Routes this task through the Product Owner and Head of Marketing for review before any work starts.">
<Button
variant="secondary"
size="sm"
onClick={() => onStart("board")}
disabled={isLaunching}
>
{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" />
)}
Board review &amp; Start
</Button>
</HelpTip>
{/* Approve & Start → PENDING, straight to Main PM (skip the board) */}
<Button
size="sm"
onClick={() => onStart("main_pm")}
disabled={isLaunching}
>
{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" />
)}
Approve &amp; Start
</Button>
<HelpTip label="Skips board review — dispatches this task immediately.">
<Button
size="sm"
onClick={() => onStart("main_pm")}
disabled={isLaunching}
>
{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" />
)}
Approve &amp; Start
</Button>
</HelpTip>
</CardFooter>
</Card>
);
+42 -16
View File
@@ -17,6 +17,7 @@ import {
import { useProjects } from "@/hooks/use-projects";
import { useProducts } from "@/hooks/use-products";
import type { TargetKind } from "@/hooks/use-prompter";
import { HelpTip } from "@/components/ui/help-tip";
interface IntakeFormProps {
targetKind: TargetKind;
@@ -111,14 +112,23 @@ export function IntakeForm({
onValueChange={(v) => onTargetKind(v as TargetKind)}
>
<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}>
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 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 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>
</TabsList>
</Tabs>
@@ -229,20 +239,36 @@ export function IntakeForm({
/>
</div>
<Button
className="w-full"
onClick={onStart}
disabled={!isValid || isPreparing}
{/* 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
}
>
{isPreparing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Preparing the agent
</>
) : (
"Start chatting"
)}
</Button>
<span
className="block w-full"
tabIndex={!isValid && !isPreparing ? 0 : undefined}
>
<Button
className="w-full"
onClick={onStart}
disabled={!isValid || isPreparing}
>
{isPreparing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Preparing the agent
</>
) : (
"Start chatting"
)}
</Button>
</span>
</HelpTip>
{isPreparing && (
<div className="space-y-1.5" aria-live="polite">
@@ -11,6 +11,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HelpTip } from "@/components/ui/help-tip";
import type { Team } from "@/types";
interface SuccessCardProps {
@@ -43,9 +44,11 @@ export function SuccessCard({
<Badge variant="secondary" className="text-xs">
{team.replace("_", " ")}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
</span>
<HelpTip label={`Full task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
</span>
</HelpTip>
</div>
</CardContent>
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const { mutateAsync } = vi.hoisted(() => ({
mutateAsync: vi.fn().mockResolvedValue(undefined),
@@ -204,3 +205,19 @@ describe("CreateTaskDialog — project/product mutual exclusivity (F085)", () =>
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 type { Task } from "@/types";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface ApproveAndStartButtonProps {
task: Task;
@@ -124,9 +125,11 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
</div>
<div className="space-y-2">
<Label htmlFor="approve-and-start-notes">
Approval notes (required)
</Label>
<HelpTip label="Minimum 20 characters — this is the permanent audit record for starting the work.">
<Label htmlFor="approve-and-start-notes">
Approval notes (required)
</Label>
</HelpTip>
<Textarea
id="approve-and-start-notes"
placeholder="Board review read; requirements are clear. Build it..."
@@ -35,6 +35,7 @@ import { DependencySelector } from "./dependency-selector";
import { TaskSelector } from "./task-selector";
import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
// Priority options (0=P0 highest, 3=P3 lowest)
const PRIORITY_OPTIONS = [
@@ -67,6 +68,21 @@ const TASK_TYPE_OPTIONS = [
{ 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)
const STATUS_OPTIONS = [
{ value: TaskStatus.PENDING, label: "Pending (Ready for work)" },
@@ -406,7 +422,9 @@ export function CreateTaskDialog() {
{/* Task Type */}
<div className="space-y-2">
<Label>Task Type</Label>
<HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label>
</HelpTip>
<Select
value={taskType}
onValueChange={(v) => setTaskType(v as TaskType)}
@@ -1,6 +1,19 @@
import { Badge } from "@/components/ui/badge";
import { FileCheck, GitPullRequest, Check, X } from "lucide-react";
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 {
docsComplete?: boolean;
@@ -24,32 +37,42 @@ export function DocsStatusBadge({
return (
<div className={cn("flex items-center gap-1", className)}>
{docsComplete !== undefined && (
<Badge
variant="outline"
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",
)}
<HelpTip
label={
docsComplete ? DOCS_DESCRIPTIONS.complete : DOCS_DESCRIPTIONS.pending
}
>
<FileCheck className="h-3 w-3 mr-1" />
{docsComplete ? "Docs" : "Pending"}
</Badge>
<Badge
variant="outline"
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 && (
<Badge
variant="outline"
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",
)}
<HelpTip
label={prCreated ? PR_DESCRIPTIONS.created : PR_DESCRIPTIONS.pending}
>
<GitPullRequest className="h-3 w-3 mr-1" />
{prCreated ? "PR" : "Pending"}
</Badge>
<Badge
variant="outline"
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>
);
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { MarkdownEditor } from "./markdown-editor";
import { AgentSelector } from "@/components/agents/agent-selector";
import { ProjectSelector } from "@/components/projects/project-selector";
import { HelpTip } from "@/components/ui/help-tip";
// Priority options (0=P0 highest, 3=P3 lowest)
const PRIORITY_OPTIONS = [
@@ -62,6 +63,21 @@ const TASK_TYPE_OPTIONS = [
{ 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 {
task: Task;
open: boolean;
@@ -290,7 +306,9 @@ function EditTaskDialogInner({
{/* Task Type */}
<div className="space-y-2">
<Label>Task Type</Label>
<HelpTip label={TASK_TYPE_DESCRIPTIONS[taskType]}>
<Label>Task Type</Label>
</HelpTip>
<Select
value={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 { Task, TaskStatus } from "@/types";
import { branchUrl, pullUrl } from "@/lib/repo-url";
import { HelpTip } from "@/components/ui/help-tip";
interface GitStatusBadgeProps {
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) {
const docsLabel = task.docs_complete
? "Documentation complete"
: "Documentation pending";
const prLabel = task.pr_created
? "Pull request opened"
: "Pull request not yet opened";
return (
<div className="flex gap-1">
<Badge
variant={task.docs_complete ? "default" : "outline"}
className={`gap-1 text-xs ${
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>
<Badge
variant={task.pr_created ? "default" : "outline"}
className={`gap-1 text-xs ${
task.pr_created
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "text-muted-foreground"
}`}
>
<GitPullRequest className="h-3 w-3" />
{compact ? "" : "PR"}
</Badge>
<HelpTip label={docsLabel}>
<Badge
variant={task.docs_complete ? "default" : "outline"}
aria-label={docsLabel}
className={`gap-1 text-xs ${
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>
</HelpTip>
<HelpTip label={prLabel}>
<Badge
variant={task.pr_created ? "default" : "outline"}
aria-label={prLabel}
className={`gap-1 text-xs ${
task.pr_created
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "text-muted-foreground"
}`}
>
<GitPullRequest className="h-3 w-3" />
{compact ? "" : "PR"}
</Badge>
</HelpTip>
</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) {
return (
<MaybeLink href={branchUrl(repoUrl, task.branch_name)}>
<Badge variant="outline" className="gap-1 text-xs">
<GitBranch className="h-3 w-3" />
{compact ? "Branch" : task.branch_name}
</Badge>
<HelpTip label={compact ? task.branch_name : null}>
<Badge variant="outline" className="gap-1 text-xs">
<GitBranch className="h-3 w-3" />
{compact ? "Branch" : task.branch_name}
</Badge>
</HelpTip>
</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 (
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
<GitBranch className="h-3 w-3" />
{compact ? "Git" : "No branch"}
</Badge>
<HelpTip label="No branch yet — created automatically once the task is claimed.">
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
<GitBranch className="h-3 w-3" />
{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 { Markdown } from "@/components/ui/markdown";
import { Eye, Edit3 } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface MarkdownEditorProps {
label: string;
@@ -71,7 +72,9 @@ export function MarkdownEditor({
)}
<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 && (
<span className={value.length < minLength ? "text-destructive" : ""}>
{value.length}/{minLength} min characters
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import type { TaskFindingsResponse } from "@/lib/api/tasks";
@@ -140,4 +141,42 @@ describe("TabFindings", () => {
screen.getByText("… 500 more not shown (501 total)"),
).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 { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
@@ -96,4 +97,18 @@ describe("TaskDescription", () => {
fireEvent.click(screen.getByRole("tab", { name: /preview/i }));
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 { GitCommit, Clock, User } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface CommitCardProps {
commit: CommitRef;
@@ -44,9 +46,11 @@ export function CommitCard({ commit }: CommitCardProps) {
{/* Meta info */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* Hash */}
<Badge variant="outline" className="font-mono text-xs">
{commit.hash.slice(0, 7)}
</Badge>
<HelpTip label="Short git commit hash (first 7 characters)">
<Badge variant="outline" className="font-mono text-xs">
{commit.hash.slice(0, 7)}
</Badge>
</HelpTip>
{/* Author */}
{commit.author_agent_id && (
@@ -57,10 +61,12 @@ export function CommitCard({ commit }: CommitCardProps) {
)}
{/* Time */}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(commit.timestamp)}
</span>
<HelpTip label={formatAbsoluteTimestamp(commit.timestamp)}>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(commit.timestamp)}
</span>
</HelpTip>
</div>
</div>
</div>
@@ -1,13 +1,21 @@
"use client";
import { Task } from "@/types";
import { Task, TaskStatus } from "@/types";
import { useTaskCollisionMap } from "@/hooks/use-tasks";
import type { CollisionMap, CollisionSibling } from "@/lib/api/tasks";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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";
// 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 {
task: Task;
}
@@ -55,11 +63,13 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
{sib.title && (
<span className="text-sm font-medium truncate">{sib.title}</span>
)}
<Badge
className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending}
>
{sib.status}
</Badge>
<HelpTip label={taskStatusDescription(sib.status as TaskStatus)}>
<Badge
className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending}
>
{sib.status}
</Badge>
</HelpTip>
{sib.branch_name && (
<code className="text-xs text-muted-foreground flex items-center gap-1">
<GitBranch className="h-3 w-3" />
@@ -70,19 +80,25 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
<Badge variant="outline">#{sib.pr_number}</Badge>
)}
{sib.adds_migration && (
<Badge variant="outline" className="text-amber-700">
+migration
</Badge>
<HelpTip label={MIGRATION_TIP}>
<Badge variant="outline" className="text-amber-700">
+migration
</Badge>
</HelpTip>
)}
{sib.touches_shared && (
<Badge variant="outline" className="text-orange-700">
shared
</Badge>
<HelpTip label={SHARED_TIP}>
<Badge variant="outline" className="text-orange-700">
shared
</Badge>
</HelpTip>
)}
{sib.sequence != null && (
<span className="ml-auto text-xs text-muted-foreground">
seq {sib.sequence}
</span>
<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">
seq {sib.sequence}
</span>
</HelpTip>
)}
</div>
@@ -219,14 +235,18 @@ function DeclaredSurfaceCard({ data }: { data: CollisionMap }) {
)}
<div className="flex flex-wrap gap-2">
{data.adds_migration && (
<Badge variant="outline" className="text-amber-700">
adds migration
</Badge>
<HelpTip label={MIGRATION_TIP}>
<Badge variant="outline" className="text-amber-700">
adds migration
</Badge>
</HelpTip>
)}
{data.touches_shared && (
<Badge variant="outline" className="text-orange-700">
touches shared
</Badge>
<HelpTip label={SHARED_TIP}>
<Badge variant="outline" className="text-orange-700">
touches shared
</Badge>
</HelpTip>
)}
</div>
</>
@@ -6,6 +6,7 @@ import type { TaskFinding } from "@/lib/api/tasks";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ListChecks } from "lucide-react";
import { CodeSnippet } from "@/components/git/code-snippet";
@@ -38,6 +39,22 @@ const ORIGIN_LABEL: Record<string, string> = {
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({
finding,
branch,
@@ -49,12 +66,16 @@ function FindingCard({
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
{finding.severity}
</Badge>
<Badge variant="outline" className={STATUS_CLASS[finding.status]}>
{finding.status}
</Badge>
<HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
{finding.severity}
</Badge>
</HelpTip>
<HelpTip label={STATUS_DESCRIPTIONS[finding.status]}>
<Badge variant="outline" className={STATUS_CLASS[finding.status]}>
{finding.status}
</Badge>
</HelpTip>
{finding.file && (
<code className="text-xs text-muted-foreground">
{finding.file}
@@ -67,9 +88,11 @@ function FindingCard({
</span>
)}
{finding.addressed_by_commit && (
<code className="ml-auto text-xs text-muted-foreground">
{finding.addressed_by_commit.slice(0, 7)}
</code>
<HelpTip label="Short git commit hash (first 7 characters) that addressed this finding">
<code className="ml-auto text-xs text-muted-foreground">
{finding.addressed_by_commit.slice(0, 7)}
</code>
</HelpTip>
)}
</div>
{finding.file && (
@@ -8,6 +8,7 @@ import { WorkSessionCard } from "./work-session-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
import { HelpTip } from "@/components/ui/help-tip";
import Link from "next/link";
interface TabOverviewProps {
@@ -23,13 +24,15 @@ export function TabOverview({ task }: TabOverviewProps) {
<CardContent className="py-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Subtask of:</span>
<Link
prefetch={false}
href={`/tasks/${task.parent_task_id}`}
className="text-primary hover:underline font-medium"
>
Parent Task #{task.parent_task_id.slice(0, 8)}
</Link>
<HelpTip label={task.parent_task_id}>
<Link
prefetch={false}
href={`/tasks/${task.parent_task_id}`}
className="text-primary hover:underline font-medium"
>
Parent Task #{task.parent_task_id.slice(0, 8)}
</Link>
</HelpTip>
</div>
</CardContent>
</Card>
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { HelpTip } from "@/components/ui/help-tip";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown";
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",
};
// 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
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -131,9 +141,16 @@ function ApproachSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" variant="ghost" onClick={handleCancel}>
<X className="h-4 w-4" />
</Button>
<HelpTip label="Discard changes without saving">
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button
size="sm"
onClick={handleSave}
@@ -340,14 +357,17 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</Badge>
)}
</span>
<Button
size="sm"
variant="ghost"
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" />
</Button>
<HelpTip label="Delete this sub-task">
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(subtask.id)}
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" />
</Button>
</HelpTip>
</>
)}
</div>
@@ -369,26 +389,32 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
placeholder="Add sub-task..."
className="h-8 text-sm flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewTitle("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newTitle.trim()}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewTitle("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Add sub-task">
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newTitle.trim()}
className="h-7 w-7 p-0"
aria-label="Add sub-task"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
)}
{subTasks.length === 0 && !isAdding && (
@@ -519,14 +545,17 @@ function TechConsiderationsSection({
>
{item}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(idx)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<HelpTip label="Delete this consideration">
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(idx)}
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" />
</Button>
</HelpTip>
</>
)}
</li>
@@ -548,26 +577,32 @@ function TechConsiderationsSection({
placeholder="Add consideration..."
className="h-8 text-sm flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewItem("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newItem.trim()}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewItem("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Add consideration">
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newItem.trim()}
className="h-7 w-7 p-0"
aria-label="Add consideration"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</li>
)}
</ul>
@@ -709,20 +744,26 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</SelectContent>
</Select>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIdx(null)}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIdx(null)}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save changes">
<Button
size="sm"
onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
</div>
) : (
@@ -740,21 +781,26 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
<span className="font-medium text-sm">{risk.description}</span>
<div className="flex items-center gap-2">
{risk.severity && (
<Badge className={severityColors[risk.severity]}>
{risk.severity}
</Badge>
<HelpTip label={severityDescriptions[risk.severity]}>
<Badge className={severityColors[risk.severity]}>
{risk.severity}
</Badge>
</HelpTip>
)}
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(idx);
}}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<HelpTip label="Delete this risk">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(idx);
}}
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" />
</Button>
</HelpTip>
</div>
</div>
<div className="text-sm text-muted-foreground">
@@ -791,25 +837,31 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</SelectContent>
</Select>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewDesc("");
setNewMit("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newDesc.trim()}
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewDesc("");
setNewMit("");
setIsAdding(false);
}}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Add risk">
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newDesc.trim()}
aria-label="Add risk"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
</div>
)}
@@ -938,20 +990,26 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
/>
<div className="flex items-center gap-2">
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIdx(null)}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => setEditingIdx(null)}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save changes">
<Button
size="sm"
onClick={handleEdit}
onMouseDown={(e) => e.preventDefault()}
aria-label="Save changes"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
</div>
) : (
@@ -967,17 +1025,20 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
<div className="flex items-start gap-2 mb-2">
<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>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(idx);
}}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<HelpTip label="Delete this question">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(idx);
}}
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" />
</Button>
</HelpTip>
</div>
{q.answer ? (
<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-1" />
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewQuestion("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newQuestion.trim()}
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewQuestion("");
setIsAdding(false);
}}
aria-label="Cancel"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Add question">
<Button
size="sm"
onClick={handleAdd}
onMouseDown={(e) => e.preventDefault()}
disabled={!newQuestion.trim()}
aria-label="Add question"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
</div>
)}
@@ -13,6 +13,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { HelpTip } from "@/components/ui/help-tip";
import {
Select,
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
// production, so it is REQUIRED and must be substantive (>= 20 chars), matching
// the server's CEO_NOTES_REQUIRED gate.
@@ -256,9 +266,11 @@ export function CeoApproveDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={tooShort || isPending}>
{isPending ? "Approving..." : "Approve & Merge"}
</Button>
<HelpTip label={remainingCharsTip(notes.trim().length, _CEO_NOTES_MIN)}>
<Button onClick={handleConfirm} disabled={tooShort || isPending}>
{isPending ? "Approving..." : "Approve & Merge"}
</Button>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -335,13 +347,15 @@ export function RequiredNotesDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={tooShort || isPending}
>
{isPending ? "Working..." : confirmLabel}
</Button>
<HelpTip label={remainingCharsTip(text.trim().length, minChars)}>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={tooShort || isPending}
>
{isPending ? "Working..." : confirmLabel}
</Button>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown";
import { HelpTip } from "@/components/ui/help-tip";
import { CollapsibleSection } from "./collapsible-section";
import { Edit3, Eye, Check, X, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
@@ -102,14 +103,17 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
>
<X className="h-4 w-4" />
</Button>
<HelpTip label="Discard changes without saving">
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
aria-label="Cancel edit"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button
size="sm"
onClick={handleSave}
@@ -32,6 +32,7 @@ import { toast } from "sonner";
import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils";
import { branchUrl } from "@/lib/repo-url";
import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskTypeBadge } from "../task-type-badge";
import { DocsStatusBadge } from "../docs-status-badge";
import Link from "next/link";
@@ -333,7 +334,9 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Hash className="h-4 w-4" />
Sequence
</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>
</Card>
)}
@@ -425,18 +428,26 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Briefcase className="h-4 w-4" />
Nature
</div>
<Badge
variant="outline"
className={
<HelpTip
label={
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"
? "Code/dev work — routes through the normal PM chain."
: "Strategic work (product/marketing) — routes to the Board for review."
}
>
{task.nature === TaskNature.TECHNICAL
? "Technical"
: "Non-Technical"}
</Badge>
<Badge
variant="outline"
className={
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>
</Card>
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import {
GitBranch,
GitPullRequest,
@@ -17,34 +18,57 @@ import {
} from "lucide-react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { WorkSessionStatus } from "@/types";
interface WorkSessionCardProps {
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) {
const tip = SESSION_STATUS_DESCRIPTIONS[status] ?? "";
switch (status) {
case WorkSessionStatus.ACTIVE:
return (
<Badge className="bg-blue-500/10 text-blue-500">
<Clock className="h-3 w-3 mr-1" />
Active
</Badge>
<HelpTip label={tip}>
<Badge className="bg-blue-500/10 text-blue-500">
<Clock className="h-3 w-3 mr-1" />
Active
</Badge>
</HelpTip>
);
case WorkSessionStatus.COMPLETED:
return (
<Badge className="bg-green-500/10 text-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
Completed
</Badge>
<HelpTip label={tip}>
<Badge className="bg-green-500/10 text-green-500">
<CheckCircle2 className="h-3 w-3 mr-1" />
Completed
</Badge>
</HelpTip>
);
case WorkSessionStatus.ABANDONED:
return (
<Badge variant="destructive">
<XCircle className="h-3 w-3 mr-1" />
Abandoned
</Badge>
<HelpTip label={tip}>
<Badge variant="destructive">
<XCircle className="h-3 w-3 mr-1" />
Abandoned
</Badge>
</HelpTip>
);
default:
return <Badge variant="outline">{status}</Badge>;
@@ -53,35 +77,44 @@ function getStatusBadge(status: WorkSessionStatus) {
function getPRStatusBadge(prStatus: string | null) {
if (!prStatus) return null;
const tip = PR_STATUS_DESCRIPTIONS[prStatus] ?? "";
switch (prStatus) {
case "open":
return (
<Badge className="bg-green-500/10 text-green-500">
<GitPullRequest className="h-3 w-3 mr-1" />
Open
</Badge>
<HelpTip label={tip}>
<Badge className="bg-green-500/10 text-green-500">
<GitPullRequest className="h-3 w-3 mr-1" />
Open
</Badge>
</HelpTip>
);
case "merged":
return (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
Merged
</Badge>
<HelpTip label={tip}>
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
Merged
</Badge>
</HelpTip>
);
case "closed":
return (
<Badge variant="destructive">
<GitPullRequest className="h-3 w-3 mr-1" />
Closed
</Badge>
<HelpTip label={tip}>
<Badge variant="destructive">
<GitPullRequest className="h-3 w-3 mr-1" />
Closed
</Badge>
</HelpTip>
);
case "draft":
return (
<Badge variant="outline">
<GitPullRequest className="h-3 w-3 mr-1" />
Draft
</Badge>
<HelpTip label={tip}>
<Badge variant="outline">
<GitPullRequest className="h-3 w-3 mr-1" />
Draft
</Badge>
</HelpTip>
);
default:
return (
@@ -181,12 +214,14 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{getPRStatusBadge(session.pr_status)}
</div>
{session.pr_created_at && (
<p className="text-xs text-muted-foreground">
Created{" "}
{formatDistanceToNow(new Date(session.pr_created_at), {
addSuffix: true,
})}
</p>
<HelpTip label={formatAbsoluteTimestamp(session.pr_created_at)}>
<p className="text-xs text-muted-foreground w-fit">
Created{" "}
{formatDistanceToNow(new Date(session.pr_created_at), {
addSuffix: true,
})}
</p>
</HelpTip>
)}
</div>
</div>
@@ -221,12 +256,14 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{session.files_modified.length !== 1 ? "s" : ""}
</span>
</div>
<div className="text-sm text-muted-foreground ml-auto">
Started{" "}
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</div>
<HelpTip label={formatAbsoluteTimestamp(session.started_at)}>
<div className="text-sm text-muted-foreground ml-auto w-fit">
Started{" "}
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</div>
</HelpTip>
</div>
{/* View Full Session Link */}
+36 -20
View File
@@ -12,6 +12,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ChevronDown, X } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface TaskFiltersProps {
searchQuery: string;
@@ -403,46 +404,61 @@ export function TaskFilters({
{statusFilter.map((status) => (
<Badge key={status} variant="secondary" className="gap-1">
{STATUS_LABELS[status]}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleStatus(status)}
/>
<HelpTip label="Remove this filter">
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleStatus(status)}
aria-label={`Remove ${STATUS_LABELS[status]} filter`}
/>
</HelpTip>
</Badge>
))}
{teamFilter.map((team) => (
<Badge key={team} variant="secondary" className="gap-1">
{TEAM_LABELS[team]}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTeam(team)}
/>
<HelpTip label="Remove this filter">
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTeam(team)}
aria-label={`Remove ${TEAM_LABELS[team]} filter`}
/>
</HelpTip>
</Badge>
))}
{taskTypeFilter.map((type) => (
<Badge key={type} variant="secondary" className="gap-1">
{TASK_TYPE_LABELS[type]}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTaskType(type)}
/>
<HelpTip label="Remove this filter">
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleTaskType(type)}
aria-label={`Remove ${TASK_TYPE_LABELS[type]} filter`}
/>
</HelpTip>
</Badge>
))}
{projectFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{projectLabel(id)}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProject(id)}
/>
<HelpTip label="Remove this filter">
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProject(id)}
aria-label={`Remove ${projectLabel(id)} filter`}
/>
</HelpTip>
</Badge>
))}
{productFilter.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{productLabel(id)}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProduct(id)}
/>
<HelpTip label="Remove this filter">
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => toggleProduct(id)}
aria-label={`Remove ${productLabel(id)} filter`}
/>
</HelpTip>
</Badge>
))}
{(statusFilter.length > 0 ||
+71 -62
View File
@@ -14,6 +14,8 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { ListTree, X } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "./task-status-badge";
interface TaskSelectorProps {
value: string | null;
@@ -33,6 +35,20 @@ const STATUS_COLORS: Partial<Record<TaskStatus, string>> = {
[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({
value,
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 (
<Select
value={value || ""}
@@ -130,9 +141,7 @@ export function TaskSelector({
{selectedTask ? (
<div className="flex items-center gap-2 overflow-hidden">
<ListTree className="h-4 w-4 shrink-0" />
<span className="truncate">
{truncateTitle(selectedTask.title)}
</span>
<TaskTitleCell title={selectedTask.title} maxLen={40} />
</div>
) : (
placeholder
@@ -156,15 +165,15 @@ export function TaskSelector({
{groupedTasks.board.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}
@@ -178,15 +187,15 @@ export function TaskSelector({
{groupedTasks.main_pm.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}
@@ -200,15 +209,15 @@ export function TaskSelector({
{groupedTasks.backend.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}
@@ -222,15 +231,15 @@ export function TaskSelector({
{groupedTasks.frontend.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}
@@ -244,15 +253,15 @@ export function TaskSelector({
{groupedTasks.ux_ui.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}
@@ -266,15 +275,15 @@ export function TaskSelector({
{groupedTasks.marketing.slice(0, 10).map((task) => (
<SelectItem key={task.id} value={task.id}>
<div className="flex items-center gap-2">
<span className="truncate">
{truncateTitle(task.title, 30)}
</span>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
<TaskTitleCell title={task.title} maxLen={30} />
<HelpTip label={taskStatusDescription(task.status)}>
<Badge
variant="secondary"
className={`text-xs ${STATUS_COLORS[task.status] || ""}`}
>
{task.status.replace(/_/g, " ")}
</Badge>
</HelpTip>
</div>
</SelectItem>
))}