mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
feat: Task plan UI Component: front-end only (#438)
* task plan init FE * dark theme * fix build * new swr-config for frequent polling task plan * rm dev logs + comments * CR * CR: center task progress bar * add task tracking to thread-card on default-view (homepage) * progress bar styling, revert cancel button change * revert onstreamready optional * implement homepage taskplan via polling instead of React Context for persistence * rm comment * Update apps/web/src/components/v2/actions-renderer.tsx Co-authored-by: Brace Sproul <braceasproul@gmail.com> * Update apps/web/src/components/v2/thread-card.tsx Co-authored-by: Brace Sproul <braceasproul@gmail.com> * Update apps/web/src/components/v2/thread-card.tsx Co-authored-by: Brace Sproul <braceasproul@gmail.com> * CR * /chat/threads page taskplan * shared utils in progressbar * fix taskPlan for stream actions-renderer --> thread-view * cr --------- Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
parent
38767397e9
commit
3967ea0dca
10 changed files with 341 additions and 218 deletions
|
|
@ -41,6 +41,7 @@ function AllThreadsPageContent() {
|
|||
|
||||
const {
|
||||
statusMap,
|
||||
taskPlanMap,
|
||||
statusCounts,
|
||||
isLoading: statusLoading,
|
||||
} = useThreadsStatus(threadIds, threads);
|
||||
|
|
@ -197,6 +198,7 @@ function AllThreadsPageContent() {
|
|||
thread={thread}
|
||||
status={statusMap[thread.id]}
|
||||
statusLoading={statusLoading}
|
||||
taskPlan={taskPlanMap[thread.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -212,6 +214,7 @@ function AllThreadsPageContent() {
|
|||
thread={thread}
|
||||
status={statusMap[thread.id]}
|
||||
statusLoading={statusLoading}
|
||||
taskPlan={taskPlanMap[thread.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -100,11 +100,13 @@ export function TasksSidebar({
|
|||
const getStateIcon = (state: string) => {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
|
||||
return (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
);
|
||||
case "current":
|
||||
return <Play className="h-4 w-4 text-blue-500" />;
|
||||
return <Play className="h-4 w-4 text-blue-500 dark:text-blue-400" />;
|
||||
default:
|
||||
return <Circle className="h-4 w-4 text-gray-400" />;
|
||||
return <Circle className="h-4 w-4 text-gray-400 dark:text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -137,7 +139,7 @@ export function TasksSidebar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-10 h-screen border-l border-gray-200 bg-white shadow-lg transition-all duration-300",
|
||||
"fixed top-0 right-0 z-10 h-screen border-l border-gray-200 bg-white shadow-lg transition-all duration-300 dark:border-gray-700 dark:bg-gray-900",
|
||||
isOpen ? "w-80 md:w-xl" : "w-0 overflow-hidden border-l-0",
|
||||
)}
|
||||
>
|
||||
|
|
@ -149,14 +151,16 @@ export function TasksSidebar({
|
|||
style={{ minWidth: isOpen ? "320px" : "0" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Tasks</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Tasks
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -165,11 +169,11 @@ export function TasksSidebar({
|
|||
{/* Task selector */}
|
||||
{taskPlan.tasks.length > 1 && (
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Task
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded border border-gray-200 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-200 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
value={currentTask.id}
|
||||
onChange={(e) => {
|
||||
const newTaskIndex = taskPlan.tasks.findIndex(
|
||||
|
|
@ -196,25 +200,25 @@ export function TasksSidebar({
|
|||
|
||||
{/* Plan revision navigation */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Plan Revision
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-1 rounded border border-gray-200 bg-gray-50 px-2 py-1">
|
||||
<div className="flex flex-1 items-center gap-1 rounded border border-gray-200 bg-gray-50 px-2 py-1 dark:border-gray-600 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-1">
|
||||
{currentRevision?.createdBy === "agent" ? (
|
||||
<Bot className="h-3 w-3 text-blue-500" />
|
||||
) : (
|
||||
<User className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
<Clock className="h-3 w-3 text-gray-500" />
|
||||
<Clock className="h-3 w-3 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<span className="text-xs">
|
||||
<span className="text-xs dark:text-gray-300">
|
||||
Rev {currentRevision?.revisionIndex + 1} of{" "}
|
||||
{currentTask.planRevisions.length}
|
||||
</span>
|
||||
{!isLatestRevision && (
|
||||
<span className="ml-1 rounded bg-orange-50 px-1 py-0.5 text-xs text-orange-600">
|
||||
<span className="ml-1 rounded bg-orange-50 px-1 py-0.5 text-xs text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
Historical
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -264,7 +268,7 @@ export function TasksSidebar({
|
|||
|
||||
{/* Revision info */}
|
||||
{currentRevision && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Created by {currentRevision.createdBy} on{" "}
|
||||
{formatDate(currentRevision.createdAt)}
|
||||
</div>
|
||||
|
|
@ -273,10 +277,10 @@ export function TasksSidebar({
|
|||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded border border-gray-200 bg-gray-50 px-2 py-1">
|
||||
<Filter className="h-3 w-3 text-gray-500" />
|
||||
<div className="flex items-center gap-1 rounded border border-gray-200 bg-gray-50 px-2 py-1 dark:border-gray-600 dark:bg-gray-800">
|
||||
<Filter className="h-3 w-3 text-gray-500 dark:text-gray-400" />
|
||||
<select
|
||||
className="border-none bg-transparent text-xs outline-none"
|
||||
className="border-none bg-transparent text-xs outline-none dark:text-gray-300"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as FilterType)}
|
||||
>
|
||||
|
|
@ -293,7 +297,7 @@ export function TasksSidebar({
|
|||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No plan items match the current filter
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -306,9 +310,12 @@ export function TasksSidebar({
|
|||
key={item.index}
|
||||
className={cn(
|
||||
"rounded-lg border p-3",
|
||||
state === "current" && "border-blue-200 bg-blue-50",
|
||||
state === "completed" && "border-green-200 bg-green-50",
|
||||
state === "remaining" && "border-gray-200 bg-white",
|
||||
state === "current" &&
|
||||
"border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20",
|
||||
state === "completed" &&
|
||||
"border-green-200 bg-green-50 dark:border-green-700 dark:bg-green-900/20",
|
||||
state === "remaining" &&
|
||||
"border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -319,24 +326,24 @@ export function TasksSidebar({
|
|||
<div className="min-w-0 flex-1">
|
||||
<>
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<p className="text-sm leading-relaxed text-gray-900">
|
||||
<p className="text-sm leading-relaxed text-gray-900 dark:text-gray-100">
|
||||
{item.plan}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Plan Item #{item.index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1 text-xs",
|
||||
state === "completed" &&
|
||||
"bg-green-100 text-green-700",
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
state === "current" &&
|
||||
"bg-blue-100 text-blue-700",
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
state === "remaining" &&
|
||||
"bg-gray-100 text-gray-700",
|
||||
"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300",
|
||||
)}
|
||||
>
|
||||
{state === "completed"
|
||||
|
|
@ -365,7 +372,7 @@ export function TasksSidebar({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 p-0 text-xs text-green-700"
|
||||
className="h-6 p-0 text-xs text-green-700 dark:text-green-400"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="mr-1 h-3 w-3" />
|
||||
|
|
@ -376,7 +383,7 @@ export function TasksSidebar({
|
|||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<div className="rounded border border-green-200 bg-green-50 p-2 text-xs text-green-800">
|
||||
<div className="rounded border border-green-200 bg-green-50 p-2 text-xs text-green-800 dark:border-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
{item.summary}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -402,8 +409,8 @@ export function TaskPlanView({ taskPlan, onTaskChange }: TaskPlanViewProps) {
|
|||
|
||||
if (taskPlan.tasks.length === 0) {
|
||||
return (
|
||||
<div className="w-full rounded border border-gray-200 bg-gray-50 p-2">
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
<div className="w-full rounded border border-gray-200 bg-gray-50 p-2 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
No tasks available
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
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 { PlanItem, Task, TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
import {
|
||||
getActivePlanItems,
|
||||
getActiveTask,
|
||||
} from "@open-swe/shared/open-swe/tasks";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
|
|
@ -34,45 +38,53 @@ export function ProgressBar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4",
|
||||
"w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4 dark:border-gray-700 dark:bg-gray-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">No active plan</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
No active plan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentTask = taskPlan.tasks[taskPlan.activeTaskIndex];
|
||||
if (!currentTask || !currentTask.planRevisions.length) {
|
||||
let currentTask: Task | null = null;
|
||||
let planItems: PlanItem[] = [];
|
||||
let sortedPlanItems: PlanItem[] = [];
|
||||
let currentTaskIndex: number | null = null;
|
||||
|
||||
try {
|
||||
currentTask = getActiveTask(taskPlan);
|
||||
planItems = getActivePlanItems(taskPlan);
|
||||
sortedPlanItems = [...planItems].sort((a, b) => a.index - b.index);
|
||||
|
||||
// Find the current task (lowest index among uncompleted tasks)
|
||||
currentTaskIndex = sortedPlanItems
|
||||
.filter((item) => !item.completed)
|
||||
.reduce(
|
||||
(min, item) => (item.index < min ? item.index : min),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
} catch (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4",
|
||||
"w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 sm:mt-4 dark:border-gray-700 dark:bg-gray-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">No active plan</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
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" => {
|
||||
|
|
@ -81,71 +93,78 @@ export function ProgressBar({
|
|||
return "remaining";
|
||||
};
|
||||
|
||||
const completedCount = planItems.filter((item) => item.completed).length;
|
||||
const completedCount = sortedPlanItems.filter(
|
||||
(item) => item.completed,
|
||||
).length;
|
||||
const progressPercentage =
|
||||
planItems.length > 0 ? (completedCount / planItems.length) * 100 : 0;
|
||||
sortedPlanItems.length > 0
|
||||
? (completedCount / sortedPlanItems.length) * 100
|
||||
: 0;
|
||||
|
||||
const getSegmentColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "bg-green-400";
|
||||
return "bg-green-400 dark:bg-green-500";
|
||||
case "current":
|
||||
return "bg-blue-400";
|
||||
return "bg-blue-400 dark:bg-blue-500";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
return "bg-gray-200 dark:bg-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-md border border-gray-200 bg-white shadow-sm",
|
||||
"bg-muted/70 w-full overflow-hidden rounded-lg px-1 py-1.5 sm:px-3 dark:bg-gray-800",
|
||||
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"
|
||||
{/* Progress Stats */}
|
||||
<div className="mb-2 flex flex-col gap-1 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 dark:text-gray-200">
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
<HelpCircle className="h-3 w-3 text-gray-500 dark:text-gray-400" />
|
||||
</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>
|
||||
<span className="text-xs text-gray-600 sm:text-sm dark:text-gray-300">
|
||||
{completedCount} of {sortedPlanItems.length} tasks completed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 sm:text-sm">
|
||||
<span className="text-xs text-gray-500 sm:text-sm dark:text-gray-400">
|
||||
{Math.round(progressPercentage)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenSidebar}
|
||||
className="h-6 border-blue-200 text-xs hover:bg-blue-50"
|
||||
className="h-6 border-blue-200 text-xs hover:bg-blue-50 dark:border-blue-700 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
<List className="size-3" />
|
||||
<span className="hidden sm:inline">Tasks</span>
|
||||
|
|
@ -153,91 +172,80 @@ export function ProgressBar({
|
|||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
{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 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-400 dark:bg-green-500"></div>
|
||||
<span>Completed</span>
|
||||
</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"
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-400 dark:bg-blue-500"></div>
|
||||
<span>Current</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-gray-200 dark:bg-gray-600"></div>
|
||||
<span>Pending</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-4 touch-manipulation p-0 text-xs"
|
||||
onClick={() => setShowLegend(false)}
|
||||
>
|
||||
{planItems.map((item) => {
|
||||
const state = getItemState(item);
|
||||
const segmentWidth = `${100 / planItems.length}%`;
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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>
|
||||
{/* 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 dark:bg-gray-700"
|
||||
onClick={onOpenSidebar}
|
||||
aria-label="Click to view all tasks"
|
||||
title="Click to view all tasks"
|
||||
>
|
||||
{sortedPlanItems.map((item) => {
|
||||
const state = getItemState(item);
|
||||
const segmentWidth = `${100 / sortedPlanItems.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 dark:text-gray-400">
|
||||
{state === "completed"
|
||||
? "Completed"
|
||||
: state === "current"
|
||||
? "Current"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs break-words">{item.plan}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useTaskPlan() {
|
||||
const { values } = useStreamContext();
|
||||
const [taskPlan, setTaskPlan] = useState<TaskPlan>();
|
||||
|
||||
useEffect(() => {
|
||||
const currentPlanStr = JSON.stringify(taskPlan, null, 2);
|
||||
const newPlanStr = JSON.stringify(values?.taskPlan, null, 2);
|
||||
if (currentPlanStr !== newPlanStr) {
|
||||
setTaskPlan(values?.taskPlan);
|
||||
}
|
||||
}, [values?.taskPlan]);
|
||||
|
||||
return {
|
||||
taskPlan,
|
||||
};
|
||||
}
|
||||
|
|
@ -74,10 +74,11 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
|||
const displayThreads = threadsMetadata.slice(0, 4);
|
||||
const displayThreadIds = displayThreads.map((thread) => thread.id);
|
||||
|
||||
const { statusMap, isLoading: statusLoading } = useThreadsStatus(
|
||||
displayThreadIds,
|
||||
threads,
|
||||
);
|
||||
const {
|
||||
statusMap,
|
||||
taskPlanMap,
|
||||
isLoading: statusLoading,
|
||||
} = useThreadsStatus(displayThreadIds, threads);
|
||||
|
||||
const handleLoadDraft = (content: string) => {
|
||||
setDraftToLoad(content);
|
||||
|
|
@ -218,6 +219,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
|||
thread={thread}
|
||||
status={statusMap[thread.id]}
|
||||
statusLoading={statusLoading}
|
||||
taskPlan={taskPlanMap[thread.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,19 +17,60 @@ import { Skeleton } from "../ui/skeleton";
|
|||
import { ThreadMetadata } from "./types";
|
||||
import { ThreadUIStatus } from "@/lib/schemas/thread-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
|
||||
|
||||
interface ThreadCardProps {
|
||||
thread: ThreadMetadata;
|
||||
status?: ThreadUIStatus;
|
||||
statusLoading?: boolean;
|
||||
taskPlan?: TaskPlan;
|
||||
}
|
||||
|
||||
export function ThreadCard({ thread, status, statusLoading }: ThreadCardProps) {
|
||||
export function ThreadCard({
|
||||
thread,
|
||||
status,
|
||||
statusLoading,
|
||||
taskPlan,
|
||||
}: ThreadCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const isStatusLoading = statusLoading && !status;
|
||||
const displayStatus = status || ("idle" as ThreadUIStatus);
|
||||
|
||||
const getTaskProgress = () => {
|
||||
if (!taskPlan || !taskPlan.tasks.length) {
|
||||
return { currentTaskIndex: 0, totalTasks: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const planItems = getActivePlanItems(taskPlan);
|
||||
const sortedPlanItems = [...planItems].sort((a, b) => a.index - b.index);
|
||||
|
||||
// Find the current task (lowest index among uncompleted tasks)
|
||||
const currentTaskIndex = sortedPlanItems
|
||||
.filter((item) => !item.completed)
|
||||
.reduce(
|
||||
(min, item) => (item.index < min ? item.index : min),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
const displayCurrentIndex =
|
||||
currentTaskIndex === Number.POSITIVE_INFINITY
|
||||
? sortedPlanItems.length // All tasks completed
|
||||
: currentTaskIndex; // +1 for 1-based display
|
||||
|
||||
return {
|
||||
currentTaskIndex: displayCurrentIndex,
|
||||
totalTasks: sortedPlanItems.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return { currentTaskIndex: 0, totalTasks: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
const { currentTaskIndex, totalTasks } = getTaskProgress();
|
||||
|
||||
const getStatusColor = (status: ThreadUIStatus) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
|
|
@ -130,9 +171,9 @@ export function ThreadCard({ thread, status, statusLoading }: ThreadCardProps) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{thread.taskCount === 0
|
||||
? "No tasks"
|
||||
: `${thread.taskCount} tasks`}
|
||||
{!taskPlan || !taskPlan.tasks.length
|
||||
? "No active plan"
|
||||
: `${currentTaskIndex}/${totalTasks} tasks`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">•</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import {
|
|||
} from "../../utils/scroll-utils";
|
||||
import { ManagerChat } from "./manager-chat";
|
||||
import { CancelStreamButton } from "./cancel-stream-button";
|
||||
import { ProgressBar } from "@/components/tasks/progress-bar";
|
||||
import { TasksSidebar } from "@/components/tasks";
|
||||
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
import { ErrorState } from "./types";
|
||||
import {
|
||||
CustomNodeEvent,
|
||||
|
|
@ -58,6 +61,14 @@ export function ThreadView({
|
|||
useState<ManagerGraphState["plannerSession"]>();
|
||||
const [programmerSession, setProgrammerSession] =
|
||||
useState<ManagerGraphState["programmerSession"]>();
|
||||
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
|
||||
const [programmerTaskPlan, setProgrammerTaskPlan] = useState<TaskPlan>();
|
||||
|
||||
const { status: realTimeStatus, taskPlan: realTimeTaskPlan } =
|
||||
useThreadStatus(displayThread.id, {
|
||||
useTaskPlanConfig: true,
|
||||
});
|
||||
|
||||
const [errorState, setErrorState] = useState<ErrorState | null>(null);
|
||||
|
||||
const [customPlannerNodeEvents, setCustomPlannerNodeEvents] = useState<
|
||||
|
|
@ -124,12 +135,10 @@ export function ThreadView({
|
|||
stream?.values?.plannerSession &&
|
||||
plannerSession?.runId !== stream.values.plannerSession.runId
|
||||
) {
|
||||
// State shouldn't update before we use it below, but still create a copy to avoid race conditions
|
||||
const prevPlannerSession = plannerSession;
|
||||
setPlannerSession(stream.values.plannerSession);
|
||||
|
||||
if (prevPlannerSession && selectedTab === "programmer") {
|
||||
// If we already has a planner session, and the user is currently on the programmer tab, bring them back to the planner tab
|
||||
setSelectedTab("planner");
|
||||
}
|
||||
}
|
||||
|
|
@ -179,7 +188,15 @@ export function ThreadView({
|
|||
}
|
||||
}, [plannerStream.values, selectedTab]);
|
||||
|
||||
const { status: realTimeStatus } = useThreadStatus(displayThread.id);
|
||||
// Extract task plan from programmer stream
|
||||
useEffect(() => {
|
||||
if (programmerStream.values?.taskPlan) {
|
||||
setProgrammerTaskPlan(programmerStream.values.taskPlan);
|
||||
} else if (realTimeTaskPlan) {
|
||||
// Fallback to real-time task plan if stream doesn't have it
|
||||
setProgrammerTaskPlan(realTimeTaskPlan);
|
||||
}
|
||||
}, [programmerStream.values, realTimeTaskPlan]);
|
||||
|
||||
const getStatusDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
|
|
@ -293,12 +310,29 @@ export function ThreadView({
|
|||
setSelectedTab(value as "planner" | "programmer")
|
||||
}
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<TabsList className="bg-muted/70 dark:bg-gray-800">
|
||||
<TabsTrigger value="planner">Planner</TabsTrigger>
|
||||
<TabsTrigger value="programmer">Programmer</TabsTrigger>
|
||||
<div className="flex flex-shrink-0 items-center gap-3">
|
||||
<TabsList className="bg-muted/70 h-13 px-2 dark:bg-gray-800">
|
||||
<TabsTrigger
|
||||
className="h-8"
|
||||
value="planner"
|
||||
>
|
||||
Planner
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="h-8"
|
||||
value="programmer"
|
||||
>
|
||||
Programmer
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{programmerTaskPlan && (
|
||||
<ProgressBar
|
||||
taskPlan={programmerTaskPlan}
|
||||
onOpenSidebar={() => setIsTaskSidebarOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{selectedTab === "planner" && plannerStream.isLoading && (
|
||||
<CancelStreamButton
|
||||
|
|
@ -423,6 +457,15 @@ export function ThreadView({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Sidebar */}
|
||||
{programmerTaskPlan && (
|
||||
<TasksSidebar
|
||||
isOpen={isTaskSidebarOpen}
|
||||
onClose={() => setIsTaskSidebarOpen(false)}
|
||||
taskPlan={programmerTaskPlan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import useSWR from "swr";
|
||||
import { THREAD_STATUS_SWR_CONFIG } from "@/lib/swr-config";
|
||||
import {
|
||||
THREAD_STATUS_SWR_CONFIG,
|
||||
TASK_PLAN_SWR_CONFIG,
|
||||
} from "@/lib/swr-config";
|
||||
import { ThreadUIStatus, ThreadStatusData } from "@/lib/schemas/thread-status";
|
||||
import { fetchThreadStatus } from "@/services/thread-status.service";
|
||||
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
|
||||
interface UseThreadStatusOptions {
|
||||
enabled?: boolean;
|
||||
refreshInterval?: number;
|
||||
useTaskPlanConfig?: boolean;
|
||||
}
|
||||
|
||||
interface ThreadStatusResult {
|
||||
status: ThreadUIStatus;
|
||||
taskPlan?: TaskPlan;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
mutate: () => void;
|
||||
|
|
@ -18,6 +24,7 @@ interface ThreadStatusResult {
|
|||
/**
|
||||
* Thread status hook using SWR for real-time status updates
|
||||
* Uses SWR caching directly instead of manual Zustand cache
|
||||
* Can use high-frequency task plan config for real-time progress updates
|
||||
*/
|
||||
export function useThreadStatus(
|
||||
threadId: string,
|
||||
|
|
@ -25,22 +32,29 @@ export function useThreadStatus(
|
|||
): ThreadStatusResult {
|
||||
const {
|
||||
enabled = true,
|
||||
refreshInterval = THREAD_STATUS_SWR_CONFIG.refreshInterval,
|
||||
refreshInterval,
|
||||
useTaskPlanConfig = false,
|
||||
} = options;
|
||||
|
||||
const swrConfig = useTaskPlanConfig
|
||||
? TASK_PLAN_SWR_CONFIG
|
||||
: THREAD_STATUS_SWR_CONFIG;
|
||||
|
||||
const finalConfig = refreshInterval
|
||||
? { ...swrConfig, refreshInterval }
|
||||
: swrConfig;
|
||||
|
||||
const swrKey = enabled ? `thread-status-${threadId}` : null;
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<ThreadStatusData>(
|
||||
swrKey,
|
||||
() => fetchThreadStatus(threadId),
|
||||
{
|
||||
...THREAD_STATUS_SWR_CONFIG,
|
||||
refreshInterval,
|
||||
},
|
||||
finalConfig,
|
||||
);
|
||||
|
||||
return {
|
||||
status: data?.status || "idle",
|
||||
taskPlan: data?.taskPlan,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useMemo, useRef } from "react";
|
|||
import { Thread } from "@langchain/langgraph-sdk";
|
||||
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||
import { GraphState, TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||
|
||||
export interface SessionCacheData {
|
||||
plannerData?: { thread: Thread<PlannerGraphState> };
|
||||
|
|
@ -20,6 +20,10 @@ interface ThreadStatusMap {
|
|||
[threadId: string]: ThreadUIStatus;
|
||||
}
|
||||
|
||||
interface TaskPlanMap {
|
||||
[threadId: string]: TaskPlan;
|
||||
}
|
||||
|
||||
interface ThreadStatusCounts {
|
||||
all: number;
|
||||
running: number;
|
||||
|
|
@ -43,6 +47,7 @@ interface GroupedThreadIds {
|
|||
|
||||
interface UseThreadsStatusResult {
|
||||
statusMap: ThreadStatusMap;
|
||||
taskPlanMap: TaskPlanMap;
|
||||
statusCounts: ThreadStatusCounts;
|
||||
groupedThreads: GroupedThreadIds;
|
||||
isLoading: boolean;
|
||||
|
|
@ -63,6 +68,7 @@ async function fetchAllThreadStatuses(
|
|||
managerThreads?: Thread<ManagerGraphState>[],
|
||||
): Promise<{
|
||||
statusMap: ThreadStatusMap;
|
||||
taskPlanMap: TaskPlanMap;
|
||||
updatedStates: Map<string, ThreadStatusData>;
|
||||
}> {
|
||||
const statusPromises = threadIds.map(async (threadId) => {
|
||||
|
|
@ -92,16 +98,20 @@ async function fetchAllThreadStatuses(
|
|||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const statusMap: ThreadStatusMap = {};
|
||||
const taskPlanMap: TaskPlanMap = {};
|
||||
const updatedStates = new Map<string, ThreadStatusData>();
|
||||
|
||||
results.forEach(({ threadId, status, statusData }) => {
|
||||
statusMap[threadId] = status;
|
||||
if (statusData) {
|
||||
updatedStates.set(threadId, statusData);
|
||||
if (statusData.taskPlan) {
|
||||
taskPlanMap[threadId] = statusData.taskPlan;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { statusMap, updatedStates };
|
||||
return { statusMap, taskPlanMap, updatedStates };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -150,6 +160,7 @@ export function useThreadsStatus(
|
|||
);
|
||||
|
||||
const statusMap = fetchResult?.statusMap || {};
|
||||
const taskPlanMap = fetchResult?.taskPlanMap || {};
|
||||
|
||||
return useMemo(() => {
|
||||
const groupedThreads: GroupedThreadIds = {
|
||||
|
|
@ -183,10 +194,11 @@ export function useThreadsStatus(
|
|||
|
||||
return {
|
||||
statusMap: statusMap || {},
|
||||
taskPlanMap: taskPlanMap || {},
|
||||
statusCounts,
|
||||
groupedThreads,
|
||||
isLoading,
|
||||
hasErrors: !!error,
|
||||
};
|
||||
}, [statusMap, threadIds, threadIdsKey, isLoading, error]);
|
||||
}, [statusMap, taskPlanMap, threadIds, threadIdsKey, isLoading, error]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
* across all thread data fetching in the application.
|
||||
*/
|
||||
export const THREAD_SWR_CONFIG = {
|
||||
refreshInterval: 15000,
|
||||
refreshInterval: 15000, // 15s for general thread data
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 5000,
|
||||
dedupingInterval: 2000,
|
||||
dedupingInterval: 5000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
@ -20,7 +20,20 @@ export const THREAD_SWR_CONFIG = {
|
|||
export const THREAD_STATUS_SWR_CONFIG = {
|
||||
...THREAD_SWR_CONFIG,
|
||||
revalidateOnFocus: true,
|
||||
dedupingInterval: 5000,
|
||||
dedupingInterval: 2000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SWR configuration for high-frequency task plan polling
|
||||
* Used when actively viewing a thread for real-time progress updates
|
||||
*/
|
||||
export const TASK_PLAN_SWR_CONFIG = {
|
||||
refreshInterval: 3000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 2000,
|
||||
dedupingInterval: 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue