mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
Dylan/task sidebar polling (#107)
* fix threadItem task-sidebar onClick bugs * format + remove thread-utils * init polling for threadItem updates * rm Stream update for sidebar * tasks completed accurate from state while polling * remove console logs * CR * CR: fix types, rm selectedThread, fix active task logic * fix minor navigation state, update polling animation time * remove type narrowing / casting CR
This commit is contained in:
parent
708ee529d1
commit
30881a5d92
7 changed files with 303 additions and 210 deletions
|
|
@ -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) {
|
|||
<ThreadItem
|
||||
key={thread.thread_id}
|
||||
thread={thread}
|
||||
onClick={handleThreadClick}
|
||||
onClick={onThreadClick}
|
||||
variant="sidebar"
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<ThreadItem
|
||||
key={thread.thread_id}
|
||||
thread={thread}
|
||||
onClick={handleThreadClick}
|
||||
onClick={onThreadClick}
|
||||
variant="dashboard"
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-gray-200 bg-inherit p-1.5 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-1 text-xs">
|
||||
<Skeleton className="h-3 w-3" /> {/* GitHub icon placeholder */}
|
||||
<Skeleton className="h-3 w-16" /> {/* Repo */}
|
||||
<Skeleton className="ml-0.5 h-2.5 w-2.5" />{" "}
|
||||
<Skeleton className="ml-0.5 h-3 w-12" /> {/* Branch */}
|
||||
<Skeleton className="ml-auto h-3 w-10" /> {/* Date */}
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer rounded-md border border-gray-200 bg-inherit p-2 shadow-sm transition-colors hover:bg-gray-50 hover:shadow-md",
|
||||
isSelected && "border-primary",
|
||||
isRecentlyUpdated && "animate-pulse border-blue-200 bg-blue-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
|
|
@ -139,4 +100,4 @@ export function ThreadItem({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
47
apps/web/src/hooks/useThreadPolling.ts
Normal file
47
apps/web/src/hooks/useThreadPolling.ts
Normal file
|
|
@ -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<ThreadWithTasks | null>;
|
||||
onUpdate: (
|
||||
updatedThreads: ThreadWithTasks[],
|
||||
changedThreadIds: string[],
|
||||
) => void;
|
||||
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useThreadPolling({
|
||||
threads,
|
||||
getThread,
|
||||
onUpdate,
|
||||
enabled = true,
|
||||
}: UseThreadPollingProps) {
|
||||
const pollerRef = useRef<ThreadPoller | null>(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(),
|
||||
};
|
||||
}
|
||||
97
apps/web/src/lib/polling/thread-poller.ts
Normal file
97
apps/web/src/lib/polling/thread-poller.ts
Normal file
|
|
@ -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<ThreadWithTasks | null>;
|
||||
|
||||
constructor(
|
||||
config: PollConfig,
|
||||
threads: ThreadWithTasks[],
|
||||
getThreadFn: (threadId: string) => Promise<ThreadWithTasks | null>,
|
||||
) {
|
||||
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<void> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<StreamContext.Provider value={streamValue}>
|
||||
{children}
|
||||
|
|
@ -129,7 +94,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
|||
const [isAuth, setIsAuth] = useState<boolean | null>(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) {
|
||||
|
|
|
|||
|
|
@ -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<SetStateAction<boolean>>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
getThread: (threadId: string) => Promise<ThreadWithTasks | null>;
|
||||
updateThreadFromStream: (threadId: string, streamValues: any) => void;
|
||||
isPending: boolean;
|
||||
recentlyUpdatedThreads: Set<string>;
|
||||
handleThreadClick: (
|
||||
thread: ThreadWithTasks,
|
||||
currentThreadId: string | null,
|
||||
setThreadId: (id: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ThreadContext = createContext<ThreadContextType | undefined>(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<ThreadWithTasks[]>([]);
|
||||
const [threadsLoading, setThreadsLoading] = useState(false);
|
||||
const [busyThreadIds, setBusyThreadIds] = useState<string[]>([]);
|
||||
|
||||
// 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<string>
|
||||
>(new Set());
|
||||
|
||||
const getThread = useCallback(
|
||||
async (threadId: string): Promise<ThreadWithTasks | null> => {
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue