mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & Start
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user