mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 19:22:13 +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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|
@ -9,12 +8,11 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
|
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
|
||||||
import { useQueryState, parseAsString } from "nuqs";
|
import { useQueryState, parseAsString } from "nuqs";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { ThreadItem } from "./thread-item";
|
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 {
|
interface TaskListSidebarProps {
|
||||||
onCollapse?: () => void;
|
onCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -23,15 +21,15 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
|
||||||
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
|
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
|
||||||
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
|
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const { threads, threadsLoading } = useThreads();
|
const { threads, threadsLoading, handleThreadClick } = useThreads();
|
||||||
|
|
||||||
// Handle thread navigation
|
const onThreadClick = useCallback(
|
||||||
const handleThreadClick = (thread: ThreadWithTasks) => {
|
(thread: ThreadWithTasks) => {
|
||||||
setThreadId(thread.thread_id);
|
handleThreadClick(thread, threadId, setThreadId);
|
||||||
setTaskId(null);
|
},
|
||||||
};
|
[handleThreadClick, threadId, setThreadId],
|
||||||
|
);
|
||||||
// Sort threads by creation date (newest first) - already done in provider
|
// Sort threads by creation date (newest first) TODO use provider (already done there)
|
||||||
const sortedThreads = threads;
|
const sortedThreads = threads;
|
||||||
const totalThreads = sortedThreads.length;
|
const totalThreads = sortedThreads.length;
|
||||||
const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE);
|
const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE);
|
||||||
|
|
@ -77,7 +75,7 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
|
||||||
<ThreadItem
|
<ThreadItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
onClick={handleThreadClick}
|
onClick={onThreadClick}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Archive, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Archive, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
|
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
|
||||||
import { useQueryState, parseAsString } from "nuqs";
|
import { useQueryState, parseAsString } from "nuqs";
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { ThreadItem } from "./thread-item";
|
import { ThreadItem } from "./thread-item";
|
||||||
|
|
||||||
const THREADS_PER_PAGE = 5;
|
const THREADS_PER_PAGE = 5;
|
||||||
|
|
||||||
export default function TaskList() {
|
export default function TaskList() {
|
||||||
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
|
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
|
||||||
const [_threadId, setThreadId] = useQueryState("threadId", parseAsString);
|
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const { threads, threadsLoading } = useThreads();
|
const { threads, threadsLoading, handleThreadClick } = useThreads();
|
||||||
|
|
||||||
const isDashboardMode = !taskId;
|
const isDashboardMode = !taskId;
|
||||||
|
|
||||||
const handleThreadClick = (thread: ThreadWithTasks) => {
|
const onThreadClick = useCallback(
|
||||||
setThreadId(thread.thread_id);
|
(thread: ThreadWithTasks) => {
|
||||||
setTaskId(null);
|
handleThreadClick(thread, threadId, setThreadId);
|
||||||
};
|
setTaskId(null);
|
||||||
|
},
|
||||||
|
[handleThreadClick, threadId, setThreadId, setTaskId],
|
||||||
|
);
|
||||||
|
|
||||||
if (!isDashboardMode) {
|
if (!isDashboardMode) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -51,7 +53,7 @@ export default function TaskList() {
|
||||||
<ThreadItem
|
<ThreadItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
onClick={handleThreadClick}
|
onClick={onThreadClick}
|
||||||
variant="dashboard"
|
variant="dashboard"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import { memo } from "react";
|
||||||
import { GitBranch, ArrowRight, ListTodo } from "lucide-react";
|
import { GitBranch, ArrowRight, ListTodo } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { ThreadWithTasks, useThreads } from "@/providers/Thread";
|
||||||
import { ThreadWithTasks } from "@/providers/Thread";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { StatusIndicator } from "@/components/status-indicator";
|
import { StatusIndicator } from "@/components/status-indicator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { GitHubSVG } from "./icons/github";
|
import { GitHubSVG } from "./icons/github";
|
||||||
import { useQueryState } from "nuqs";
|
import { useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
|
@ -16,66 +14,29 @@ interface ThreadItemProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadItem({
|
export const ThreadItem = memo(function ThreadItem({
|
||||||
thread,
|
thread,
|
||||||
onClick,
|
onClick,
|
||||||
variant = "dashboard",
|
variant = "dashboard",
|
||||||
className,
|
className,
|
||||||
}: ThreadItemProps) {
|
}: ThreadItemProps) {
|
||||||
const [threadId] = useQueryState("threadId");
|
const [threadId] = useQueryState("threadId");
|
||||||
|
const { recentlyUpdatedThreads } = useThreads();
|
||||||
const isSelected = thread.thread_id === threadId;
|
const isSelected = thread.thread_id === threadId;
|
||||||
const isSidebar = variant === "sidebar";
|
const isSidebar = variant === "sidebar";
|
||||||
|
const isRecentlyUpdated = recentlyUpdatedThreads.has(thread.thread_id);
|
||||||
|
|
||||||
const displayDate = new Date(thread.created_at).toLocaleDateString("en-US", {
|
const displayDate = new Date(thread.created_at).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
isSelected && "border-primary",
|
||||||
|
isRecentlyUpdated && "animate-pulse border-blue-200 bg-blue-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -139,4 +100,4 @@ export function ThreadItem({
|
||||||
</div>
|
</div>
|
||||||
</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,
|
ReactNode,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -52,8 +51,7 @@ const StreamSession = ({
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [threadId, setThreadId] = useQueryState("threadId");
|
const [threadId, setThreadId] = useQueryState("threadId");
|
||||||
const { refreshThreads, updateThreadFromStream } = useThreads();
|
const { refreshThreads, setThreads } = useThreads();
|
||||||
|
|
||||||
const streamValue = useTypedStream({
|
const streamValue = useTypedStream({
|
||||||
apiUrl,
|
apiUrl,
|
||||||
assistantId,
|
assistantId,
|
||||||
|
|
@ -69,46 +67,13 @@ const StreamSession = ({
|
||||||
},
|
},
|
||||||
onThreadId: (id) => {
|
onThreadId: (id) => {
|
||||||
setThreadId(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(() => {
|
sleep().then(() => {
|
||||||
refreshThreads().catch(console.error);
|
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 (
|
return (
|
||||||
<StreamContext.Provider value={streamValue}>
|
<StreamContext.Provider value={streamValue}>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -129,7 +94,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
const [isAuth, setIsAuth] = useState<boolean | null>(null);
|
const [isAuth, setIsAuth] = useState<boolean | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Initialize from localStorage to prevent modal flash
|
|
||||||
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
|
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
|
||||||
boolean | null
|
boolean | null
|
||||||
>(() => {
|
>(() => {
|
||||||
|
|
@ -152,7 +116,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuth) {
|
if (isAuth) {
|
||||||
// Check if we already have cached installation status
|
|
||||||
const cachedInstallationStatus = localStorage.getItem(
|
const cachedInstallationStatus = localStorage.getItem(
|
||||||
"github_app_installed",
|
"github_app_installed",
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +128,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
} else if (cachedInstallationStatus === "false") {
|
} else if (cachedInstallationStatus === "false") {
|
||||||
setHasGitHubAppInstalled(false);
|
setHasGitHubAppInstalled(false);
|
||||||
} else {
|
} else {
|
||||||
// Only check installation if we don't have cached status
|
|
||||||
checkGitHubAppInstallation();
|
checkGitHubAppInstallation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +151,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setHasGitHubAppInstalled(true);
|
setHasGitHubAppInstalled(true);
|
||||||
localStorage.setItem("github_app_installed", "true");
|
localStorage.setItem("github_app_installed", "true");
|
||||||
// If the app is installed, fetch a token
|
|
||||||
await fetchGitHubToken();
|
await fetchGitHubToken();
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
|
@ -197,7 +159,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
setHasGitHubAppInstalled(false);
|
setHasGitHubAppInstalled(false);
|
||||||
localStorage.setItem("github_app_installed", "false");
|
localStorage.setItem("github_app_installed", "false");
|
||||||
} else {
|
} else {
|
||||||
// If there's a different error, we'll assume the app is not installed
|
|
||||||
setHasGitHubAppInstalled(false);
|
setHasGitHubAppInstalled(false);
|
||||||
localStorage.setItem("github_app_installed", "false");
|
localStorage.setItem("github_app_installed", "false");
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +179,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
|
||||||
const handleInstallGitHubApp = () => {
|
const handleInstallGitHubApp = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Clear cached status so we check again after installation
|
|
||||||
localStorage.removeItem("github_app_installed");
|
localStorage.removeItem("github_app_installed");
|
||||||
window.location.href = "/api/github/installation";
|
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 => {
|
export const useStreamContext = (): StreamContextType => {
|
||||||
const context = useContext(StreamContext);
|
const context = useContext(StreamContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
SetStateAction,
|
SetStateAction,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createClient } from "./client";
|
import { createClient } from "./client";
|
||||||
import { getMessageContentString } from "@open-swe/shared/messages";
|
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 {
|
export interface ThreadWithTasks extends Thread {
|
||||||
threadTitle: string;
|
threadTitle: string;
|
||||||
|
|
@ -31,7 +33,13 @@ interface ThreadContextType {
|
||||||
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
refreshThreads: () => Promise<void>;
|
refreshThreads: () => Promise<void>;
|
||||||
getThread: (threadId: string) => Promise<ThreadWithTasks | null>;
|
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);
|
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
|
||||||
|
|
@ -49,39 +57,56 @@ function getThreadSearchMetadata(
|
||||||
const getTaskCounts = (
|
const getTaskCounts = (
|
||||||
tasks?: TaskPlan,
|
tasks?: TaskPlan,
|
||||||
proposedPlan?: string[],
|
proposedPlan?: string[],
|
||||||
|
existingCounts?: { totalTasksCount: number; completedTasksCount: number },
|
||||||
): { 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 {
|
return {
|
||||||
totalTasksCount: proposedPlan.length,
|
totalTasksCount: proposedPlan.length,
|
||||||
completedTasksCount: 0,
|
completedTasksCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tasks) {
|
if (!tasks || !tasks.tasks || tasks.tasks.length === 0) {
|
||||||
// No tasks passed, return 0s
|
return defaultCounts;
|
||||||
return { totalTasksCount: 0, completedTasksCount: 0 };
|
}
|
||||||
|
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(
|
const activeRevisionIndex = activeTask.activeRevisionIndex;
|
||||||
(t) => t.taskIndex === tasks.activeTaskIndex,
|
const activeRevision = activeTask.planRevisions.find(
|
||||||
|
(revision) => revision.revisionIndex === activeRevisionIndex,
|
||||||
);
|
);
|
||||||
if (!activeTaskList) {
|
|
||||||
// Something is wrong here. Return 0
|
if (
|
||||||
return { totalTasksCount: 0, completedTasksCount: 0 };
|
!activeRevision ||
|
||||||
|
!activeRevision.plans ||
|
||||||
|
activeRevision.plans.length === 0
|
||||||
|
) {
|
||||||
|
return defaultCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTaskPlans = activeTaskList.planRevisions.find(
|
const plans = activeRevision.plans;
|
||||||
(p) => p.revisionIndex === activeTaskList.activeRevisionIndex,
|
|
||||||
);
|
const completedTasksCount = plans.filter((p) => p.completed)?.length || 0;
|
||||||
if (!activeTaskPlans) {
|
|
||||||
// Something is wrong here. Return 0
|
|
||||||
return { totalTasksCount: 0, completedTasksCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTasksCount: activeTaskPlans.plans.length,
|
totalTasksCount: plans.length,
|
||||||
completedTasksCount: activeTaskPlans.plans.filter((p) => p.completed)
|
completedTasksCount,
|
||||||
.length,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,63 +117,10 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const [threads, setThreads] = useState<ThreadWithTasks[]>([]);
|
const [threads, setThreads] = useState<ThreadWithTasks[]>([]);
|
||||||
const [threadsLoading, setThreadsLoading] = useState(false);
|
const [threadsLoading, setThreadsLoading] = useState(false);
|
||||||
const [busyThreadIds, setBusyThreadIds] = useState<string[]>([]);
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [recentlyUpdatedThreads, setRecentlyUpdatedThreads] = useState<
|
||||||
// Real-time thread updater for all properties (replaces polling)
|
Set<string>
|
||||||
const updateThreadFromStream = useCallback(
|
>(new Set());
|
||||||
(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 getThread = useCallback(
|
const getThread = useCallback(
|
||||||
async (threadId: string): Promise<ThreadWithTasks | null> => {
|
async (threadId: string): Promise<ThreadWithTasks | null> => {
|
||||||
|
|
@ -157,7 +129,17 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thread = await client.threads.get(threadId);
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch thread:", threadId, error);
|
console.error("Failed to fetch thread:", threadId, error);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -166,13 +148,20 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
[apiUrl, assistantId],
|
[apiUrl, assistantId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enhanceThreadWithTasks = (thread: Thread): ThreadWithTasks => {
|
const enhanceThreadWithTasks = (
|
||||||
const threadValues = thread.values as any;
|
thread: Thread,
|
||||||
const plan: TaskPlan | undefined = threadValues?.plan;
|
stateData?: { values: GraphState } | null,
|
||||||
const proposedPlan: string[] = threadValues?.proposedPlan || [];
|
): ThreadWithTasks => {
|
||||||
|
const stateValues = stateData?.values;
|
||||||
|
const threadValues = thread.values as GraphState;
|
||||||
|
|
||||||
const targetRepository = threadValues?.targetRepository;
|
const plan: TaskPlan | undefined = stateValues?.plan || threadValues?.plan;
|
||||||
const messages = (threadValues as any)?.messages;
|
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 firstMessageContent = messages?.[0]?.content;
|
||||||
const threadTitle = firstMessageContent
|
const threadTitle = firstMessageContent
|
||||||
? getMessageContentString(firstMessageContent)
|
? getMessageContentString(firstMessageContent)
|
||||||
|
|
@ -186,10 +175,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
return {
|
return {
|
||||||
...thread,
|
...thread,
|
||||||
threadTitle,
|
threadTitle,
|
||||||
repository:
|
repository: targetRepository?.repo || "Unknown Repository",
|
||||||
targetRepository?.repo ||
|
|
||||||
targetRepository?.name ||
|
|
||||||
"Unknown Repository",
|
|
||||||
branch: targetRepository?.branch || "main",
|
branch: targetRepository?.branch || "main",
|
||||||
completedTasksCount,
|
completedTasksCount,
|
||||||
totalTasksCount,
|
totalTasksCount,
|
||||||
|
|
@ -205,7 +191,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
const client = createClient(apiUrl);
|
const client = createClient(apiUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simple thread search - try both metadata approaches
|
|
||||||
const searchParams = {
|
const searchParams = {
|
||||||
limit: 100,
|
limit: 100,
|
||||||
metadata: getThreadSearchMetadata(assistantId),
|
metadata: getThreadSearchMetadata(assistantId),
|
||||||
|
|
@ -213,7 +198,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
let threadsResponse = await client.threads.search(searchParams);
|
let threadsResponse = await client.threads.search(searchParams);
|
||||||
|
|
||||||
// If no threads found, try alternative metadata
|
|
||||||
if (threadsResponse.length === 0) {
|
if (threadsResponse.length === 0) {
|
||||||
const altMetadata = assistantId.includes("-")
|
const altMetadata = assistantId.includes("-")
|
||||||
? { assistant_id: assistantId }
|
? { assistant_id: assistantId }
|
||||||
|
|
@ -224,18 +208,24 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance threads with task data
|
|
||||||
const enhancedThreads: ThreadWithTasks[] = [];
|
const enhancedThreads: ThreadWithTasks[] = [];
|
||||||
for (const thread of threadsResponse) {
|
for (const thread of threadsResponse) {
|
||||||
try {
|
try {
|
||||||
const fullThread = await client.threads.get(thread.thread_id);
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to enhance thread ${thread.thread_id}:`, error);
|
console.error(`Failed to enhance thread ${thread.thread_id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by creation date (newest first)
|
|
||||||
enhancedThreads.sort(
|
enhancedThreads.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
|
@ -249,13 +239,49 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [apiUrl, assistantId]);
|
}, [apiUrl, assistantId]);
|
||||||
|
|
||||||
// Removed polling - now using real-time stream updates via updateThreadFromStream
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshThreads();
|
refreshThreads();
|
||||||
}, [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 = {
|
const value = {
|
||||||
threads,
|
threads,
|
||||||
setThreads,
|
setThreads,
|
||||||
|
|
@ -263,7 +289,9 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
setThreadsLoading,
|
setThreadsLoading,
|
||||||
refreshThreads,
|
refreshThreads,
|
||||||
getThread,
|
getThread,
|
||||||
updateThreadFromStream,
|
isPending,
|
||||||
|
recentlyUpdatedThreads,
|
||||||
|
handleThreadClick,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue