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
+15 -5
View File
@@ -5,6 +5,11 @@ import { useStopAgent } from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Card,
CardContent,
@@ -62,11 +67,16 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
{agent.name || "Unknown Agent"}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Agent actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
+13 -3
View File
@@ -10,6 +10,11 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAgentStream, ConnectionState } from "@/hooks/use-websocket";
import { Wifi, WifiOff, Loader2, Trash2 } from "lucide-react";
@@ -78,9 +83,14 @@ export function AgentStreamViewer({
{stateLabels[state]}
</Badge>
{streamChunks.length > 0 && (
<Button variant="ghost" size="icon" onClick={clearMessages}>
<Trash2 className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={clearMessages}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear stream output</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -23,6 +23,11 @@ import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { ScorecardOverviewPanel } from "./scorecard-overview-panel";
import { Settings, AlertCircle } from "lucide-react";
@@ -103,11 +108,16 @@ export function CommandCenter() {
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/settings" prefetch={false}>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</Link>
<Tooltip>
<TooltipTrigger asChild>
<Link href="/settings" prefetch={false}>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Open settings</TooltipContent>
</Tooltip>
</div>
</div>
@@ -11,6 +11,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import {
@@ -356,9 +361,14 @@ export function KanbanBoard({
value={teamFilter || "all"}
onValueChange={(v) => onTeamChange(v as Team | "all")}
>
<SelectTrigger className="w-auto min-w-28">
<SelectValue placeholder="Team" />
</SelectTrigger>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="w-auto min-w-28">
<SelectValue placeholder="Team" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>Filter the board by team</TooltipContent>
</Tooltip>
<SelectContent>
<SelectItem value="all">All Teams</SelectItem>
{Object.values(Team).map((team) => (
+118 -86
View File
@@ -14,7 +14,6 @@ import {
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PriorityIndicator } from "../shared/priority-indicator";
@@ -119,13 +118,22 @@ export function KanbanCard({
</p>
)}
</div>
<div
{...attributes}
{...listeners}
className={`shrink-0 mt-0.5 ${isBacklog ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
{...attributes}
{...listeners}
className={`shrink-0 mt-0.5 ${isBacklog ? "cursor-not-allowed opacity-50" : "cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
{isBacklog
? "Backlog tasks can't be dragged — activate first"
: "Drag to another column to change status"}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between mt-2">
@@ -135,36 +143,35 @@ export function KanbanCard({
</Badge>
<TaskTypeBadge type={task.task_type} showLabel={false} />
{task.sequence != null && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs gap-1">
<Hash className="h-3 w-3" />
{task.sequence}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Sequence #{task.sequence}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-xs gap-1">
<Hash className="h-3 w-3" />
{task.sequence}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
Sequence #{task.sequence} lower-sequence siblings run
first
</p>
</TooltipContent>
</Tooltip>
)}
<PriorityIndicator priority={task.priority} />
{isBlocked && <BlockedBadge />}
{isBacklog && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs gap-1">
<Clock className="h-3 w-3" />
Backlog
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Awaiting session creation by PM</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge variant="secondary" className="text-xs gap-1">
<Clock className="h-3 w-3" />
Backlog
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Awaiting session creation by PM</p>
</TooltipContent>
</Tooltip>
)}
</div>
<AssigneeAvatar agentId={task.assigned_to} />
@@ -175,18 +182,25 @@ export function KanbanCard({
<div className="flex items-center justify-between gap-1 mt-2 pt-2 border-t">
{/* Quick Assign */}
<Popover open={assignOpen} onOpenChange={setAssignOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
disabled={isBacklog}
>
<UserPlus className="h-3 w-3 mr-1" />
Assign
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
disabled={isBacklog}
>
<UserPlus className="h-3 w-3 mr-1" />
Assign
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
Assign this task to an agent on its team
</TooltipContent>
</Tooltip>
<PopoverContent
className="w-64 p-2"
onClick={(e) => e.stopPropagation()}
@@ -206,51 +220,69 @@ export function KanbanCard({
<div className="flex items-center gap-1">
{showQaActions ? (
<>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={(e) => {
e.stopPropagation();
onAction("pass-qa", task.id);
}}
>
<CheckCircle className="h-3 w-3 mr-1" />
Pass
</Button>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation();
onAction("fail-qa", task.id);
}}
>
<XCircle className="h-3 w-3 mr-1" />
Fail
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={(e) => {
e.stopPropagation();
onAction("pass-qa", task.id);
}}
>
<CheckCircle className="h-3 w-3 mr-1" />
Pass
</Button>
</TooltipTrigger>
<TooltipContent>
Pass QA you&apos;ll be asked for review notes
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="max-sm:min-h-11 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation();
onAction("fail-qa", task.id);
}}
>
<XCircle className="h-3 w-3 mr-1" />
Fail
</Button>
</TooltipTrigger>
<TooltipContent>
Fail QA returns the task to the developer with notes
</TooltipContent>
</Tooltip>
</>
) : (
task.status !== TaskStatus.COMPLETED &&
task.status !== TaskStatus.CANCELLED && (
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={(e) => {
e.stopPropagation();
onAction("move-forward", task.id);
}}
disabled={isBacklog}
title={
isBacklog
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={(e) => {
e.stopPropagation();
onAction("move-forward", task.id);
}}
disabled={isBacklog}
>
<ArrowRight className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isBacklog
? "PM must activate this task first"
: "Move forward"
}
>
<ArrowRight className="h-3 w-3" />
</Button>
: "Advance to the next lifecycle stage"}
</TooltipContent>
</Tooltip>
)
)}
</div>
@@ -3,7 +3,11 @@
import { Task, TaskStatus } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { KanbanCard } from "./kanban-card";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
@@ -41,7 +45,10 @@ export function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"flex flex-col rounded-lg p-3 w-72 shrink-0 sm:w-80 h-full",
// Columns share the available width (flex-1) down to a 18rem floor —
// below that the board container scrolls horizontally — and cap at
// 24rem so a near-empty board doesn't produce comically wide columns.
"flex min-w-0 flex-col rounded-lg p-3 h-full shrink-0 w-72 sm:w-auto sm:flex-1 sm:min-w-72 sm:max-w-96",
color,
isOver && "ring-2 ring-primary ring-offset-2",
className,
@@ -51,14 +58,26 @@ export function KanbanColumn({
<h3 className="font-semibold text-sm text-gray-800 dark:text-gray-100">
{title}
</h3>
<Badge
variant="secondary"
className="dark:bg-gray-700 dark:text-gray-100"
>
{isLoading ? "..." : tasks.length}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="dark:bg-gray-700 dark:text-gray-100"
>
{isLoading ? "..." : tasks.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
{isLoading
? "Loading…"
: `${tasks.length} task${tasks.length === 1 ? "" : "s"} in ${title}`}
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="flex-1 min-h-0">
{/* Native overflow scroll: Radix ScrollArea's display:table viewport
let cards grow past the column width and clip — a plain div keeps
content constrained to the column. */}
<div className="flex-1 min-h-0 overflow-y-auto">
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-24" />
@@ -85,7 +104,7 @@ export function KanbanColumn({
))}
</div>
)}
</ScrollArea>
</div>
</div>
);
}
@@ -1,7 +1,12 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { getAgentInitials } from "@/lib/agent-utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
interface AssigneeAvatarProps {
agentId: string | null;
@@ -15,10 +20,17 @@ export function AssigneeAvatar({ agentId, size = "sm" }: AssigneeAvatarProps) {
const sizeClasses = size === "sm" ? "h-6 w-6 text-xs" : "h-8 w-8 text-sm";
return (
<Avatar className={sizeClasses}>
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
<Tooltip>
<TooltipTrigger asChild>
<Avatar className={sizeClasses}>
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
Assigned to {getAgentDisplayName(agentId)}
</TooltipContent>
</Tooltip>
);
}
@@ -9,49 +9,49 @@ const DEV_COLUMNS = [
id: "backlog",
status: TaskStatus.BACKLOG,
title: "Backlog",
color: "bg-slate-50 dark:bg-slate-900",
color: "bg-slate-50 dark:bg-slate-900/40",
},
{
id: "pending",
status: TaskStatus.PENDING,
title: "Ready",
color: "bg-gray-100 dark:bg-gray-800",
color: "bg-gray-100 dark:bg-gray-900/40",
},
{
id: "assigned",
status: TaskStatus.CLAIMED,
title: "Assigned",
color: "bg-blue-50 dark:bg-blue-950",
color: "bg-blue-50 dark:bg-blue-950/40",
},
{
id: "in-progress",
status: TaskStatus.IN_PROGRESS,
title: "In Progress",
color: "bg-blue-100 dark:bg-blue-900",
color: "bg-blue-100 dark:bg-blue-900/40",
},
{
id: "blocked",
status: TaskStatus.BLOCKED,
title: "Blocked",
color: "bg-red-50 dark:bg-red-950",
color: "bg-red-50 dark:bg-red-950/40",
},
{
id: "verifying",
status: TaskStatus.VERIFYING,
title: "Verifying",
color: "bg-purple-50 dark:bg-purple-950",
color: "bg-purple-50 dark:bg-purple-950/40",
},
{
id: "qa-review",
status: TaskStatus.AWAITING_QA,
title: "QA Review",
color: "bg-yellow-50 dark:bg-yellow-950",
color: "bg-yellow-50 dark:bg-yellow-950/40",
},
{
id: "done",
status: TaskStatus.COMPLETED,
title: "Done",
color: "bg-green-50 dark:bg-green-950",
color: "bg-green-50 dark:bg-green-950/40",
},
];
+14 -14
View File
@@ -15,85 +15,85 @@ const PM_COLUMNS = [
id: "backlog",
status: TaskStatus.BACKLOG,
title: "Backlog",
color: "bg-slate-50 dark:bg-slate-900",
color: "bg-slate-50 dark:bg-slate-900/40",
},
{
id: "incoming",
status: TaskStatus.PENDING,
title: "Pending",
color: "bg-gray-100 dark:bg-gray-800",
color: "bg-gray-100 dark:bg-gray-900/40",
},
{
id: "assigned",
status: TaskStatus.CLAIMED,
title: "Assigned",
color: "bg-blue-50 dark:bg-blue-950",
color: "bg-blue-50 dark:bg-blue-950/40",
},
{
id: "in-progress",
status: TaskStatus.IN_PROGRESS,
title: "In Progress",
color: "bg-blue-100 dark:bg-blue-900",
color: "bg-blue-100 dark:bg-blue-900/40",
},
{
id: "blocked",
status: TaskStatus.BLOCKED,
title: "Blocked",
color: "bg-red-100 dark:bg-red-900",
color: "bg-red-100 dark:bg-red-900/40",
},
{
id: "paused",
status: TaskStatus.PAUSED,
title: "Paused",
color: "bg-amber-50 dark:bg-amber-950",
color: "bg-amber-50 dark:bg-amber-950/40",
},
{
id: "qa",
status: TaskStatus.AWAITING_QA,
title: "In QA",
color: "bg-yellow-50 dark:bg-yellow-950",
color: "bg-yellow-50 dark:bg-yellow-950/40",
},
{
id: "needs-revision",
status: TaskStatus.NEEDS_REVISION,
title: "Needs Revision",
color: "bg-rose-50 dark:bg-rose-950",
color: "bg-rose-50 dark:bg-rose-950/40",
},
{
id: "docs",
status: TaskStatus.AWAITING_DOCUMENTATION,
title: "In Docs",
color: "bg-purple-50 dark:bg-purple-950",
color: "bg-purple-50 dark:bg-purple-950/40",
},
{
id: "pr-review",
status: TaskStatus.AWAITING_PR_REVIEW,
title: "PR Review",
color: "bg-teal-50 dark:bg-teal-950",
color: "bg-teal-50 dark:bg-teal-950/40",
},
{
id: "pm-review",
status: TaskStatus.AWAITING_PM_REVIEW,
title: "PM Review",
color: "bg-orange-50 dark:bg-orange-950",
color: "bg-orange-50 dark:bg-orange-950/40",
},
{
id: "ceo-approval",
status: TaskStatus.AWAITING_CEO_APPROVAL,
title: "CEO Approval",
color: "bg-indigo-50 dark:bg-indigo-950",
color: "bg-indigo-50 dark:bg-indigo-950/40",
},
{
id: "done",
status: TaskStatus.COMPLETED,
title: "Done",
color: "bg-green-50 dark:bg-green-950",
color: "bg-green-50 dark:bg-green-950/40",
},
{
id: "cancelled",
status: TaskStatus.CANCELLED,
title: "Cancelled",
color: "bg-zinc-100 dark:bg-zinc-900",
color: "bg-zinc-100 dark:bg-zinc-900/40",
},
];
@@ -12,19 +12,19 @@ const PR_REVIEW_COLUMNS = [
id: "awaiting",
status: TaskStatus.AWAITING_PR_REVIEW,
title: "Awaiting Review",
color: "bg-teal-100 dark:bg-teal-900",
color: "bg-teal-100 dark:bg-teal-900/40",
},
{
id: "passed",
status: TaskStatus.AWAITING_PM_REVIEW,
title: "Passed",
color: "bg-green-50 dark:bg-green-950",
color: "bg-green-50 dark:bg-green-950/40",
},
{
id: "changes",
status: TaskStatus.NEEDS_REVISION,
title: "Changes Requested",
color: "bg-red-50 dark:bg-red-950",
color: "bg-red-50 dark:bg-red-950/40",
},
];
@@ -9,25 +9,25 @@ const QA_COLUMNS = [
id: "awaiting",
status: TaskStatus.AWAITING_QA,
title: "Awaiting Review",
color: "bg-yellow-100 dark:bg-yellow-900",
color: "bg-yellow-100 dark:bg-yellow-900/40",
},
{
id: "verifying",
status: TaskStatus.VERIFYING,
title: "In Review",
color: "bg-blue-100 dark:bg-blue-900",
color: "bg-blue-100 dark:bg-blue-900/40",
},
{
id: "passed",
status: TaskStatus.AWAITING_DOCUMENTATION,
title: "Passed",
color: "bg-green-50 dark:bg-green-950",
color: "bg-green-50 dark:bg-green-950/40",
},
{
id: "failed",
status: TaskStatus.NEEDS_REVISION,
title: "Failed",
color: "bg-red-50 dark:bg-red-950",
color: "bg-red-50 dark:bg-red-950/40",
},
];
@@ -2,6 +2,11 @@
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -144,14 +149,19 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="min-h-20 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
<Send className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
<Send className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Send to your mentor (Enter)</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@@ -310,18 +320,23 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
className="min-h-16 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Send to your mentor (Enter)</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@@ -2,6 +2,11 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -80,18 +85,23 @@ export function MentorQueryInput({
className="min-h-24 pr-12 resize-none bg-background"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Ask the mentor (Enter)</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
@@ -2,6 +2,11 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Textarea } from "@/components/ui/textarea";
import { Send, Loader2, Sparkles } from "lucide-react";
@@ -46,18 +51,23 @@ export function RAGQueryInput({
className="min-h-24 pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="absolute bottom-2 right-2"
onClick={handleSubmit}
disabled={!question.trim() || isLoading}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Ask the knowledge base (Enter)</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
@@ -91,10 +91,8 @@ describe("Header — navbar refresh button", () => {
"aria-label",
"Refresh only the current page",
);
expect(refreshButton).toHaveAttribute(
"title",
"Refresh only the current page",
);
// The hover hint is a Radix tooltip now (no native title attribute).
expect(refreshButton).not.toHaveAttribute("title");
});
it("disables the refresh button when no page has registered a refresh callback", () => {
@@ -67,17 +67,17 @@ describe("SidebarFooter", () => {
]);
});
it("renders exactly one Separator between the nav list and the footer, expanded and collapsed", () => {
it("renders no Separator — the wrapper's border-t is the single divider (a Separator doubled it)", () => {
const expanded = render(<SidebarFooter />);
expect(
expanded.container.querySelectorAll('[data-slot="separator"]'),
).toHaveLength(1);
).toHaveLength(0);
expanded.unmount();
const collapsed = render(<SidebarFooter collapsed />);
expect(
collapsed.container.querySelectorAll('[data-slot="separator"]'),
).toHaveLength(1);
).toHaveLength(0);
});
it("renders correctly when collapsed (icon-only)", () => {
@@ -2,6 +2,11 @@
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Wifi, WifiOff, Loader2 } from "lucide-react";
type ConnectionState = "checking" | "connected" | "disconnected";
@@ -29,17 +34,13 @@ export function ConnectionStatus() {
return () => clearInterval(interval);
}, []);
if (state === "checking") {
return (
const badge =
state === "checking" ? (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Checking...
</Badge>
);
}
if (state === "connected") {
return (
) : state === "connected" ? (
<Badge
variant="outline"
className="gap-1 border-green-500 text-green-600"
@@ -47,16 +48,26 @@ export function ConnectionStatus() {
<Wifi className="h-3 w-3" />
Connected
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-orange-500 text-orange-600"
>
<WifiOff className="h-3 w-3" />
Offline
</Badge>
);
}
const hint: Record<ConnectionState, string> = {
checking: "Checking the orchestrator API…",
connected: "Orchestrator API reachable — re-checked every 30s",
disconnected: "Orchestrator API unreachable — retrying every 30s",
};
return (
<Badge
variant="outline"
className="gap-1 border-orange-500 text-orange-600"
>
<WifiOff className="h-3 w-3" />
Offline
</Badge>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent>{hint[state]}</TooltipContent>
</Tooltip>
);
}
+42 -34
View File
@@ -16,7 +16,6 @@ import { MobileSidebar } from "./mobile-sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -32,22 +31,20 @@ export function Header() {
<div className="flex items-center gap-4 flex-1 max-w-md">
{/* Mobile nav trigger — only shown below md, where the sidebar is hidden */}
<MobileSidebar />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search tasks, agents..."
className="pl-10"
disabled={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>Coming Soon</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search tasks, agents..."
className="pl-10"
disabled={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>Coming Soon</TooltipContent>
</Tooltip>
</div>
{/* Actions */}
@@ -56,26 +53,37 @@ export function Header() {
<ConnectionStatus />
{/* Refresh current page data */}
<Button
variant="ghost"
size="icon"
onClick={() => void refresh()}
disabled={disabled || loading}
aria-label="Refresh only the current page"
title="Refresh only the current page"
>
<RefreshCw className={cn("h-5 w-5", loading && "animate-spin")} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => void refresh()}
disabled={disabled || loading}
aria-label="Refresh only the current page"
>
<RefreshCw
className={cn("h-5 w-5", loading && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh this page&apos;s data</TooltipContent>
</Tooltip>
{/* Theme toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Switch light / dark theme</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="h-4 w-4 mr-2" />
+20 -10
View File
@@ -13,6 +13,11 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SidebarNav, SidebarFooter } from "./sidebar";
/**
@@ -27,16 +32,21 @@ export function MobileSidebar() {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent>Open navigation</TooltipContent>
</Tooltip>
<SheetContent side="left" className="flex w-64 flex-col gap-0 p-0">
<SheetHeader className="h-16 justify-center border-b px-4 text-left">
<SheetTitle asChild>
+57 -25
View File
@@ -26,7 +26,11 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUIStore } from "@/store";
// Flat list, in the exact order product wants the sidebar to read top to
@@ -73,10 +77,9 @@ export function SidebarNav({
<nav className="space-y-1 px-2">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
const link = (
<Link
prefetch={false}
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
@@ -86,12 +89,22 @@ export function SidebarNav({
: "text-muted-foreground hover:bg-muted hover:text-foreground",
collapsed && "justify-center px-2",
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</Link>
);
// Icon-only rail: the label moves into a tooltip.
return collapsed ? (
<Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
) : (
<span key={item.href} className="block">
{link}
</span>
);
})}
</nav>
);
@@ -105,27 +118,36 @@ export function SidebarFooter({
collapsed?: boolean;
onNavigate?: () => void;
}) {
// No Separator here: both wrappers (desktop aside + mobile Sheet) already
// draw a border-t, and a second line reads as a rendering glitch.
return (
<div className="space-y-2">
<Separator />
<div className="space-y-1">
{footerItems.map((item) => (
<div className="space-y-1">
{footerItems.map((item) => {
const link = (
<Link
prefetch={false}
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
collapsed && "justify-center px-2",
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5" />
{!collapsed && <span>{item.title}</span>}
</Link>
))}
</div>
);
return collapsed ? (
<Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
) : (
<span key={item.href} className="block">
{link}
</span>
);
})}
</div>
);
}
@@ -162,19 +184,29 @@ export function Sidebar() {
<span className="font-semibold text-lg">RoboCo</span>
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className={cn(sidebarCollapsed && "mx-auto")}
>
<ChevronLeft
className={cn(
"h-4 w-4 transition-transform",
sidebarCollapsed && "rotate-180",
)}
/>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className={cn(sidebarCollapsed && "mx-auto")}
aria-label={
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
<ChevronLeft
className={cn(
"h-4 w-4 transition-transform",
sidebarCollapsed && "rotate-180",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
</TooltipContent>
</Tooltip>
</div>
{/* Navigation */}
@@ -10,6 +10,11 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Bell, Wifi, WifiOff } from "lucide-react";
export function NotificationBell() {
@@ -20,16 +25,30 @@ export function NotificationBell() {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-red-500">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs bg-red-500">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
: "Notifications"}
</TooltipContent>
</Tooltip>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="flex items-center justify-between">
+23 -13
View File
@@ -3,6 +3,11 @@
import { useState, useRef, KeyboardEvent } from "react";
import { Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
@@ -62,19 +67,24 @@ export function ChatComposer({
rows={2}
className="flex-1 resize-none text-sm"
/>
<Button
size="icon"
onClick={handleSend}
disabled={isDisabled}
className="mb-0.5 shrink-0"
aria-label="Send message"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
onClick={handleSend}
disabled={isDisabled}
className="mb-0.5 shrink-0"
aria-label="Send message"
>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Send message (Enter)</TooltipContent>
</Tooltip>
</div>
);
}
@@ -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>
);
}
+28 -17
View File
@@ -2,6 +2,11 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
@@ -62,22 +67,28 @@ export function CopyButton({ value, label, className }: CopyButtonProps) {
};
return (
<button
type="button"
onClick={onCopy}
aria-label={label ?? "Copy"}
title={label ?? "Copy"}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className,
)}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{label ? <span>{copied ? "Copied" : label}</span> : null}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onCopy}
aria-label={label ?? "Copy"}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className,
)}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{label ? <span>{copied ? "Copied" : label}</span> : null}
</button>
</TooltipTrigger>
<TooltipContent>
{copied ? "Copied!" : (label ?? "Copy to clipboard")}
</TooltipContent>
</Tooltip>
);
}
+11 -1
View File
@@ -7,7 +7,17 @@ import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
// Self-providing Root (shadcn v4 pattern): every <Tooltip> works anywhere —
// components and tests alike — without an app-level TooltipProvider.
function Tooltip(
props: React.ComponentProps<typeof TooltipPrimitive.Root>,
): React.ReactElement {
return (
<TooltipProvider delayDuration={300}>
<TooltipPrimitive.Root {...props} />
</TooltipProvider>
);
}
const TooltipTrigger = TooltipPrimitive.Trigger;
@@ -4,6 +4,11 @@ import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Table,
TableBody,
@@ -147,14 +152,19 @@ export function WorkSessionTable({
})}
</TableCell>
<TableCell>
<Link
href={`/work-sessions/${session.id}`}
prefetch={false}
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/work-sessions/${session.id}`}
prefetch={false}
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Open work session</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
@@ -177,11 +187,19 @@ export function WorkSessionTable({
{session.branch_name}
</Link>
</div>
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Button variant="ghost" size="icon" className="shrink-0">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/work-sessions/${session.id}`}
prefetch={false}
>
<Button variant="ghost" size="icon" className="shrink-0">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>Open work session</TooltipContent>
</Tooltip>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Task">