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:
Dylan Boudro 2025-07-22 18:43:42 -04:00 • committed by GitHub
parent 38767397e9
commit 3967ea0dca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 341 additions and 218 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
};
}

View file

@ -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>

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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]);
}

View file

@ -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;
/**