+
diff --git a/apps/web/src/components/tasks/progress-bar.tsx b/apps/web/src/components/tasks/progress-bar.tsx
index 40014573..d10a4019 100644
--- a/apps/web/src/components/tasks/progress-bar.tsx
+++ b/apps/web/src/components/tasks/progress-bar.tsx
@@ -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 (
-
No active plan
+
+ No active plan
+
);
}
- 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 (
-
No active plan
+
+ No active plan
+
);
}
- 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 (
- {/* Compact header */}
-
-
-
-
- Plan Progress
-
-
-
-
-
-
-
+
+
+ Plan Progress
+
+
+
+
+
-
-
+
+
+
+
+
+
Plan Progress
+
+ Shows the current progress of the AI agent's plan execution.
+
+
+
+
+
+
+ {completedCount} of {sortedPlanItems.length} tasks completed
+
+
+
+
-
+
{Math.round(progressPercentage)}%
+
- {/* Legend - conditionally shown */}
- {showLegend && (
-
-
-
-
-
+ {showLegend && (
+
+
- )}
-
- {/* Progress Stats */}
-
-
- {completedCount} of {planItems.length} tasks completed
-
-
- Task #{currentTask.taskIndex + 1}
-
-
-
- {/* Progress Bar */}
-
-
+
+
+
+ )}
- return (
-
-
-
-
-
-
-
-
- Task #{item.index + 1}
-
-
- {state === "completed"
- ? "Completed"
- : state === "current"
- ? "Current"
- : "Pending"}
-
-
-
{item.plan}
+ {/* Progress Bar */}
+
+
+ {sortedPlanItems.map((item) => {
+ const state = getItemState(item);
+ const segmentWidth = `${100 / sortedPlanItems.length}%`;
+
+ return (
+
+
+
+
+
+
+
+
+ Task #{item.index + 1}
+
+
+ {state === "completed"
+ ? "Completed"
+ : state === "current"
+ ? "Current"
+ : "Pending"}
+
-
-
- );
- })}
-
+ {item.plan}
+
+
+
+ );
+ })}
diff --git a/apps/web/src/components/tasks/useTaskPlan.tsx b/apps/web/src/components/tasks/useTaskPlan.tsx
deleted file mode 100644
index c9d33335..00000000
--- a/apps/web/src/components/tasks/useTaskPlan.tsx
+++ /dev/null
@@ -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
();
-
- 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,
- };
-}
diff --git a/apps/web/src/components/v2/default-view.tsx b/apps/web/src/components/v2/default-view.tsx
index e337cce7..aaf48862 100644
--- a/apps/web/src/components/v2/default-view.tsx
+++ b/apps/web/src/components/v2/default-view.tsx
@@ -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]}
/>
))}
diff --git a/apps/web/src/components/v2/thread-card.tsx b/apps/web/src/components/v2/thread-card.tsx
index ab65ce18..6ab98f1f 100644
--- a/apps/web/src/components/v2/thread-card.tsx
+++ b/apps/web/src/components/v2/thread-card.tsx
@@ -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) {
- {thread.taskCount === 0
- ? "No tasks"
- : `${thread.taskCount} tasks`}
+ {!taskPlan || !taskPlan.tasks.length
+ ? "No active plan"
+ : `${currentTaskIndex}/${totalTasks} tasks`}
•
diff --git a/apps/web/src/components/v2/thread-view.tsx b/apps/web/src/components/v2/thread-view.tsx
index 3d0b8a67..abd84858 100644
--- a/apps/web/src/components/v2/thread-view.tsx
+++ b/apps/web/src/components/v2/thread-view.tsx
@@ -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();
const [programmerSession, setProgrammerSession] =
useState();
+ const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
+ const [programmerTaskPlan, setProgrammerTaskPlan] = useState();
+
+ const { status: realTimeStatus, taskPlan: realTimeTaskPlan } =
+ useThreadStatus(displayThread.id, {
+ useTaskPlanConfig: true,
+ });
+
const [errorState, setErrorState] = useState(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")
}
>
-
-
- Planner
- Programmer
+
+
+
+ Planner
+
+
+ Programmer
+
+ {programmerTaskPlan && (
+
setIsTaskSidebarOpen(true)}
+ />
+ )}
+
{selectedTab === "planner" && plannerStream.isLoading && (
+
+ {/* Task Sidebar */}
+ {programmerTaskPlan && (
+ setIsTaskSidebarOpen(false)}
+ taskPlan={programmerTaskPlan}
+ />
+ )}
);
}
diff --git a/apps/web/src/hooks/useThreadStatus.ts b/apps/web/src/hooks/useThreadStatus.ts
index e4c62c5a..d3a5283f 100644
--- a/apps/web/src/hooks/useThreadStatus.ts
+++ b/apps/web/src/hooks/useThreadStatus.ts
@@ -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(
swrKey,
() => fetchThreadStatus(threadId),
- {
- ...THREAD_STATUS_SWR_CONFIG,
- refreshInterval,
- },
+ finalConfig,
);
return {
status: data?.status || "idle",
+ taskPlan: data?.taskPlan,
isLoading,
error,
mutate,
diff --git a/apps/web/src/hooks/useThreadsStatus.ts b/apps/web/src/hooks/useThreadsStatus.ts
index abc94ec6..129a8a09 100644
--- a/apps/web/src/hooks/useThreadsStatus.ts
+++ b/apps/web/src/hooks/useThreadsStatus.ts
@@ -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 };
@@ -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[],
): Promise<{
statusMap: ThreadStatusMap;
+ taskPlanMap: TaskPlanMap;
updatedStates: Map;
}> {
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();
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]);
}
diff --git a/apps/web/src/lib/swr-config.ts b/apps/web/src/lib/swr-config.ts
index e5dfc563..c66b205a 100644
--- a/apps/web/src/lib/swr-config.ts
+++ b/apps/web/src/lib/swr-config.ts
@@ -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;
/**