diff --git a/apps/web/src/components/task-list-sidebar.tsx b/apps/web/src/components/task-list-sidebar.tsx index 39da7eb5..348dce99 100644 --- a/apps/web/src/components/task-list-sidebar.tsx +++ b/apps/web/src/components/task-list-sidebar.tsx @@ -1,5 +1,4 @@ "use client"; - import { Archive, ChevronLeft, @@ -9,12 +8,11 @@ import { import { Button } from "@/components/ui/button"; import { useThreads, ThreadWithTasks } from "@/providers/Thread"; import { useQueryState, parseAsString } from "nuqs"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { ThreadItem } from "./thread-item"; -const THREADS_PER_PAGE = 10; // More threads per page in sidebar +const THREADS_PER_PAGE = 10; -// TODO: Clarify Language about Threads and Tasks in the TaskListSidebar component interface TaskListSidebarProps { onCollapse?: () => void; } @@ -23,15 +21,15 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) { const [taskId, setTaskId] = useQueryState("taskId", parseAsString); const [threadId, setThreadId] = useQueryState("threadId", parseAsString); const [currentPage, setCurrentPage] = useState(0); - const { threads, threadsLoading } = useThreads(); + const { threads, threadsLoading, handleThreadClick } = useThreads(); - // Handle thread navigation - const handleThreadClick = (thread: ThreadWithTasks) => { - setThreadId(thread.thread_id); - setTaskId(null); - }; - - // Sort threads by creation date (newest first) - already done in provider + const onThreadClick = useCallback( + (thread: ThreadWithTasks) => { + handleThreadClick(thread, threadId, setThreadId); + }, + [handleThreadClick, threadId, setThreadId], + ); + // Sort threads by creation date (newest first) TODO use provider (already done there) const sortedThreads = threads; const totalThreads = sortedThreads.length; const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE); @@ -77,7 +75,7 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) { ))} diff --git a/apps/web/src/components/task-list.tsx b/apps/web/src/components/task-list.tsx index 1a132c3d..c420940a 100644 --- a/apps/web/src/components/task-list.tsx +++ b/apps/web/src/components/task-list.tsx @@ -1,26 +1,28 @@ "use client"; - import { Archive, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useThreads, ThreadWithTasks } from "@/providers/Thread"; import { useQueryState, parseAsString } from "nuqs"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { ThreadItem } from "./thread-item"; const THREADS_PER_PAGE = 5; export default function TaskList() { const [taskId, setTaskId] = useQueryState("taskId", parseAsString); - const [_threadId, setThreadId] = useQueryState("threadId", parseAsString); + const [threadId, setThreadId] = useQueryState("threadId", parseAsString); const [currentPage, setCurrentPage] = useState(0); - const { threads, threadsLoading } = useThreads(); + const { threads, threadsLoading, handleThreadClick } = useThreads(); const isDashboardMode = !taskId; - const handleThreadClick = (thread: ThreadWithTasks) => { - setThreadId(thread.thread_id); - setTaskId(null); - }; + const onThreadClick = useCallback( + (thread: ThreadWithTasks) => { + handleThreadClick(thread, threadId, setThreadId); + setTaskId(null); + }, + [handleThreadClick, threadId, setThreadId, setTaskId], + ); if (!isDashboardMode) { return null; @@ -51,7 +53,7 @@ export default function TaskList() { ))} diff --git a/apps/web/src/components/thread-item.tsx b/apps/web/src/components/thread-item.tsx index 7b0c19df..e3ac0f8d 100644 --- a/apps/web/src/components/thread-item.tsx +++ b/apps/web/src/components/thread-item.tsx @@ -1,11 +1,9 @@ "use client"; - +import { memo } from "react"; import { GitBranch, ArrowRight, ListTodo } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { ThreadWithTasks } from "@/providers/Thread"; +import { ThreadWithTasks, useThreads } from "@/providers/Thread"; import { cn } from "@/lib/utils"; import { StatusIndicator } from "@/components/status-indicator"; -import { Skeleton } from "@/components/ui/skeleton"; import { GitHubSVG } from "./icons/github"; import { useQueryState } from "nuqs"; @@ -16,66 +14,29 @@ interface ThreadItemProps { className?: string; } -export function ThreadItem({ +export const ThreadItem = memo(function ThreadItem({ thread, onClick, variant = "dashboard", className, }: ThreadItemProps) { const [threadId] = useQueryState("threadId"); + const { recentlyUpdatedThreads } = useThreads(); const isSelected = thread.thread_id === threadId; const isSidebar = variant === "sidebar"; + const isRecentlyUpdated = recentlyUpdatedThreads.has(thread.thread_id); const displayDate = new Date(thread.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", }); - // Check if thread data is still loading/incomplete - const isLoading = - !thread.threadTitle || - thread.threadTitle.includes("undefined") || - !thread.repository || - thread.repository === "Unknown Repository" || - thread.repository.includes("undefined"); - - if (isLoading) { - return ( -
-
-
-
- - -
- -
- {/* GitHub icon placeholder */} - {/* Repo */} - {" "} - {/* Branch */} - {/* Date */} -
- -
- -
-
-
-
- ); - } - return (
{ @@ -139,4 +100,4 @@ export function ThreadItem({
); -} +}); diff --git a/apps/web/src/hooks/useThreadPolling.ts b/apps/web/src/hooks/useThreadPolling.ts new file mode 100644 index 00000000..042e388e --- /dev/null +++ b/apps/web/src/hooks/useThreadPolling.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { ThreadPoller, PollConfig } from "@/lib/polling/thread-poller"; +import { ThreadWithTasks } from "@/providers/Thread"; + +interface UseThreadPollingProps { + threads: ThreadWithTasks[]; + getThread: (threadId: string) => Promise; + onUpdate: ( + updatedThreads: ThreadWithTasks[], + changedThreadIds: string[], + ) => void; + + enabled?: boolean; +} + +export function useThreadPolling({ + threads, + getThread, + onUpdate, + enabled = true, +}: UseThreadPollingProps) { + const pollerRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + const config: PollConfig = { + interval: 15000, + onUpdate, + }; + + pollerRef.current = new ThreadPoller(config, threads, getThread); + pollerRef.current.start(); + + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, [threads, getThread, onUpdate, enabled]); + + return { + start: () => pollerRef.current?.start(), + stop: () => pollerRef.current?.stop(), + }; +} diff --git a/apps/web/src/lib/polling/thread-poller.ts b/apps/web/src/lib/polling/thread-poller.ts new file mode 100644 index 00000000..9cfa43b4 --- /dev/null +++ b/apps/web/src/lib/polling/thread-poller.ts @@ -0,0 +1,97 @@ +import { ThreadWithTasks } from "@/providers/Thread"; + +export interface PollConfig { + interval: number; + onUpdate: ( + updatedThreads: ThreadWithTasks[], + changedThreadIds: string[], + ) => void; +} + +export class ThreadPoller { + private config: PollConfig; + private isPolling: boolean = false; + private intervalId: NodeJS.Timeout | null = null; + private threads: ThreadWithTasks[]; + private getThreadFn: (threadId: string) => Promise; + + constructor( + config: PollConfig, + threads: ThreadWithTasks[], + getThreadFn: (threadId: string) => Promise, + ) { + this.config = config; + this.threads = threads; + this.getThreadFn = getThreadFn; + } + + start(): void { + if (this.isPolling) return; + + this.isPolling = true; + this.intervalId = setInterval(() => { + this.pollThreads(); + }, this.config.interval); + } + + stop(): void { + if (!this.isPolling) return; + + this.isPolling = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + private async pollThreads(): Promise { + try { + const currentThreads = this.threads; + + const threadsToPool = currentThreads.slice(0, 10); + const updatedThreads: ThreadWithTasks[] = []; + const changedThreadIds: string[] = []; + const errors: string[] = []; + + for (const currentThread of threadsToPool) { + try { + const updatedThread = await this.getThreadFn(currentThread.thread_id); + if (updatedThread) { + updatedThreads.push(updatedThread); + + if (this.hasThreadChanged(currentThread, updatedThread)) { + changedThreadIds.push(updatedThread.thread_id); + } + } + } catch (error) { + errors.push(`Thread ${currentThread.thread_id}: ${error}`); + + updatedThreads.push(currentThread); + } + } + + if (changedThreadIds.length > 0) { + this.config.onUpdate(updatedThreads, changedThreadIds); + } + } catch (error) { + console.error("Thread polling error:", error); + } + } + + private hasThreadChanged( + current: ThreadWithTasks, + updated: ThreadWithTasks, + ): boolean { + return ( + current.completedTasksCount !== updated.completedTasksCount || + current.totalTasksCount !== updated.totalTasksCount || + current.status !== updated.status || + current.threadTitle !== updated.threadTitle || + current.repository !== updated.repository || + current.branch !== updated.branch || + JSON.stringify(current.tasks) !== JSON.stringify(updated.tasks) || + JSON.stringify(current.proposedPlan) !== + JSON.stringify(updated.proposedPlan) + ); + } +} diff --git a/apps/web/src/providers/Stream.tsx b/apps/web/src/providers/Stream.tsx index 3a38eaa5..68589eaf 100644 --- a/apps/web/src/providers/Stream.tsx +++ b/apps/web/src/providers/Stream.tsx @@ -4,7 +4,6 @@ import React, { ReactNode, useState, useEffect, - useRef, } from "react"; import { useStream } from "@langchain/langgraph-sdk/react"; import { @@ -52,8 +51,7 @@ const StreamSession = ({ githubToken: string; }) => { const [threadId, setThreadId] = useQueryState("threadId"); - const { refreshThreads, updateThreadFromStream } = useThreads(); - + const { refreshThreads, setThreads } = useThreads(); const streamValue = useTypedStream({ apiUrl, assistantId, @@ -69,46 +67,13 @@ const StreamSession = ({ }, onThreadId: (id) => { setThreadId(id); - // Refetch threads list when thread ID changes. - // Wait for some seconds before fetching so we're able to get the new thread that was created. + sleep().then(() => { refreshThreads().catch(console.error); }); }, }); - // Listen for stream updates to update all thread properties in real-time - // Use a ref to track the last update to prevent excessive calls - const lastUpdateRef = useRef<{ threadId: string; valuesHash: string } | null>( - null, - ); - - useEffect(() => { - if (threadId && streamValue.values) { - // Create a simple hash of the values to detect actual changes - const valuesHash = JSON.stringify({ - plan: (streamValue.values as any).plan, - proposedPlan: (streamValue.values as any).proposedPlan, - targetRepository: (streamValue.values as any).targetRepository, - messages: ( - streamValue.values as any - ).messages?.[0]?.content?.[0]?.text?.substring(0, 50), - }); - - // Only update if thread or values actually changed - if ( - !lastUpdateRef.current || - lastUpdateRef.current.threadId !== threadId || - lastUpdateRef.current.valuesHash !== valuesHash - ) { - lastUpdateRef.current = { threadId, valuesHash }; - updateThreadFromStream(threadId, streamValue.values); - } - } - }, [threadId, streamValue.values, updateThreadFromStream]); - - // Real-time updates via stream - no polling needed - return ( {children} @@ -129,7 +94,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ const [isAuth, setIsAuth] = useState(null); const [isLoading, setIsLoading] = useState(false); - // Initialize from localStorage to prevent modal flash const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState< boolean | null >(() => { @@ -152,7 +116,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ useEffect(() => { if (isAuth) { - // Check if we already have cached installation status const cachedInstallationStatus = localStorage.getItem( "github_app_installed", ); @@ -165,7 +128,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ } else if (cachedInstallationStatus === "false") { setHasGitHubAppInstalled(false); } else { - // Only check installation if we don't have cached status checkGitHubAppInstallation(); } } @@ -189,7 +151,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ if (response.ok) { setHasGitHubAppInstalled(true); localStorage.setItem("github_app_installed", "true"); - // If the app is installed, fetch a token + await fetchGitHubToken(); } else { const errorData = await response.json(); @@ -197,7 +159,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ setHasGitHubAppInstalled(false); localStorage.setItem("github_app_installed", "false"); } else { - // If there's a different error, we'll assume the app is not installed setHasGitHubAppInstalled(false); localStorage.setItem("github_app_installed", "false"); } @@ -218,7 +179,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ const handleInstallGitHubApp = () => { setIsLoading(true); - // Clear cached status so we check again after installation + localStorage.removeItem("github_app_installed"); window.location.href = "/api/github/installation"; }; @@ -401,7 +362,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({ ); }; -// Create a custom hook to use the context export const useStreamContext = (): StreamContextType => { const context = useContext(StreamContext); if (context === undefined) { diff --git a/apps/web/src/providers/Thread.tsx b/apps/web/src/providers/Thread.tsx index f90c70d5..61429cb0 100644 --- a/apps/web/src/providers/Thread.tsx +++ b/apps/web/src/providers/Thread.tsx @@ -9,10 +9,12 @@ import { Dispatch, SetStateAction, useEffect, + useTransition, } from "react"; import { createClient } from "./client"; import { getMessageContentString } from "@open-swe/shared/messages"; -import { TaskPlan } from "@open-swe/shared/open-swe/types"; +import { TaskPlan, GraphState } from "@open-swe/shared/open-swe/types"; +import { useThreadPolling } from "@/hooks/useThreadPolling"; export interface ThreadWithTasks extends Thread { threadTitle: string; @@ -31,7 +33,13 @@ interface ThreadContextType { setThreadsLoading: Dispatch>; refreshThreads: () => Promise; getThread: (threadId: string) => Promise; - updateThreadFromStream: (threadId: string, streamValues: any) => void; + isPending: boolean; + recentlyUpdatedThreads: Set; + handleThreadClick: ( + thread: ThreadWithTasks, + currentThreadId: string | null, + setThreadId: (id: string) => void, + ) => void; } const ThreadContext = createContext(undefined); @@ -49,39 +57,56 @@ function getThreadSearchMetadata( const getTaskCounts = ( tasks?: TaskPlan, proposedPlan?: string[], + existingCounts?: { totalTasksCount: number; completedTasksCount: number }, ): { totalTasksCount: number; completedTasksCount: number } => { - if (proposedPlan && !tasks) { + const defaultCounts = existingCounts || { + totalTasksCount: 0, + completedTasksCount: 0, + }; + + if (proposedPlan && proposedPlan.length > 0 && !tasks) { return { totalTasksCount: proposedPlan.length, completedTasksCount: 0, }; } - if (!tasks) { - // No tasks passed, return 0s - return { totalTasksCount: 0, completedTasksCount: 0 }; + if (!tasks || !tasks.tasks || tasks.tasks.length === 0) { + return defaultCounts; + } + const activeTaskIndex = tasks.activeTaskIndex; + const activeTask = tasks.tasks.find( + (task) => task.taskIndex === activeTaskIndex, + ); + + if ( + !activeTask || + !activeTask.planRevisions || + activeTask.planRevisions.length === 0 + ) { + return defaultCounts; } - const activeTaskList = tasks.tasks.find( - (t) => t.taskIndex === tasks.activeTaskIndex, + const activeRevisionIndex = activeTask.activeRevisionIndex; + const activeRevision = activeTask.planRevisions.find( + (revision) => revision.revisionIndex === activeRevisionIndex, ); - if (!activeTaskList) { - // Something is wrong here. Return 0 - return { totalTasksCount: 0, completedTasksCount: 0 }; + + if ( + !activeRevision || + !activeRevision.plans || + activeRevision.plans.length === 0 + ) { + return defaultCounts; } - const activeTaskPlans = activeTaskList.planRevisions.find( - (p) => p.revisionIndex === activeTaskList.activeRevisionIndex, - ); - if (!activeTaskPlans) { - // Something is wrong here. Return 0 - return { totalTasksCount: 0, completedTasksCount: 0 }; - } + const plans = activeRevision.plans; + + const completedTasksCount = plans.filter((p) => p.completed)?.length || 0; return { - totalTasksCount: activeTaskPlans.plans.length, - completedTasksCount: activeTaskPlans.plans.filter((p) => p.completed) - .length, + totalTasksCount: plans.length, + completedTasksCount, }; }; @@ -92,63 +117,10 @@ export function ThreadProvider({ children }: { children: ReactNode }) { const [threads, setThreads] = useState([]); const [threadsLoading, setThreadsLoading] = useState(false); - const [busyThreadIds, setBusyThreadIds] = useState([]); - - // Real-time thread updater for all properties (replaces polling) - const updateThreadFromStream = useCallback( - (threadId: string, streamValues: any) => { - if (!threadId || !streamValues) return; - - setThreads((currentThreads) => { - const targetThread = currentThreads.find( - (t) => t.thread_id === threadId, - ); - if (!targetThread) return currentThreads; // Thread not found, no update needed - - const plan: TaskPlan | undefined = streamValues?.plan; - const proposedPlan: string[] = streamValues?.proposedPlan || []; - const targetRepository = streamValues?.targetRepository; - const messages = streamValues?.messages; - - const { totalTasksCount, completedTasksCount } = getTaskCounts( - plan, - proposedPlan, - ); - - // Extract thread title from messages if available - const firstMessageContent = messages?.[0]?.content; - const threadTitle = firstMessageContent - ? getMessageContentString(firstMessageContent) - : targetThread.threadTitle; - - const newRepository = - targetRepository?.repo || - targetRepository?.name || - targetThread.repository || - "Unknown Repository"; - - const newBranch = - targetRepository?.branch || targetThread.branch || "main"; - - return currentThreads.map((thread) => { - if (thread.thread_id === threadId) { - return { - ...thread, - threadTitle, - repository: newRepository, - branch: newBranch, - completedTasksCount, - totalTasksCount, - tasks: plan, - proposedPlan, - }; - } - return thread; - }); - }); - }, - [], - ); + const [isPending, startTransition] = useTransition(); + const [recentlyUpdatedThreads, setRecentlyUpdatedThreads] = useState< + Set + >(new Set()); const getThread = useCallback( async (threadId: string): Promise => { @@ -157,7 +129,17 @@ export function ThreadProvider({ children }: { children: ReactNode }) { try { const thread = await client.threads.get(threadId); - return enhanceThreadWithTasks(thread); + + // Get the comprehensive state data which contains the plan + let stateData: { values: GraphState } | null = null; + + try { + stateData = await client.threads.getState(threadId); + } catch (stateError) { + console.error("Failed to get state data:", stateError); + } + + return enhanceThreadWithTasks(thread, stateData); } catch (error) { console.error("Failed to fetch thread:", threadId, error); return null; @@ -166,13 +148,20 @@ export function ThreadProvider({ children }: { children: ReactNode }) { [apiUrl, assistantId], ); - const enhanceThreadWithTasks = (thread: Thread): ThreadWithTasks => { - const threadValues = thread.values as any; - const plan: TaskPlan | undefined = threadValues?.plan; - const proposedPlan: string[] = threadValues?.proposedPlan || []; + const enhanceThreadWithTasks = ( + thread: Thread, + stateData?: { values: GraphState } | null, + ): ThreadWithTasks => { + const stateValues = stateData?.values; + const threadValues = thread.values as GraphState; - const targetRepository = threadValues?.targetRepository; - const messages = (threadValues as any)?.messages; + const plan: TaskPlan | undefined = stateValues?.plan || threadValues?.plan; + const proposedPlan: string[] = + stateValues?.proposedPlan || threadValues?.proposedPlan || []; + + const targetRepository = + stateValues?.targetRepository || threadValues?.targetRepository; + const messages = stateValues?.messages || threadValues?.messages; const firstMessageContent = messages?.[0]?.content; const threadTitle = firstMessageContent ? getMessageContentString(firstMessageContent) @@ -186,10 +175,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) { return { ...thread, threadTitle, - repository: - targetRepository?.repo || - targetRepository?.name || - "Unknown Repository", + repository: targetRepository?.repo || "Unknown Repository", branch: targetRepository?.branch || "main", completedTasksCount, totalTasksCount, @@ -205,7 +191,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) { const client = createClient(apiUrl); try { - // Simple thread search - try both metadata approaches const searchParams = { limit: 100, metadata: getThreadSearchMetadata(assistantId), @@ -213,7 +198,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) { let threadsResponse = await client.threads.search(searchParams); - // If no threads found, try alternative metadata if (threadsResponse.length === 0) { const altMetadata = assistantId.includes("-") ? { assistant_id: assistantId } @@ -224,18 +208,24 @@ export function ThreadProvider({ children }: { children: ReactNode }) { }); } - // Enhance threads with task data const enhancedThreads: ThreadWithTasks[] = []; for (const thread of threadsResponse) { try { const fullThread = await client.threads.get(thread.thread_id); - enhancedThreads.push(enhanceThreadWithTasks(fullThread)); + + let stateData: { values: GraphState } | null = null; + try { + stateData = await client.threads.getState(thread.thread_id); + } catch (stateError) { + console.error("Failed to get state data:", stateError); + } + + enhancedThreads.push(enhanceThreadWithTasks(fullThread, stateData)); } catch (error) { console.error(`Failed to enhance thread ${thread.thread_id}:`, error); } } - // Sort by creation date (newest first) enhancedThreads.sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), @@ -249,13 +239,49 @@ export function ThreadProvider({ children }: { children: ReactNode }) { } }, [apiUrl, assistantId]); - // Removed polling - now using real-time stream updates via updateThreadFromStream - - // Initial load useEffect(() => { refreshThreads(); }, [refreshThreads]); + const handlePollingUpdate = useCallback( + (updatedThreads: ThreadWithTasks[], changedThreadIds: string[]) => { + setThreads((currentThreads) => { + const updatedMap = new Map(updatedThreads.map((t) => [t.thread_id, t])); + return currentThreads.map( + (thread) => updatedMap.get(thread.thread_id) || thread, + ); + }); + + setRecentlyUpdatedThreads(new Set(changedThreadIds)); + + setTimeout(() => { + setRecentlyUpdatedThreads(new Set()); + }, 2000); + }, + [], + ); + + // Initialize polling + useThreadPolling({ + threads, + getThread, + onUpdate: handlePollingUpdate, + enabled: true, + }); + + const handleThreadClick = useCallback( + ( + thread: ThreadWithTasks, + currentThreadId: string | null, + setThreadId: (id: string) => void, + ) => { + if (currentThreadId === thread.thread_id) return; + + setThreadId(thread.thread_id); + }, + [], + ); + const value = { threads, setThreads, @@ -263,7 +289,9 @@ export function ThreadProvider({ children }: { children: ReactNode }) { setThreadsLoading, refreshThreads, getThread, - updateThreadFromStream, + isPending, + recentlyUpdatedThreads, + handleThreadClick, }; return (