open-swe/apps/web/src/components/tasks/progress-bar.tsx

245 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { List, HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { PlanItem, TaskPlan } from "@open-swe/shared/open-swe/types";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface ProgressBarProps {
taskPlan?: TaskPlan;
className?: string;
onOpenSidebar?: () => void;
}
export function ProgressBar({
taskPlan,
className,
onOpenSidebar,
}: ProgressBarProps) {
const [showLegend, setShowLegend] = useState(false);
if (!taskPlan || !taskPlan.tasks.length) {
return (
<div
className={cn(
"mt-2 w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4",
className,
)}
>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">No active plan</div>
</div>
</div>
);
}
const currentTask = taskPlan.tasks[taskPlan.activeTaskIndex];
if (!currentTask || !currentTask.planRevisions.length) {
return (
<div
className={cn(
"mt-2 w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4",
className,
)}
>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">No active plan</div>
</div>
</div>
);
}
const activeRevision =
currentTask.planRevisions[currentTask.activeRevisionIndex];
const planItems = [...activeRevision.plans].sort((a, b) => a.index - b.index);
// Find the current task (lowest index among uncompleted tasks)
const currentTaskIndex = planItems
.filter((item) => !item.completed)
.reduce(
(min, item) => (item.index < min ? item.index : min),
Number.POSITIVE_INFINITY,
);
const getItemState = (
item: PlanItem,
): "completed" | "current" | "remaining" => {
if (item.completed) return "completed";
if (item.index === currentTaskIndex) return "current";
return "remaining";
};
const completedCount = planItems.filter((item) => item.completed).length;
const progressPercentage =
planItems.length > 0 ? (completedCount / planItems.length) * 100 : 0;
const getSegmentColor = (state: string) => {
switch (state) {
case "completed":
return "bg-green-400";
case "current":
return "bg-blue-400";
default:
return "bg-gray-200";
}
};
return (
<div
className={cn(
"w-full rounded-md border border-gray-200 bg-white shadow-sm",
className,
)}
>
{/* Compact header */}
<div className="overflow-hidden px-1 py-1.5 sm:px-2">
<div className="mb-1 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-700 sm:text-sm">
Plan Progress
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 sm:h-5 sm:w-5"
onClick={() => setShowLegend(!showLegend)}
>
<HelpCircle className="h-3 w-3 text-gray-500" />
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-xs p-2 text-xs"
>
<div className="space-y-1">
<p className="font-medium">Plan Progress</p>
<p>
Shows the current progress of the AI agent's plan
execution.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 sm:text-sm">
{Math.round(progressPercentage)}%
</span>
<Button
variant="outline"
size="sm"
onClick={onOpenSidebar}
className="h-6 border-blue-200 text-xs hover:bg-blue-50"
>
<List className="size-3" />
<span className="hidden sm:inline">Tasks</span>
<span className="sm:hidden">View</span>
</Button>
</div>
</div>
{/* Legend - conditionally shown */}
{showLegend && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1 text-xs sm:gap-3">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-400"></div>
<span>Completed</span>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400"></div>
<span>Current</span>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-gray-200"></div>
<span>Pending</span>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto h-4 touch-manipulation p-0 text-xs"
onClick={() => setShowLegend(false)}
>
×
</Button>
</div>
)}
{/* Progress Stats */}
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs text-gray-600 sm:text-sm">
{completedCount} of {planItems.length} tasks completed
</span>
<span className="text-xs text-gray-500 sm:text-sm">
Task #{currentTask.taskIndex + 1}
</span>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div
className="flex h-3 cursor-pointer touch-manipulation gap-[1px] overflow-hidden rounded-sm bg-gray-100 transition-all sm:h-2"
onClick={onOpenSidebar}
aria-label="Click to view all tasks"
title="Click to view all tasks"
>
{planItems.map((item) => {
const state = getItemState(item);
const segmentWidth = `${100 / planItems.length}%`;
return (
<HoverCard key={item.index}>
<HoverCardTrigger asChild>
<div
className={cn(
"transition-all hover:opacity-80",
getSegmentColor(state),
state === "current" && "animate-pulse",
)}
style={{ width: segmentWidth }}
/>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
className="min-w-xs p-2 text-xs sm:max-w-sm md:max-w-lg"
>
<div className="space-y-1">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-center sm:gap-1">
<span className="font-medium">
Task #{item.index + 1}
</span>
<span className="text-xs text-gray-500">
{state === "completed"
? "Completed"
: state === "current"
? "Current"
: "Pending"}
</span>
</div>
<p className="text-xs break-words">{item.plan}</p>
</div>
</HoverCardContent>
</HoverCard>
);
})}
</div>
</div>
</div>
</div>
);
}