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