mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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} />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user