fix(panel): task-detail tab state in URL, nav placement, kanban overflow, sidebar divider, tooltip sweep

- Task detail: active tab lives in ?tab= (survives reload, back/forward, and
  prev/next task jumps); prev/next arrows move into the header row next to
  Actions instead of their own row above the title
- Constraints section always starts collapsed (project boilerplate)
- Kanban: native overflow scroll replaces Radix ScrollArea (display:table
  viewport let cards grow past the column and clip); columns share width
  (flex-1, 18rem floor, 24rem cap); dark column colors normalized to /40 tints
- Sidebar footer: drop the Separator doubled with the wrapper's border-t
- Tooltips: self-providing Tooltip root (300ms) + hover hints across sidebar,
  header, task detail, kanban, and every icon-only button that had none
This commit is contained in:
Renn F
2026-07-11 10:59:26 +02:00
parent 5a0fce7da4
commit e211a3c15e
37 changed files with 891 additions and 484 deletions
@@ -2,6 +2,11 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, X, GripVertical } from "lucide-react";
@@ -70,15 +75,20 @@ export function AcceptanceCriteriaEditor({
onChange={(e) => handleUpdate(index, e.target.value)}
className="flex-1 h-8"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove this criterion</TooltipContent>
</Tooltip>
</div>
))}
</div>
@@ -4,6 +4,11 @@ import { useState } from "react";
import { useTasks } from "@/hooks/use-tasks";
import { TaskStatus } from "@/types";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
@@ -80,15 +85,20 @@ export function DependencySelector({
{task.id.slice(0, 8)}
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeTask(task.id)}
>
<X className="h-3 w-3" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => removeTask(task.id)}
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove dependency</TooltipContent>
</Tooltip>
</div>
))}
</div>
@@ -37,21 +37,22 @@ describe("TaskDescription", () => {
render(<TaskDescription task={task} />);
expect(screen.getByText("Implement the thing.")).toBeInTheDocument();
expect(
screen.getByText("Route handlers must stay thin"),
).toBeInTheDocument();
// Two independent toggles: one for Description, one for Constraints.
// Constraints is project boilerplate, so it ALWAYS starts collapsed.
const constraintsToggle = screen.getByRole("button", {
name: "Constraints",
});
expect(constraintsToggle).toHaveAttribute("aria-expanded", "true");
fireEvent.click(constraintsToggle);
expect(constraintsToggle).toHaveAttribute("aria-expanded", "false");
expect(
screen.queryByText("Route handlers must stay thin"),
).not.toBeInTheDocument();
// Collapsing Constraints doesn't affect the Description section.
fireEvent.click(constraintsToggle);
expect(constraintsToggle).toHaveAttribute("aria-expanded", "true");
expect(
screen.getByText("Route handlers must stay thin"),
).toBeInTheDocument();
// Expanding Constraints doesn't affect the Description section.
expect(screen.getByText("Implement the thing.")).toBeVisible();
});
@@ -6,6 +6,7 @@ import { useScrollRestorationStore } from "@/lib/stores";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, back: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
import { TaskListNav } from "../task-list-nav";
@@ -6,6 +6,11 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -78,24 +83,31 @@ export function CollapsibleSection({
<Collapsible open={open} onOpenChange={setOpen} className="contents">
<CardHeader className={cn("pb-3", headerClassName)}>
<div className="flex items-center justify-between gap-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={open}
>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
!open && "-rotate-90",
)}
/>
<CardTitle className="flex min-w-0 items-center gap-2 text-lg">
{title}
</CardTitle>
</button>
</CollapsibleTrigger>
<Tooltip>
<TooltipTrigger asChild>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={open}
>
<ChevronDown
aria-hidden="true"
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200",
!open && "-rotate-90",
)}
/>
<CardTitle className="flex min-w-0 items-center gap-2 text-lg">
{title}
</CardTitle>
</button>
</CollapsibleTrigger>
</TooltipTrigger>
<TooltipContent>
{open ? "Collapse section" : "Expand section"}
</TooltipContent>
</Tooltip>
{actions && (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
)}
@@ -180,6 +180,9 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
</CollapsibleSection>
{task.constraints ? (
<CollapsibleSection
// Boilerplate identical on every task in the project — always
// starts collapsed.
defaultOpen={false}
title={
<>
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
@@ -53,6 +53,11 @@ import {
import { toast } from "sonner";
import { TaskTypeBadge } from "../task-type-badge";
import { CopyButton } from "@/components/ui/copy-button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Status badge colors
const statusColors: Record<TaskStatus, string> = {
@@ -109,9 +114,11 @@ const statusLabels: Record<TaskStatus, string> = {
interface TaskHeaderProps {
task: Task;
onAction?: (action: string) => void;
/** Right-aligned slot rendered before the Actions menu (prev/next nav). */
nav?: React.ReactNode;
}
export function TaskHeader({ task, onAction }: TaskHeaderProps) {
export function TaskHeader({ task, onAction, nav }: TaskHeaderProps) {
const router = useRouter();
const deleteTask = useDeleteTask();
const updateTask = useUpdateTask();
@@ -513,12 +520,20 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
value={task.status}
onValueChange={(v) => handleStatusChange(v as TaskStatus)}
>
<SelectTrigger
className={`w-40 shrink-0 h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
disabled={isTransitionsLoading}
>
<SelectValue />
</SelectTrigger>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
className={`w-40 shrink-0 h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
disabled={isTransitionsLoading}
>
<SelectValue />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
Change status valid transitions first, the rest as forced
admin overrides
</TooltipContent>
</Tooltip>
<SelectContent>
{/* Always render the current status first so the trigger value is always present */}
<SelectItem key={task.status} value={task.status}>
@@ -563,9 +578,14 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
value={task.team}
onValueChange={(v) => handleTeamChange(v as Team)}
>
<SelectTrigger className="w-36 shrink-0 h-7 text-sm text-muted-foreground border-0 bg-transparent hover:bg-muted/50 px-2">
<SelectValue />
</SelectTrigger>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="w-36 shrink-0 h-7 text-sm text-muted-foreground border-0 bg-transparent hover:bg-muted/50 px-2">
<SelectValue />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>Reassign this task to a team</TooltipContent>
</Tooltip>
<SelectContent>
{Object.values(Team).map((team) => (
<SelectItem key={team} value={team}>
@@ -588,17 +608,25 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
</div>
</div>
{/* Actions pinned top-right; never moves regardless of title length
or a dropdown's selected-label width. */}
<div className="shrink-0">
{/* Nav + Actions pinned top-right; never moves regardless of title
length or a dropdown's selected-label width. */}
<div className="flex shrink-0 items-center gap-2">
{nav}
{actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<MoreVertical className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<MoreVertical className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
Lifecycle actions available from this status
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{/* Lifecycle actions (non-cancel) */}
{actions
@@ -7,12 +7,11 @@ import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
interface TaskListNavProps {
task: Task;
@@ -40,6 +39,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
// explaining why — there is no list order to fall back to, so we don't guess.
export function TaskListNav({ task }: TaskListNavProps) {
const router = useRouter();
const searchParams = useSearchParams();
const context = useScrollRestorationStore((state) => state.taskListNav);
const items = context?.items ?? [];
@@ -49,7 +49,14 @@ export function TaskListNav({ task }: TaskListNavProps) {
const nextItem =
hasContext && index < items.length - 1 ? items[index + 1] : null;
const query = context?.queryString ? `?${context.queryString}` : "";
// Carry the active detail tab (?tab=) into the adjacent task's URL so
// prev/next keeps the user on the same tab.
const params = new URLSearchParams(context?.queryString ?? "");
const activeTab = searchParams.get("tab");
if (activeTab) params.set("tab", activeTab);
else params.delete("tab");
const qs = params.toString();
const query = qs ? `?${qs}` : "";
// Alt+ArrowLeft/Right mirrors the prev/next buttons above.
useEffect(() => {
@@ -68,22 +75,20 @@ export function TaskListNav({ task }: TaskListNavProps) {
}, [prevItem, nextItem, query, router]);
return (
<TooltipProvider>
<div className="flex items-center gap-1 shrink-0">
<NavButton
direction="prev"
item={prevItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
<NavButton
direction="next"
item={nextItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
</div>
</TooltipProvider>
<div className="flex items-center gap-1 shrink-0">
<NavButton
direction="prev"
item={prevItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
<NavButton
direction="next"
item={nextItem}
query={query}
disabledReason={!hasContext ? NO_CONTEXT_TOOLTIP : undefined}
/>
</div>
);
}
@@ -1,8 +1,14 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Task } from "@/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TabOverview } from "./tab-overview";
import { TabPlan } from "./tab-plan";
import { TabProgress } from "./tab-progress";
@@ -16,13 +22,28 @@ import {
GitCommit,
StickyNote,
Link2,
type LucideIcon,
} from "lucide-react";
interface TaskTabsProps {
task: Task;
}
interface TabDef {
value: string;
label: string;
icon: LucideIcon;
hint: string;
count?: number;
}
const DEFAULT_TAB = "overview";
export function TaskTabs({ task }: TaskTabsProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Calculate badge counts
const progressCount = task.progress_updates.length + task.checkpoints.length;
const commitCount = task.commits.length;
@@ -33,58 +54,85 @@ export function TaskTabs({ task }: TaskTabsProps) {
(task.quick_context ? 1 : 0);
const depsCount = task.dependency_ids.length + task.blocker_ids.length;
const tabs: TabDef[] = [
{
value: "overview",
label: "Overview",
icon: FileText,
hint: "Description, acceptance criteria, and metadata",
},
{
value: "plan",
label: "Plan",
icon: Layout,
hint: "The delegated sub-task plan",
count: task.plan ? task.plan.sub_tasks.length : undefined,
},
{
value: "progress",
label: "Progress",
icon: Clock,
hint: "Progress updates and checkpoints",
count: progressCount > 0 ? progressCount : undefined,
},
{
value: "commits",
label: "Commits",
icon: GitCommit,
hint: "Commits linked to this task",
count: commitCount > 0 ? commitCount : undefined,
},
{
value: "notes",
label: "Notes",
icon: StickyNote,
hint: "Dev, QA, and auditor notes",
count: notesCount > 0 ? notesCount : undefined,
},
{
value: "dependencies",
label: "Deps",
icon: Link2,
hint: "Dependencies and blockers",
count: depsCount > 0 ? depsCount : undefined,
},
];
// The active tab lives in the URL (?tab=) so it survives reloads,
// back/forward, and prev/next task navigation. Unknown values fall back to
// the default rather than rendering an empty pane.
const tabParam = searchParams.get("tab");
const activeTab = tabs.some((t) => t.value === tabParam)
? (tabParam as string)
: DEFAULT_TAB;
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === DEFAULT_TAB) params.delete("tab");
else params.set("tab", value);
const qs = params.toString();
router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
};
return (
<Tabs defaultValue="overview" className="mt-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="mt-6">
<TabsList className="grid w-full grid-cols-6 lg:w-auto lg:inline-grid">
<TabsTrigger value="overview" className="gap-2">
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">Overview</span>
</TabsTrigger>
<TabsTrigger value="plan" className="gap-2">
<Layout className="h-4 w-4" />
<span className="hidden sm:inline">Plan</span>
{task.plan && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{task.plan.sub_tasks.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="progress" className="gap-2">
<Clock className="h-4 w-4" />
<span className="hidden sm:inline">Progress</span>
{progressCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{progressCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="commits" className="gap-2">
<GitCommit className="h-4 w-4" />
<span className="hidden sm:inline">Commits</span>
{commitCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{commitCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="notes" className="gap-2">
<StickyNote className="h-4 w-4" />
<span className="hidden sm:inline">Notes</span>
{notesCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{notesCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="dependencies" className="gap-2">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline">Deps</span>
{depsCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{depsCount}
</Badge>
)}
</TabsTrigger>
{tabs.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
<TabsTrigger value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
<span className="hidden sm:inline">{tab.label}</span>
{tab.count !== undefined && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{tab.count}
</Badge>
)}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList>
<div className="mt-4">
+18 -6
View File
@@ -1,5 +1,12 @@
"use client";
import { TaskType } from "@/types";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Code,
FileText,
@@ -64,11 +71,16 @@ export function TaskTypeBadge({
if (!config) return null;
return (
<Badge variant="outline" className={`${config.color} ${className}`}>
<span className="flex items-center gap-1">
{config.icon}
{showLabel && <span>{config.label}</span>}
</span>
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className={`${config.color} ${className}`}>
<span className="flex items-center gap-1">
{config.icon}
{showLabel && <span>{config.label}</span>}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>Task type: {config.label}</TooltipContent>
</Tooltip>
);
}