mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 15:52:11 +00:00
Feat: Thread Status SWR Polling (#370)
* init swr polling branch * useThreads types improvement * minor build fix * major refactor and simplification of polling system * remove temp guide file * refactor, remove errorBoundary, simplify schema * refactor: unify client<>server status logic * fix threads/page conflicts + functionality * Update apps/web/src/components/v2/thread-card.tsx Co-authored-by: Brace Sproul <braceasproul@gmail.com> * CR in progress * CR fix: add cn to thread-card.tsx Co-authored-by: Brace Sproul <braceasproul@gmail.com> * CR * CR * CR: no threads state, Batch ThreadsStatus for /chat/threads actual logic * drop req type validation in lg proxy * optimize thread polling, reduce unnecessary api calls, match implementation spec * reduce unnecessary api calls, update all threads page * add thread loading state to threadCard * utilize threads/search for manager thread info * improve polling api req efficiency * rm comments * post merge format * fix import * improve programmer cache logic * reduce run requests * remove timeout run requests * remove run check for completed tasks * fix: update status dot to support dark mode styles too * fix session cache and proper typing * fix: more typing * code review fixes * CR * code review * fix build --------- Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
parent
4053f18df5
commit
d51af805e2
22 changed files with 1122 additions and 491 deletions
|
|
@ -2,15 +2,16 @@
|
||||||
|
|
||||||
import { ThreadView } from "@/components/v2/thread-view";
|
import { ThreadView } from "@/components/v2/thread-view";
|
||||||
import { ThreadViewLoading } from "@/components/v2/thread-view-loading";
|
import { ThreadViewLoading } from "@/components/v2/thread-view-loading";
|
||||||
import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types";
|
import { ThreadMetadata } from "@/components/v2/types";
|
||||||
import { useThreads } from "@/hooks/useThreads";
|
import { useThreadMetadata } from "@/hooks/useThreadMetadata";
|
||||||
|
import { useThreadsSWR } from "@/hooks/useThreadsSWR";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { use } from "react";
|
import { use, useMemo } from "react";
|
||||||
|
import { threadsToMetadata } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface ThreadPageProps {
|
interface ThreadPageProps {
|
||||||
thread_id: string;
|
thread_id: string;
|
||||||
|
|
@ -31,10 +32,28 @@ export default function ThreadPage({
|
||||||
fetchStateHistory: false,
|
fetchStateHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { threads, threadsLoading } = useThreads<GraphState>(MANAGER_GRAPH_ID);
|
const { threads, isLoading: threadsLoading } = useThreadsSWR({
|
||||||
|
assistantId: MANAGER_GRAPH_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadsMetadata = useMemo(() => threadsToMetadata(threads), [threads]);
|
||||||
|
|
||||||
// Find the thread by ID
|
// Find the thread by ID
|
||||||
const thread = threads.find((t) => t.thread_id === thread_id);
|
const thread = threads.find((t) => t.thread_id === thread_id);
|
||||||
|
|
||||||
|
// We need a thread object for the hook, so use a dummy if not found
|
||||||
|
const dummyThread = thread || {
|
||||||
|
thread_id: thread_id,
|
||||||
|
values: {},
|
||||||
|
status: "idle" as const,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { metadata: currentDisplayThread } = useThreadMetadata(
|
||||||
|
dummyThread as any,
|
||||||
|
);
|
||||||
|
|
||||||
const handleBackToHome = () => {
|
const handleBackToHome = () => {
|
||||||
router.push("/chat");
|
router.push("/chat");
|
||||||
};
|
};
|
||||||
|
|
@ -43,16 +62,12 @@ export default function ThreadPage({
|
||||||
return <ThreadViewLoading onBackToHome={handleBackToHome} />;
|
return <ThreadViewLoading onBackToHome={handleBackToHome} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert all threads to display format
|
|
||||||
const displayThreads: ThreadDisplayInfo[] = threads.map(threadToDisplayInfo);
|
|
||||||
const currentDisplayThread = threadToDisplayInfo(thread);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background fixed inset-0">
|
<div className="bg-background fixed inset-0">
|
||||||
<ThreadView
|
<ThreadView
|
||||||
stream={stream}
|
stream={stream}
|
||||||
displayThread={currentDisplayThread}
|
displayThread={currentDisplayThread}
|
||||||
allDisplayThreads={displayThreads}
|
allDisplayThreads={threadsMetadata}
|
||||||
onBackToHome={handleBackToHome}
|
onBackToHome={handleBackToHome}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DefaultView } from "@/components/v2/default-view";
|
import { DefaultView } from "@/components/v2/default-view";
|
||||||
import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types";
|
|
||||||
import { useThreadsSWR } from "@/hooks/useThreadsSWR";
|
import { useThreadsSWR } from "@/hooks/useThreadsSWR";
|
||||||
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { threads, isLoading: threadsLoading } = useThreadsSWR<GraphState>({
|
const { threads, isLoading: threadsLoading } = useThreadsSWR({
|
||||||
assistantId: MANAGER_GRAPH_ID,
|
assistantId: MANAGER_GRAPH_ID,
|
||||||
refreshInterval: 15000, // Poll every 15 seconds
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!threads) {
|
if (!threads) {
|
||||||
return <div>No threads</div>;
|
return <div>No threads</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert Thread objects to ThreadDisplayInfo for UI
|
|
||||||
const displayThreads: ThreadDisplayInfo[] = threads.map(threadToDisplayInfo);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background h-screen">
|
<div className="bg-background h-screen">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<GitHubAppProvider>
|
<GitHubAppProvider>
|
||||||
<DefaultView
|
<DefaultView
|
||||||
threads={displayThreads}
|
threads={threads}
|
||||||
threadsLoading={threadsLoading}
|
threadsLoading={threadsLoading}
|
||||||
/>
|
/>
|
||||||
</GitHubAppProvider>
|
</GitHubAppProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,83 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import { useState, Suspense, useMemo } from "react";
|
||||||
import { useState, Suspense } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ArrowLeft, Search, Filter } from "lucide-react";
|
import { ArrowLeft, Search, Filter } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ThreadDisplayInfo, threadToDisplayInfo } from "@/components/v2/types";
|
import { ThreadMetadata } from "@/components/v2/types";
|
||||||
import { useThreadsSWR } from "@/hooks/useThreadsSWR";
|
import { useThreadsSWR } from "@/hooks/useThreadsSWR";
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
|
||||||
import { ThreadCard, ThreadCardLoading } from "@/components/v2/thread-card";
|
import { ThreadCard, ThreadCardLoading } from "@/components/v2/thread-card";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { InstallationSelector } from "@/components/github/installation-selector";
|
import { InstallationSelector } from "@/components/github/installation-selector";
|
||||||
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
import { GitHubAppProvider } from "@/providers/GitHubApp";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
|
import { useThreadsStatus } from "@/hooks/useThreadsStatus";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { threadsToMetadata } from "@/lib/thread-utils";
|
||||||
|
|
||||||
type FilterStatus = "all" | "running" | "completed" | "failed" | "pending";
|
type FilterStatus =
|
||||||
|
| "all"
|
||||||
|
| "running"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "pending"
|
||||||
|
| "idle"
|
||||||
|
| "paused"
|
||||||
|
| "error";
|
||||||
|
|
||||||
function AllThreadsPageContent() {
|
function AllThreadsPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { threads, isLoading: threadsLoading } = useThreadsSWR<GraphState>({
|
const { threads, isLoading: threadsLoading } = useThreadsSWR({
|
||||||
assistantId: MANAGER_GRAPH_ID,
|
assistantId: MANAGER_GRAPH_ID,
|
||||||
refreshInterval: 15000, // Poll every 15 seconds
|
|
||||||
});
|
});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");
|
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");
|
||||||
|
|
||||||
// Convert Thread objects to ThreadDisplayInfo for UI
|
const threadsMetadata = useMemo(() => threadsToMetadata(threads), [threads]);
|
||||||
const displayThreads: ThreadDisplayInfo[] = threads.map(threadToDisplayInfo);
|
|
||||||
|
|
||||||
// Filter and search threads
|
const threadIds = threadsMetadata.map((thread) => thread.id);
|
||||||
const filteredThreads = displayThreads.filter((thread) => {
|
|
||||||
|
const {
|
||||||
|
statusMap,
|
||||||
|
statusCounts,
|
||||||
|
isLoading: statusLoading,
|
||||||
|
} = useThreadsStatus(threadIds, threads);
|
||||||
|
|
||||||
|
const filteredThreads = threadsMetadata.filter((thread: ThreadMetadata) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
thread.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
thread.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
thread.repository.toLowerCase().includes(searchQuery.toLowerCase());
|
thread.repository.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
statusFilter === "all" || thread.status === statusFilter;
|
statusFilter === "all" || statusMap[thread.id] === statusFilter;
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group threads by status
|
|
||||||
const groupedThreads = {
|
const groupedThreads = {
|
||||||
running: filteredThreads.filter((t) => t.status === "running"),
|
running: filteredThreads.filter(
|
||||||
completed: filteredThreads.filter((t) => t.status === "completed"),
|
(thread: ThreadMetadata) => statusMap[thread.id] === "running",
|
||||||
failed: filteredThreads.filter((t) => t.status === "failed"),
|
),
|
||||||
pending: filteredThreads.filter((t) => t.status === "pending"),
|
completed: filteredThreads.filter(
|
||||||
};
|
(thread: ThreadMetadata) => statusMap[thread.id] === "completed",
|
||||||
|
),
|
||||||
const statusCounts = {
|
failed: filteredThreads.filter(
|
||||||
all: displayThreads.length,
|
(thread: ThreadMetadata) => statusMap[thread.id] === "failed",
|
||||||
running: displayThreads.filter((t) => t.status === "running").length,
|
),
|
||||||
completed: displayThreads.filter((t) => t.status === "completed").length,
|
pending: filteredThreads.filter(
|
||||||
failed: displayThreads.filter((t) => t.status === "failed").length,
|
(thread: ThreadMetadata) => statusMap[thread.id] === "pending",
|
||||||
pending: displayThreads.filter((t) => t.status === "pending").length,
|
),
|
||||||
|
idle: filteredThreads.filter(
|
||||||
|
(thread: ThreadMetadata) => statusMap[thread.id] === "idle",
|
||||||
|
),
|
||||||
|
paused: filteredThreads.filter(
|
||||||
|
(thread: ThreadMetadata) => statusMap[thread.id] === "paused",
|
||||||
|
),
|
||||||
|
error: filteredThreads.filter(
|
||||||
|
(thread: ThreadMetadata) => statusMap[thread.id] === "error",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -115,6 +138,9 @@ function AllThreadsPageContent() {
|
||||||
"completed",
|
"completed",
|
||||||
"failed",
|
"failed",
|
||||||
"pending",
|
"pending",
|
||||||
|
"idle",
|
||||||
|
"paused",
|
||||||
|
"error",
|
||||||
] as FilterStatus[]
|
] as FilterStatus[]
|
||||||
).map((status) => (
|
).map((status) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -148,7 +174,6 @@ function AllThreadsPageContent() {
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="mx-auto max-w-6xl p-4">
|
<div className="mx-auto max-w-6xl p-4">
|
||||||
{statusFilter === "all" ? (
|
{statusFilter === "all" ? (
|
||||||
// Show grouped view when "all" is selected
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{Object.entries(groupedThreads).map(([status, threads]) => {
|
{Object.entries(groupedThreads).map(([status, threads]) => {
|
||||||
if (threads.length === 0) return null;
|
if (threads.length === 0) return null;
|
||||||
|
|
@ -170,6 +195,8 @@ function AllThreadsPageContent() {
|
||||||
<ThreadCard
|
<ThreadCard
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
status={statusMap[thread.id]}
|
||||||
|
statusLoading={statusLoading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,44 +205,50 @@ function AllThreadsPageContent() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Show flat list when specific status is selected
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredThreads.map((thread) => (
|
{filteredThreads.map((thread) => (
|
||||||
<ThreadCard
|
<ThreadCard
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
status={statusMap[thread.id]}
|
||||||
|
statusLoading={statusLoading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredThreads.length === 0 && !threadsLoading && (
|
{filteredThreads.length === 0 &&
|
||||||
<div className="py-12 text-center">
|
!threadsLoading &&
|
||||||
<div className="text-muted-foreground mb-2">
|
!statusLoading && (
|
||||||
No threads found
|
<div className="py-12 text-center">
|
||||||
|
<div className="text-muted-foreground mb-2">
|
||||||
|
No threads found
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground/70 text-xs">
|
||||||
|
{!threads || threads.length === 0
|
||||||
|
? "No threads have been created yet"
|
||||||
|
: searchQuery
|
||||||
|
? "Try adjusting your search query"
|
||||||
|
: "No threads match the selected filter"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground/70 text-xs">
|
)}
|
||||||
{searchQuery
|
|
||||||
? "Try adjusting your search query"
|
|
||||||
: "No threads match the selected filter"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{threadsLoading && threads.length === 0 && (
|
{(threadsLoading || statusLoading) &&
|
||||||
<div>
|
(!threads || threads.length === 0) && (
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div>
|
||||||
<h2 className="text-foreground text-base font-semibold capitalize">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
Loading threads...
|
<h2 className="text-foreground text-base font-semibold capitalize">
|
||||||
</h2>
|
Loading threads...
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 9 }).map((_, index) => (
|
||||||
|
<ThreadCardLoading key={`all-threads-loading-${index}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
)}
|
||||||
{Array.from({ length: 9 }).map((_, index) => (
|
|
||||||
<ThreadCardLoading key={`all-threads-loading-${index}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { App } from "@octokit/app";
|
||||||
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
|
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
|
||||||
import { encryptSecret } from "@open-swe/shared/crypto";
|
import { encryptSecret } from "@open-swe/shared/crypto";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { validate } from "uuid";
|
|
||||||
|
|
||||||
export function getGitHubAccessTokenOrThrow(
|
export function getGitHubAccessTokenOrThrow(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|
@ -64,58 +63,6 @@ async function getInstallationName(installationId: string) {
|
||||||
return installationName ?? "";
|
return installationName ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNewRunRequest(reqUrlStr: string, reqMethod: string) {
|
|
||||||
try {
|
|
||||||
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
|
|
||||||
const isCreateNewRunReq =
|
|
||||||
reqPathnameParts?.[1] === "api" &&
|
|
||||||
reqPathnameParts?.[2] === "threads" &&
|
|
||||||
validate(reqPathnameParts?.[3]) &&
|
|
||||||
reqPathnameParts?.[4] === "runs" &&
|
|
||||||
reqMethod.toLowerCase() === "post";
|
|
||||||
const isStreamRunReq =
|
|
||||||
reqPathnameParts?.[1] === "api" &&
|
|
||||||
reqPathnameParts?.[2] === "threads" &&
|
|
||||||
validate(reqPathnameParts?.[3]) &&
|
|
||||||
reqPathnameParts?.[4] === "runs" &&
|
|
||||||
validate(reqPathnameParts?.[5]) &&
|
|
||||||
reqPathnameParts?.[6]?.startsWith("stream") &&
|
|
||||||
reqMethod.toLowerCase() === "get";
|
|
||||||
return isCreateNewRunReq || isStreamRunReq;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGetStateRequest(reqUrlStr: string, reqMethod: string) {
|
|
||||||
try {
|
|
||||||
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
|
|
||||||
const isGetStateReq =
|
|
||||||
reqPathnameParts?.[1] === "api" &&
|
|
||||||
reqPathnameParts?.[2] === "threads" &&
|
|
||||||
validate(reqPathnameParts?.[3]) &&
|
|
||||||
reqPathnameParts?.[4] === "state" &&
|
|
||||||
reqMethod.toLowerCase() === "get";
|
|
||||||
return isGetStateReq;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSearchThreadsRequest(reqUrlStr: string, reqMethod: string) {
|
|
||||||
try {
|
|
||||||
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
|
|
||||||
const isGetStateReq =
|
|
||||||
reqPathnameParts?.[1] === "api" &&
|
|
||||||
reqPathnameParts?.[2] === "threads" &&
|
|
||||||
reqPathnameParts?.[3] === "search" &&
|
|
||||||
reqMethod.toLowerCase() === "post";
|
|
||||||
return isGetStateReq;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInstallationNameFromReq(
|
export async function getInstallationNameFromReq(
|
||||||
req: Request,
|
req: Request,
|
||||||
installationId: string,
|
installationId: string,
|
||||||
|
|
@ -131,14 +78,7 @@ export async function getInstallationNameFromReq(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
return await getInstallationName(installationId);
|
||||||
isNewRunRequest(req.url, req.method) ||
|
|
||||||
isGetStateRequest(req.url, req.method) ||
|
|
||||||
isSearchThreadsRequest(req.url, req.method)
|
|
||||||
) {
|
|
||||||
return await getInstallationName(installationId);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { FilePlus2, Archive, Zap } from "lucide-react";
|
import { FilePlus2, Archive, Zap } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ThreadDisplayInfo } from "./types";
|
import { ThreadMetadata } from "./types";
|
||||||
import { TerminalInput } from "./terminal-input";
|
import { TerminalInput } from "./terminal-input";
|
||||||
import { useFileUpload } from "@/hooks/useFileUpload";
|
import { useFileUpload } from "@/hooks/useFileUpload";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -19,12 +19,17 @@ import { ThemeToggle } from "../theme-toggle";
|
||||||
import { ThreadCard, ThreadCardLoading } from "./thread-card";
|
import { ThreadCard, ThreadCardLoading } from "./thread-card";
|
||||||
import { GitHubInstallationBanner } from "../github/installation-banner";
|
import { GitHubInstallationBanner } from "../github/installation-banner";
|
||||||
import { QuickActions } from "./quick-actions";
|
import { QuickActions } from "./quick-actions";
|
||||||
import { useState } from "react";
|
|
||||||
import { DraftsSection } from "./drafts-section";
|
import { DraftsSection } from "./drafts-section";
|
||||||
import { GitHubLogoutButton } from "../github/github-oauth-button";
|
import { GitHubLogoutButton } from "../github/github-oauth-button";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
||||||
import { InstallationSelector } from "../github/installation-selector";
|
import { InstallationSelector } from "../github/installation-selector";
|
||||||
|
|
||||||
|
import { useThreadsStatus } from "@/hooks/useThreadsStatus";
|
||||||
|
import { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { threadsToMetadata } from "@/lib/thread-utils";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import NextLink from "next/link";
|
import NextLink from "next/link";
|
||||||
|
|
||||||
|
|
@ -44,7 +49,7 @@ function OpenSettingsButton() {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DefaultViewProps {
|
interface DefaultViewProps {
|
||||||
threads: ThreadDisplayInfo[];
|
threads: Thread<ManagerGraphState>[];
|
||||||
threadsLoading: boolean;
|
threadsLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +70,15 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
} = useFileUpload();
|
} = useFileUpload();
|
||||||
const [autoAccept, setAutoAccept] = useState(false);
|
const [autoAccept, setAutoAccept] = useState(false);
|
||||||
|
|
||||||
|
const threadsMetadata = useMemo(() => threadsToMetadata(threads), [threads]);
|
||||||
|
const displayThreads = threadsMetadata.slice(0, 4);
|
||||||
|
const displayThreadIds = displayThreads.map((thread) => thread.id);
|
||||||
|
|
||||||
|
const { statusMap, isLoading: statusLoading } = useThreadsStatus(
|
||||||
|
displayThreadIds,
|
||||||
|
threads,
|
||||||
|
);
|
||||||
|
|
||||||
const handleLoadDraft = (content: string) => {
|
const handleLoadDraft = (content: string) => {
|
||||||
setDraftToLoad(content);
|
setDraftToLoad(content);
|
||||||
};
|
};
|
||||||
|
|
@ -198,10 +212,12 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
<ThreadCardLoading />
|
<ThreadCardLoading />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{threads.slice(0, 4).map((thread) => (
|
{displayThreads.map((thread) => (
|
||||||
<ThreadCard
|
<ThreadCard
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
|
status={statusMap[thread.id]}
|
||||||
|
statusLoading={statusLoading}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,42 @@ import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Pause,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { ThreadDisplayInfo } from "./types";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { ThreadMetadata } from "./types";
|
||||||
|
import { ThreadUIStatus } from "@/lib/schemas/thread-status";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ThreadCard({ thread }: { thread: ThreadDisplayInfo }) {
|
interface ThreadCardProps {
|
||||||
|
thread: ThreadMetadata;
|
||||||
|
status?: ThreadUIStatus;
|
||||||
|
statusLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadCard({ thread, status, statusLoading }: ThreadCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const getStatusColor = (status: ThreadDisplayInfo["status"]) => {
|
const isStatusLoading = statusLoading && !status;
|
||||||
|
const displayStatus = status || ("idle" as ThreadUIStatus);
|
||||||
|
|
||||||
|
const getStatusColor = (status: ThreadUIStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
return "dark:bg-blue-950 bg-blue-100 dark:text-blue-400 text-blue-700";
|
return "dark:bg-blue-950 bg-blue-100 dark:text-blue-400 text-blue-700";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "dark:bg-green-950 bg-green-100 dark:text-green-400 text-green-700";
|
return "dark:bg-green-950 bg-green-100 dark:text-green-400 text-green-700";
|
||||||
|
case "error":
|
||||||
|
return "dark:bg-red-950 bg-red-100 dark:text-red-400 text-red-700";
|
||||||
|
case "paused":
|
||||||
|
return "dark:bg-yellow-950 bg-yellow-100 dark:text-yellow-400 text-yellow-700";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "dark:bg-red-950 bg-red-100 dark:text-red-400 text-red-700";
|
return "dark:bg-red-950 bg-red-100 dark:text-red-400 text-red-700";
|
||||||
case "pending":
|
case "pending":
|
||||||
|
|
@ -32,12 +49,18 @@ export function ThreadCard({ thread }: { thread: ThreadDisplayInfo }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: ThreadDisplayInfo["status"]) => {
|
const getStatusIcon = (status: ThreadUIStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
return <Loader2 className="h-4 w-4 animate-spin" />;
|
return <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
case "completed":
|
case "completed":
|
||||||
return <CheckCircle className="h-4 w-4" />;
|
return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "error":
|
||||||
|
return <AlertCircle className="h-4 w-4" />;
|
||||||
|
case "idle":
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
case "paused":
|
||||||
|
return <Pause className="h-4 w-4" />;
|
||||||
case "failed":
|
case "failed":
|
||||||
return <XCircle className="h-4 w-4" />;
|
return <XCircle className="h-4 w-4" />;
|
||||||
default:
|
default:
|
||||||
|
|
@ -83,11 +106,22 @@ export function ThreadCard({ thread }: { thread: ThreadDisplayInfo }) {
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(getStatusColor(thread.status), "text-xs")}
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
isStatusLoading
|
||||||
|
? "bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||||
|
: getStatusColor(displayStatus),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{getStatusIcon(thread.status)}
|
{isStatusLoading ? (
|
||||||
<span className="capitalize">{thread.status}</span>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
getStatusIcon(displayStatus)
|
||||||
|
)}
|
||||||
|
<span className="capitalize">
|
||||||
|
{isStatusLoading ? "Loading..." : displayStatus}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,12 @@ import {
|
||||||
Bug,
|
Bug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ThreadDisplayInfo } from "./types";
|
import { ThreadMetadata } from "./types";
|
||||||
import { ThreadCard } from "./thread-card";
|
import { ThreadCard } from "./thread-card";
|
||||||
|
|
||||||
interface ThreadSwitcherProps {
|
interface ThreadSwitcherProps {
|
||||||
currentThread: ThreadDisplayInfo;
|
currentThread: ThreadMetadata;
|
||||||
allThreads: ThreadDisplayInfo[];
|
allThreads: ThreadMetadata[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadSwitcher({
|
export function ThreadSwitcher({
|
||||||
|
|
@ -37,9 +37,6 @@ export function ThreadSwitcher({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const otherThreads = allThreads.filter((t) => t.id !== currentThread.id);
|
const otherThreads = allThreads.filter((t) => t.id !== currentThread.id);
|
||||||
const runningCount = otherThreads.filter(
|
|
||||||
(t) => t.status === "running",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet
|
<Sheet
|
||||||
|
|
@ -54,14 +51,6 @@ export function ThreadSwitcher({
|
||||||
>
|
>
|
||||||
<Layers3 className="h-3 w-3" />
|
<Layers3 className="h-3 w-3" />
|
||||||
<span className="hidden sm:inline">Switch Thread</span>
|
<span className="hidden sm:inline">Switch Thread</span>
|
||||||
{runningCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="h-4 bg-blue-100 px-1 text-xs text-blue-700 dark:bg-blue-950 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
{runningCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ArrowLeft, GitBranch, Terminal, Clock } from "lucide-react";
|
import { ArrowLeft, GitBranch, Terminal, Clock } from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ThreadSwitcher } from "./thread-switcher";
|
import { ThreadSwitcher } from "./thread-switcher";
|
||||||
import { ThreadDisplayInfo } from "./types";
|
import { ThreadMetadata } from "./types";
|
||||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||||
|
|
@ -20,6 +20,9 @@ import {
|
||||||
PROGRAMMER_GRAPH_ID,
|
PROGRAMMER_GRAPH_ID,
|
||||||
PLANNER_GRAPH_ID,
|
PLANNER_GRAPH_ID,
|
||||||
} from "@open-swe/shared/constants";
|
} from "@open-swe/shared/constants";
|
||||||
|
import { useThreadStatus } from "@/hooks/useThreadStatus";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { StickToBottom } from "use-stick-to-bottom";
|
import { StickToBottom } from "use-stick-to-bottom";
|
||||||
import {
|
import {
|
||||||
StickyToBottomContent,
|
StickyToBottomContent,
|
||||||
|
|
@ -27,12 +30,11 @@ import {
|
||||||
} from "../../utils/scroll-utils";
|
} from "../../utils/scroll-utils";
|
||||||
import { ManagerChat } from "./manager-chat";
|
import { ManagerChat } from "./manager-chat";
|
||||||
import { CancelStreamButton } from "./cancel-stream-button";
|
import { CancelStreamButton } from "./cancel-stream-button";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ThreadViewProps {
|
interface ThreadViewProps {
|
||||||
stream: ReturnType<typeof useStream<ManagerGraphState>>;
|
stream: ReturnType<typeof useStream<ManagerGraphState>>;
|
||||||
displayThread: ThreadDisplayInfo;
|
displayThread: ThreadMetadata;
|
||||||
allDisplayThreads: ThreadDisplayInfo[];
|
allDisplayThreads: ThreadMetadata[];
|
||||||
onBackToHome: () => void;
|
onBackToHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +53,23 @@ export function ThreadView({
|
||||||
const [programmerSession, setProgrammerSession] =
|
const [programmerSession, setProgrammerSession] =
|
||||||
useState<ManagerGraphState["programmerSession"]>();
|
useState<ManagerGraphState["programmerSession"]>();
|
||||||
|
|
||||||
|
const { status: realTimeStatus } = useThreadStatus(displayThread.id);
|
||||||
|
|
||||||
|
const getStatusDotColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "bg-blue-500 dark:bg-blue-400";
|
||||||
|
case "completed":
|
||||||
|
return "bg-green-500 dark:bg-green-400";
|
||||||
|
case "paused":
|
||||||
|
return "bg-yellow-500 dark:bg-yellow-400";
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500 dark:bg-red-400";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500 dark:bg-gray-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const plannerCancelRef = useRef<(() => void) | null>(null);
|
const plannerCancelRef = useRef<(() => void) | null>(null);
|
||||||
const programmerCancelRef = useRef<(() => void) | null>(null);
|
const programmerCancelRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
|
@ -102,11 +121,7 @@ export function ThreadView({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-2 flex-shrink-0 rounded-full",
|
"size-2 flex-shrink-0 rounded-full",
|
||||||
displayThread.status === "running"
|
getStatusDotColor(realTimeStatus),
|
||||||
? "bg-blue-500"
|
|
||||||
: displayThread.status === "completed"
|
|
||||||
? "bg-green-500"
|
|
||||||
: "bg-red-500",
|
|
||||||
)}
|
)}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-muted-foreground max-w-[500px] truncate font-mono text-sm">
|
<span className="text-muted-foreground max-w-[500px] truncate font-mono text-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { getThreadTitle } from "@/lib/thread";
|
import { ThreadUIStatus } from "@/lib/schemas/thread-status";
|
||||||
import { Thread } from "@langchain/langgraph-sdk";
|
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||||
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
|
|
||||||
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
|
||||||
|
|
||||||
export interface ThreadDisplayInfo {
|
export interface ThreadMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: "running" | "completed" | "failed" | "pending";
|
|
||||||
lastActivity: string;
|
lastActivity: string;
|
||||||
taskCount: number;
|
taskCount: number;
|
||||||
repository: string;
|
repository: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
|
taskPlan?: TaskPlan;
|
||||||
|
status: ThreadUIStatus;
|
||||||
githubIssue?: {
|
githubIssue?: {
|
||||||
number: number;
|
number: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -21,71 +20,3 @@ export interface ThreadDisplayInfo {
|
||||||
status: "draft" | "open" | "merged" | "closed";
|
status: "draft" | "open" | "merged" | "closed";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility functions to convert between Thread and ThreadDisplayInfo
|
|
||||||
export function threadToDisplayInfo(
|
|
||||||
thread: Thread<ManagerGraphState>,
|
|
||||||
): ThreadDisplayInfo {
|
|
||||||
const values = thread.values;
|
|
||||||
const activePlanItems = values?.taskPlan
|
|
||||||
? getActivePlanItems(values.taskPlan)
|
|
||||||
: [];
|
|
||||||
const completedTasksLen = activePlanItems.filter((t) => t.completed).length;
|
|
||||||
|
|
||||||
// Determine UI status from thread status and task completion
|
|
||||||
let uiStatus: ThreadDisplayInfo["status"];
|
|
||||||
switch (thread.status) {
|
|
||||||
case "busy":
|
|
||||||
uiStatus = "running";
|
|
||||||
break;
|
|
||||||
case "idle":
|
|
||||||
uiStatus =
|
|
||||||
completedTasksLen === activePlanItems.length ? "completed" : "pending";
|
|
||||||
break;
|
|
||||||
case "error":
|
|
||||||
uiStatus = "failed";
|
|
||||||
break;
|
|
||||||
case "interrupted":
|
|
||||||
uiStatus = "pending";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
uiStatus = "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time since last update
|
|
||||||
const lastUpdate = new Date(thread.updated_at);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - lastUpdate.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
let lastActivity: string;
|
|
||||||
if (diffMins < 1) {
|
|
||||||
lastActivity = "just now";
|
|
||||||
} else if (diffMins < 60) {
|
|
||||||
lastActivity = `${diffMins} min ago`;
|
|
||||||
} else if (diffHours < 24) {
|
|
||||||
lastActivity = `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
|
|
||||||
} else {
|
|
||||||
lastActivity = `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: thread.thread_id,
|
|
||||||
title: getThreadTitle(thread),
|
|
||||||
status: uiStatus,
|
|
||||||
lastActivity,
|
|
||||||
taskCount: values?.taskPlan?.tasks.length ?? 0,
|
|
||||||
repository: values?.targetRepository
|
|
||||||
? `${values.targetRepository.owner}/${values.targetRepository.repo}`
|
|
||||||
: "",
|
|
||||||
branch: values?.targetRepository.branch || "main",
|
|
||||||
githubIssue: values?.githubIssueId
|
|
||||||
? {
|
|
||||||
number: values?.githubIssueId,
|
|
||||||
url: `https://github.com/${values?.targetRepository.owner}/${values?.targetRepository.repo}/issues/${values?.githubIssueId}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
51
apps/web/src/hooks/useThreadMetadata.ts
Normal file
51
apps/web/src/hooks/useThreadMetadata.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { ThreadMetadata } from "@/components/v2/types";
|
||||||
|
import { useThreadStatus } from "./useThreadStatus";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { getThreadTitle } from "@/lib/thread";
|
||||||
|
import { calculateLastActivity } from "@/lib/thread-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that combines thread metadata with real-time status
|
||||||
|
*/
|
||||||
|
export function useThreadMetadata(thread: Thread<ManagerGraphState>): {
|
||||||
|
metadata: ThreadMetadata;
|
||||||
|
isStatusLoading: boolean;
|
||||||
|
statusError: Error | null;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
isLoading: isStatusLoading,
|
||||||
|
error: statusError,
|
||||||
|
} = useThreadStatus(thread.thread_id);
|
||||||
|
|
||||||
|
const metadata: ThreadMetadata = useMemo((): ThreadMetadata => {
|
||||||
|
const values = thread.values;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: thread.thread_id,
|
||||||
|
title: getThreadTitle(thread),
|
||||||
|
lastActivity: calculateLastActivity(thread.updated_at),
|
||||||
|
taskCount: values?.taskPlan?.tasks.length ?? 0,
|
||||||
|
repository: values?.targetRepository
|
||||||
|
? `${values.targetRepository.owner}/${values.targetRepository.repo}`
|
||||||
|
: "",
|
||||||
|
branch: values?.targetRepository?.branch || "main",
|
||||||
|
taskPlan: values?.taskPlan,
|
||||||
|
status,
|
||||||
|
githubIssue: values?.githubIssueId
|
||||||
|
? {
|
||||||
|
number: values?.githubIssueId,
|
||||||
|
url: `https://github.com/${values?.targetRepository?.owner}/${values?.targetRepository?.repo}/issues/${values?.githubIssueId}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}, [thread, status]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
isStatusLoading,
|
||||||
|
statusError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { ThreadPoller, PollConfig } from "@/lib/polling/thread-poller";
|
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
|
||||||
import { Thread } from "@langchain/langgraph-sdk";
|
|
||||||
|
|
||||||
interface UseThreadPollingProps {
|
|
||||||
threads: Thread<GraphState>[];
|
|
||||||
getThread: (threadId: string) => Promise<Thread<GraphState> | null>;
|
|
||||||
onUpdate: (
|
|
||||||
updatedThreads: Thread<GraphState>[],
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
48
apps/web/src/hooks/useThreadStatus.ts
Normal file
48
apps/web/src/hooks/useThreadStatus.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { THREAD_STATUS_SWR_CONFIG } from "@/lib/swr-config";
|
||||||
|
import { ThreadUIStatus, ThreadStatusData } from "@/lib/schemas/thread-status";
|
||||||
|
import { fetchThreadStatus } from "@/services/thread-status.service";
|
||||||
|
|
||||||
|
interface UseThreadStatusOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadStatusResult {
|
||||||
|
status: ThreadUIStatus;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
mutate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread status hook using SWR for real-time status updates
|
||||||
|
* Uses SWR caching directly instead of manual Zustand cache
|
||||||
|
*/
|
||||||
|
export function useThreadStatus(
|
||||||
|
threadId: string,
|
||||||
|
options: UseThreadStatusOptions = {},
|
||||||
|
): ThreadStatusResult {
|
||||||
|
const {
|
||||||
|
enabled = true,
|
||||||
|
refreshInterval = THREAD_STATUS_SWR_CONFIG.refreshInterval,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const swrKey = enabled ? `thread-status-${threadId}` : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading, mutate } = useSWR<ThreadStatusData>(
|
||||||
|
swrKey,
|
||||||
|
() => fetchThreadStatus(threadId),
|
||||||
|
{
|
||||||
|
...THREAD_STATUS_SWR_CONFIG,
|
||||||
|
refreshInterval,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data?.status || "idle",
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { createClient } from "@/providers/client";
|
|
||||||
import { Thread } from "@langchain/langgraph-sdk";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function useThreads<State extends Record<string, any>>(
|
|
||||||
assistantId?: string,
|
|
||||||
) {
|
|
||||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
|
||||||
const [threads, setThreads] = useState<Thread<State>[]>([]);
|
|
||||||
const [threadsLoading, setThreadsLoading] = useState(false);
|
|
||||||
|
|
||||||
const getThread = useCallback(
|
|
||||||
async (threadId: string): Promise<Thread<State> | null> => {
|
|
||||||
if (!apiUrl) return null;
|
|
||||||
const client = createClient(apiUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const thread = await client.threads.get<State>(threadId);
|
|
||||||
return thread;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch thread:", threadId, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getThreads = useCallback(async (): Promise<Thread<State>[] | null> => {
|
|
||||||
if (!apiUrl) return null;
|
|
||||||
setThreadsLoading(true);
|
|
||||||
const client = createClient(apiUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchArgs = assistantId
|
|
||||||
? {
|
|
||||||
metadata: {
|
|
||||||
graph_id: assistantId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const threads = await client.threads.search<State>(searchArgs);
|
|
||||||
return threads;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch threads:", error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setThreadsLoading(false);
|
|
||||||
}
|
|
||||||
}, [apiUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getThreads().then((threads) => {
|
|
||||||
setThreads(threads ?? []);
|
|
||||||
});
|
|
||||||
}, [getThreads]);
|
|
||||||
|
|
||||||
return { threads, setThreads, getThread, getThreads, threadsLoading };
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,44 @@
|
||||||
import { createClient } from "@/providers/client";
|
|
||||||
import { Thread } from "@langchain/langgraph-sdk";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { createClient } from "@/providers/client";
|
||||||
|
import { THREAD_SWR_CONFIG } from "@/lib/swr-config";
|
||||||
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||||
|
import { ReviewerGraphState } from "@open-swe/shared/open-swe/reviewer/types";
|
||||||
|
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||||
|
|
||||||
export interface UseThreadsSWROptions {
|
/**
|
||||||
|
* Union type representing all possible graph states in the Open SWE system
|
||||||
|
*/
|
||||||
|
export type AnyGraphState =
|
||||||
|
| ManagerGraphState
|
||||||
|
| PlannerGraphState
|
||||||
|
| ReviewerGraphState
|
||||||
|
| GraphState;
|
||||||
|
|
||||||
|
interface UseThreadsSWROptions {
|
||||||
assistantId?: string;
|
assistantId?: string;
|
||||||
refreshInterval?: number;
|
refreshInterval?: number;
|
||||||
revalidateOnFocus?: boolean;
|
revalidateOnFocus?: boolean;
|
||||||
revalidateOnReconnect?: boolean;
|
revalidateOnReconnect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThreadsSWR<State extends Record<string, any>>(
|
/**
|
||||||
options: UseThreadsSWROptions = {},
|
* Hook for fetching threads for any graph type.
|
||||||
) {
|
* Works with all graph states (Manager, Planner, Programmer, Reviewer)
|
||||||
|
* by passing the appropriate assistantId.
|
||||||
|
*
|
||||||
|
* For UI display of manager threads, use `threadsToMetadata(threads)` utility to convert
|
||||||
|
* raw threads to ThreadMetadata objects.
|
||||||
|
*/
|
||||||
|
export function useThreadsSWR<
|
||||||
|
TGraphState extends AnyGraphState = AnyGraphState,
|
||||||
|
>(options: UseThreadsSWROptions = {}) {
|
||||||
const {
|
const {
|
||||||
assistantId,
|
assistantId,
|
||||||
refreshInterval = 0, // Default to no polling, can be overridden
|
refreshInterval = THREAD_SWR_CONFIG.refreshInterval,
|
||||||
revalidateOnFocus = true,
|
revalidateOnFocus = THREAD_SWR_CONFIG.revalidateOnFocus,
|
||||||
revalidateOnReconnect = true,
|
revalidateOnReconnect = THREAD_SWR_CONFIG.revalidateOnReconnect,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
|
@ -24,7 +46,7 @@ export function useThreadsSWR<State extends Record<string, any>>(
|
||||||
// Create a unique key for SWR caching based on assistantId
|
// Create a unique key for SWR caching based on assistantId
|
||||||
const swrKey = assistantId ? ["threads", assistantId] : ["threads", "all"];
|
const swrKey = assistantId ? ["threads", assistantId] : ["threads", "all"];
|
||||||
|
|
||||||
const fetcher = async (): Promise<Thread<State>[]> => {
|
const fetcher = async (): Promise<Thread<TGraphState>[]> => {
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
throw new Error("API URL is not configured");
|
throw new Error("API URL is not configured");
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +60,7 @@ export function useThreadsSWR<State extends Record<string, any>>(
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return await client.threads.search<State>(searchArgs);
|
return await client.threads.search<TGraphState>(searchArgs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, error, isLoading, mutate, isValidating } = useSWR(
|
const { data, error, isLoading, mutate, isValidating } = useSWR(
|
||||||
|
|
@ -48,8 +70,9 @@ export function useThreadsSWR<State extends Record<string, any>>(
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
revalidateOnFocus,
|
revalidateOnFocus,
|
||||||
revalidateOnReconnect,
|
revalidateOnReconnect,
|
||||||
errorRetryCount: 3,
|
errorRetryCount: THREAD_SWR_CONFIG.errorRetryCount,
|
||||||
errorRetryInterval: 5000,
|
errorRetryInterval: THREAD_SWR_CONFIG.errorRetryInterval,
|
||||||
|
dedupingInterval: THREAD_SWR_CONFIG.dedupingInterval,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
192
apps/web/src/hooks/useThreadsStatus.ts
Normal file
192
apps/web/src/hooks/useThreadsStatus.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { ThreadUIStatus, ThreadStatusData } from "@/lib/schemas/thread-status";
|
||||||
|
import { fetchThreadStatus } from "@/services/thread-status.service";
|
||||||
|
import { THREAD_STATUS_SWR_CONFIG } from "@/lib/swr-config";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export interface SessionCacheData {
|
||||||
|
plannerData?: { thread: Thread<PlannerGraphState> };
|
||||||
|
programmerData?: { thread: Thread<GraphState> };
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionCache = Map<string, SessionCacheData>;
|
||||||
|
|
||||||
|
interface ThreadStatusMap {
|
||||||
|
[threadId: string]: ThreadUIStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadStatusCounts {
|
||||||
|
all: number;
|
||||||
|
running: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
idle: number;
|
||||||
|
paused: number;
|
||||||
|
error: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedThreadIds {
|
||||||
|
running: string[];
|
||||||
|
completed: string[];
|
||||||
|
failed: string[];
|
||||||
|
pending: string[];
|
||||||
|
idle: string[];
|
||||||
|
paused: string[];
|
||||||
|
error: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseThreadsStatusResult {
|
||||||
|
statusMap: ThreadStatusMap;
|
||||||
|
statusCounts: ThreadStatusCounts;
|
||||||
|
groupedThreads: GroupedThreadIds;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasErrors: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDataCache: SessionCache = new Map();
|
||||||
|
|
||||||
|
const CACHE_TTL = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches statuses for multiple threads in parallel
|
||||||
|
* Uses session caching to achieve "single request per thread + cache sessions" goal
|
||||||
|
*/
|
||||||
|
async function fetchAllThreadStatuses(
|
||||||
|
threadIds: string[],
|
||||||
|
lastPollingStates: Map<string, ThreadStatusData>,
|
||||||
|
managerThreads?: Thread<ManagerGraphState>[],
|
||||||
|
): Promise<{
|
||||||
|
statusMap: ThreadStatusMap;
|
||||||
|
updatedStates: Map<string, ThreadStatusData>;
|
||||||
|
}> {
|
||||||
|
const statusPromises = threadIds.map(async (threadId) => {
|
||||||
|
try {
|
||||||
|
const lastState = lastPollingStates.get(threadId) || null;
|
||||||
|
|
||||||
|
const managerThread = managerThreads?.find(
|
||||||
|
(t) => t.thread_id === threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusData = await fetchThreadStatus(
|
||||||
|
threadId,
|
||||||
|
lastState,
|
||||||
|
managerThread,
|
||||||
|
sessionDataCache,
|
||||||
|
);
|
||||||
|
return { threadId, status: statusData.status, statusData };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch status for thread ${threadId}:`, error);
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
status: "idle" as ThreadUIStatus,
|
||||||
|
statusData: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(statusPromises);
|
||||||
|
const statusMap: ThreadStatusMap = {};
|
||||||
|
const updatedStates = new Map<string, ThreadStatusData>();
|
||||||
|
|
||||||
|
results.forEach(({ threadId, status, statusData }) => {
|
||||||
|
statusMap[threadId] = status;
|
||||||
|
if (statusData) {
|
||||||
|
updatedStates.set(threadId, statusData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { statusMap, updatedStates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that fetches statuses for multiple threads in parallel
|
||||||
|
* Uses SWR for caching and deduplication with state optimization
|
||||||
|
*/
|
||||||
|
export function useThreadsStatus(
|
||||||
|
threadIds: string[],
|
||||||
|
managerThreads?: Thread<ManagerGraphState>[],
|
||||||
|
): UseThreadsStatusResult {
|
||||||
|
const lastPollingStatesRef = useRef<Map<string, ThreadStatusData>>(new Map());
|
||||||
|
|
||||||
|
// Create a stable key for the thread IDs array
|
||||||
|
const sortedThreadIds = threadIds.sort();
|
||||||
|
const threadIdsKey = sortedThreadIds.join(",");
|
||||||
|
|
||||||
|
const swrKey =
|
||||||
|
threadIds.length > 0
|
||||||
|
? threadIds.length <= 4
|
||||||
|
? `threads-status-batch-${threadIds.length}-${threadIdsKey}`
|
||||||
|
: `threads-status-${threadIdsKey}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: fetchResult,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useSWR(
|
||||||
|
swrKey,
|
||||||
|
async () => {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log(
|
||||||
|
`[Status SWR] Fetching statuses for ${threadIds.length} threads`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchAllThreadStatuses(
|
||||||
|
sortedThreadIds, // Use sorted array for consistency
|
||||||
|
lastPollingStatesRef.current,
|
||||||
|
managerThreads,
|
||||||
|
);
|
||||||
|
lastPollingStatesRef.current = result.updatedStates;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
THREAD_STATUS_SWR_CONFIG,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusMap = fetchResult?.statusMap || {};
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const groupedThreads: GroupedThreadIds = {
|
||||||
|
running: [],
|
||||||
|
completed: [],
|
||||||
|
failed: [],
|
||||||
|
pending: [],
|
||||||
|
idle: [],
|
||||||
|
paused: [],
|
||||||
|
error: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusMap) {
|
||||||
|
Object.entries(statusMap).forEach(([threadId, status]) => {
|
||||||
|
if (groupedThreads[status]) {
|
||||||
|
groupedThreads[status].push(threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCounts: ThreadStatusCounts = {
|
||||||
|
all: threadIds.length,
|
||||||
|
running: groupedThreads.running.length,
|
||||||
|
completed: groupedThreads.completed.length,
|
||||||
|
failed: groupedThreads.failed.length,
|
||||||
|
pending: groupedThreads.pending.length,
|
||||||
|
idle: groupedThreads.idle.length,
|
||||||
|
paused: groupedThreads.paused.length,
|
||||||
|
error: groupedThreads.error.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusMap: statusMap || {},
|
||||||
|
statusCounts,
|
||||||
|
groupedThreads,
|
||||||
|
isLoading,
|
||||||
|
hasErrors: !!error,
|
||||||
|
};
|
||||||
|
}, [statusMap, threadIds, threadIdsKey, isLoading, error]);
|
||||||
|
}
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { Thread } from "@langchain/langgraph-sdk";
|
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
|
||||||
import { getThreadTasks, getThreadTitle } from "../thread";
|
|
||||||
|
|
||||||
export interface PollConfig {
|
|
||||||
interval: number;
|
|
||||||
onUpdate: (
|
|
||||||
updatedThreads: Thread<GraphState>[],
|
|
||||||
changedThreadIds: string[],
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ThreadPoller {
|
|
||||||
private config: PollConfig;
|
|
||||||
private isPolling: boolean = false;
|
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
|
||||||
private threads: Thread<GraphState>[];
|
|
||||||
private getThreadFn: (threadId: string) => Promise<Thread<GraphState> | null>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
config: PollConfig,
|
|
||||||
threads: Thread<GraphState>[],
|
|
||||||
getThreadFn: (threadId: string) => Promise<Thread<GraphState> | 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: Thread<GraphState>[] = [];
|
|
||||||
const changedThreadIds: string[] = [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
const pollUpdatePromise = Promise.allSettled(
|
|
||||||
threadsToPool.map(async (currentThread) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await pollUpdatePromise;
|
|
||||||
|
|
||||||
if (changedThreadIds.length > 0) {
|
|
||||||
this.config.onUpdate(updatedThreads, changedThreadIds);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Thread polling error:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasThreadChanged(
|
|
||||||
current: Thread<GraphState>,
|
|
||||||
updated: Thread<GraphState>,
|
|
||||||
): boolean {
|
|
||||||
const currentTaskCounts = getThreadTasks(current);
|
|
||||||
const updatedTaskCounts = getThreadTasks(updated);
|
|
||||||
const currentTargetRepo = current.values?.targetRepository;
|
|
||||||
const updatedTargetRepo = updated.values?.targetRepository;
|
|
||||||
return (
|
|
||||||
currentTaskCounts.completedTasks !== updatedTaskCounts.completedTasks ||
|
|
||||||
currentTaskCounts.totalTasks !== updatedTaskCounts.totalTasks ||
|
|
||||||
current.status !== updated.status ||
|
|
||||||
getThreadTitle(current) !== getThreadTitle(updated) ||
|
|
||||||
currentTargetRepo.repo !== updatedTargetRepo.repo ||
|
|
||||||
currentTargetRepo.branch !== updatedTargetRepo.branch ||
|
|
||||||
JSON.stringify(current.values?.taskPlan) !==
|
|
||||||
JSON.stringify(updated.values?.taskPlan)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
apps/web/src/lib/schemas/thread-status.ts
Normal file
45
apps/web/src/lib/schemas/thread-status.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
MANAGER_GRAPH_ID,
|
||||||
|
PLANNER_GRAPH_ID,
|
||||||
|
PROGRAMMER_GRAPH_ID,
|
||||||
|
} from "@open-swe/shared/constants";
|
||||||
|
import { ThreadStatus } from "@langchain/langgraph-sdk";
|
||||||
|
import { TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-specific thread status that extends LangGraph's states
|
||||||
|
*/
|
||||||
|
export type ThreadUIStatus =
|
||||||
|
| "running" // Maps from LangGraph "busy"
|
||||||
|
| "completed" // Business logic: all tasks completed
|
||||||
|
| "failed" // UI-specific state
|
||||||
|
| "pending" // UI-specific state
|
||||||
|
| "idle" // Same as LangGraph "idle"
|
||||||
|
| "paused" // Maps from LangGraph "interrupted"
|
||||||
|
| "error"; // Same as LangGraph "error"
|
||||||
|
|
||||||
|
export function mapLangGraphToUIStatus(status: ThreadStatus): ThreadUIStatus {
|
||||||
|
switch (status) {
|
||||||
|
case "busy":
|
||||||
|
return "running";
|
||||||
|
case "interrupted":
|
||||||
|
return "paused";
|
||||||
|
case "idle":
|
||||||
|
return "idle";
|
||||||
|
case "error":
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadStatusData {
|
||||||
|
graph:
|
||||||
|
| typeof MANAGER_GRAPH_ID
|
||||||
|
| typeof PLANNER_GRAPH_ID
|
||||||
|
| typeof PROGRAMMER_GRAPH_ID;
|
||||||
|
runId: string;
|
||||||
|
threadId: string;
|
||||||
|
status: ThreadUIStatus;
|
||||||
|
taskPlan?: TaskPlan; // Task plan data when available from programmer sessions
|
||||||
|
}
|
||||||
33
apps/web/src/lib/swr-config.ts
Normal file
33
apps/web/src/lib/swr-config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Standardized SWR configuration for thread-related hooks
|
||||||
|
*
|
||||||
|
* This ensures consistent polling intervals, error handling, and caching behavior
|
||||||
|
* across all thread data fetching in the application.
|
||||||
|
*/
|
||||||
|
export const THREAD_SWR_CONFIG = {
|
||||||
|
refreshInterval: 15000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
|
errorRetryCount: 3,
|
||||||
|
errorRetryInterval: 5000,
|
||||||
|
dedupingInterval: 2000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SWR configuration for thread status polling
|
||||||
|
* Uses same intervals but with focus revalidation for real-time updates
|
||||||
|
*/
|
||||||
|
export const THREAD_STATUS_SWR_CONFIG = {
|
||||||
|
...THREAD_SWR_CONFIG,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
dedupingInterval: 5000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SWR configuration for one-time fetches (no polling)
|
||||||
|
* Used for thread data that doesn't need real-time updates
|
||||||
|
*/
|
||||||
|
export const THREAD_STATIC_SWR_CONFIG = {
|
||||||
|
...THREAD_SWR_CONFIG,
|
||||||
|
refreshInterval: 0, // No automatic polling
|
||||||
|
} as const;
|
||||||
42
apps/web/src/lib/thread-utils.ts
Normal file
42
apps/web/src/lib/thread-utils.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { ThreadMetadata } from "@/components/v2/types";
|
||||||
|
import { getThreadTitle } from "./thread";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate human-readable last activity time from thread updated_at timestamp
|
||||||
|
*/
|
||||||
|
export function calculateLastActivity(updatedAt: string): string {
|
||||||
|
return formatDistanceToNow(new Date(updatedAt), { addSuffix: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw manager threads to ThreadMetadata objects for UI display
|
||||||
|
*/
|
||||||
|
export function threadsToMetadata(
|
||||||
|
threads: Thread<ManagerGraphState>[],
|
||||||
|
): ThreadMetadata[] {
|
||||||
|
return threads.map((thread): ThreadMetadata => {
|
||||||
|
const values = thread.values;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: thread.thread_id,
|
||||||
|
title: getThreadTitle(thread),
|
||||||
|
lastActivity: calculateLastActivity(thread.updated_at),
|
||||||
|
taskCount: values?.taskPlan?.tasks.length ?? 0,
|
||||||
|
repository: values?.targetRepository
|
||||||
|
? `${values.targetRepository.owner}/${values.targetRepository.repo}`
|
||||||
|
: "",
|
||||||
|
branch: values?.targetRepository?.branch || "main",
|
||||||
|
taskPlan: values?.taskPlan,
|
||||||
|
status: "idle" as const, // Default status - consumers can override with real status
|
||||||
|
githubIssue: values?.githubIssueId
|
||||||
|
? {
|
||||||
|
number: values?.githubIssueId,
|
||||||
|
url: `https://github.com/${values?.targetRepository?.owner}/${values?.targetRepository?.repo}/issues/${values?.githubIssueId}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createClient } from "./client";
|
import { createClient } from "./client";
|
||||||
import { GraphState } from "@open-swe/shared/open-swe/types";
|
import { GraphState } from "@open-swe/shared/open-swe/types";
|
||||||
import { useThreadPolling } from "@/hooks/useThreadPolling";
|
|
||||||
|
|
||||||
interface ThreadContextType {
|
interface ThreadContextType {
|
||||||
threads: Thread<GraphState>[];
|
threads: Thread<GraphState>[];
|
||||||
|
|
@ -110,32 +109,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
|
||||||
refreshThreads();
|
refreshThreads();
|
||||||
}, [refreshThreads]);
|
}, [refreshThreads]);
|
||||||
|
|
||||||
const handlePollingUpdate = useCallback(
|
|
||||||
(updatedThreads: Thread<GraphState>[], 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(
|
const handleThreadClick = useCallback(
|
||||||
(
|
(
|
||||||
thread: Thread<GraphState>,
|
thread: Thread<GraphState>,
|
||||||
|
|
|
||||||
426
apps/web/src/services/thread-status.service.ts
Normal file
426
apps/web/src/services/thread-status.service.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
import { Client, Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { createClient } from "@/providers/client";
|
||||||
|
import {
|
||||||
|
ThreadUIStatus,
|
||||||
|
ThreadStatusData,
|
||||||
|
mapLangGraphToUIStatus,
|
||||||
|
} from "@/lib/schemas/thread-status";
|
||||||
|
import { GraphState, TaskPlan } from "@open-swe/shared/open-swe/types";
|
||||||
|
import { ManagerGraphState } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { PlannerGraphState } from "@open-swe/shared/open-swe/planner/types";
|
||||||
|
import { getActivePlanItems } from "@open-swe/shared/open-swe/tasks";
|
||||||
|
import { SessionCache, SessionCacheData } from "@/hooks/useThreadsStatus";
|
||||||
|
|
||||||
|
interface StatusResult {
|
||||||
|
graph: "manager" | "planner" | "programmer";
|
||||||
|
runId: string;
|
||||||
|
threadId: string;
|
||||||
|
status: ThreadUIStatus;
|
||||||
|
taskPlan?: TaskPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if all tasks in a task plan are completed
|
||||||
|
*/
|
||||||
|
function areAllPlanItemsCompleted(taskPlan: TaskPlan): boolean {
|
||||||
|
if (!taskPlan?.tasks || !Array.isArray(taskPlan.tasks)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const activePlanItems = getActivePlanItems(taskPlan);
|
||||||
|
return activePlanItems.every((planItem) => planItem.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StatusResolver {
|
||||||
|
resolve(
|
||||||
|
manager: StatusResult,
|
||||||
|
planner?: StatusResult,
|
||||||
|
programmer?: StatusResult,
|
||||||
|
): ThreadStatusData {
|
||||||
|
if (manager.status === "running" || manager.status === "error") {
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!planner) {
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planner.status === "running" || planner.status === "paused") {
|
||||||
|
return planner;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planner.status === "error") {
|
||||||
|
return planner;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!programmer) {
|
||||||
|
return planner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return programmer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL = 30 * 1000;
|
||||||
|
|
||||||
|
function getCachedSessionData(
|
||||||
|
sessionCache: SessionCache | undefined,
|
||||||
|
sessionKey: string,
|
||||||
|
): SessionCacheData | null {
|
||||||
|
if (!sessionCache) return null;
|
||||||
|
|
||||||
|
const cached = sessionCache.get(sessionKey);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
|
||||||
|
if (isExpired) {
|
||||||
|
sessionCache.delete(sessionKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedSessionData(
|
||||||
|
sessionCache: SessionCache | undefined,
|
||||||
|
sessionKey: string,
|
||||||
|
data: Partial<SessionCacheData>,
|
||||||
|
): void {
|
||||||
|
if (!sessionCache) return;
|
||||||
|
|
||||||
|
sessionCache.set(sessionKey, {
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchThreadStatus(
|
||||||
|
threadId: string,
|
||||||
|
lastPollingState: ThreadStatusData | null = null,
|
||||||
|
managerThreadData?: Thread<ManagerGraphState> | null,
|
||||||
|
sessionCache?: SessionCache,
|
||||||
|
): Promise<ThreadStatusData> {
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
if (!apiUrl) {
|
||||||
|
throw new Error("API URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(apiUrl);
|
||||||
|
const resolver = new StatusResolver();
|
||||||
|
|
||||||
|
if (lastPollingState) {
|
||||||
|
try {
|
||||||
|
const optimizedResult = await checkLastKnownGraph(
|
||||||
|
client,
|
||||||
|
lastPollingState,
|
||||||
|
resolver,
|
||||||
|
);
|
||||||
|
if (optimizedResult) {
|
||||||
|
return optimizedResult;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"Optimization check failed, falling back to full status check:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await performFullStatusCheck(
|
||||||
|
client,
|
||||||
|
threadId,
|
||||||
|
resolver,
|
||||||
|
managerThreadData,
|
||||||
|
sessionCache,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching thread status for ${threadId}:`, error);
|
||||||
|
|
||||||
|
const graph = lastPollingState?.graph || "manager";
|
||||||
|
const runId = lastPollingState?.runId || "";
|
||||||
|
const errorThreadId = lastPollingState?.threadId || threadId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
runId,
|
||||||
|
threadId: errorThreadId,
|
||||||
|
status: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLastKnownGraph(
|
||||||
|
client: Client,
|
||||||
|
lastState: ThreadStatusData,
|
||||||
|
resolver: StatusResolver,
|
||||||
|
): Promise<ThreadStatusData | null> {
|
||||||
|
switch (lastState.graph) {
|
||||||
|
case "programmer":
|
||||||
|
if (lastState.threadId && lastState.runId) {
|
||||||
|
const programmerThread = await client.threads.get<GraphState>(
|
||||||
|
lastState.threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use thread status directly for most cases
|
||||||
|
let programmerStatusValue = mapLangGraphToUIStatus(
|
||||||
|
programmerThread.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check task completion when thread is idle - no run status needed
|
||||||
|
if (
|
||||||
|
programmerThread.status === "idle" &&
|
||||||
|
areAllPlanItemsCompleted(programmerThread.values?.taskPlan)
|
||||||
|
) {
|
||||||
|
programmerStatusValue = "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const programmerStatus: StatusResult = {
|
||||||
|
graph: "programmer",
|
||||||
|
runId: lastState.runId,
|
||||||
|
threadId: lastState.threadId,
|
||||||
|
status: programmerStatusValue,
|
||||||
|
taskPlan: programmerThread.values?.taskPlan,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
programmerStatus.status === "running" ||
|
||||||
|
programmerStatus.status === "error"
|
||||||
|
) {
|
||||||
|
return programmerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "planner":
|
||||||
|
if (lastState.threadId && lastState.runId) {
|
||||||
|
const plannerThread = await client.threads.get<PlannerGraphState>(
|
||||||
|
lastState.threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use thread status directly for most cases
|
||||||
|
let plannerStatusValue = mapLangGraphToUIStatus(plannerThread.status);
|
||||||
|
|
||||||
|
// Special case: check for interrupts even if thread status doesn't show interrupted
|
||||||
|
if (
|
||||||
|
plannerThread.interrupts &&
|
||||||
|
Array.isArray(plannerThread.interrupts) &&
|
||||||
|
plannerThread.interrupts.length > 0
|
||||||
|
) {
|
||||||
|
plannerStatusValue = "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to check run status for planners - thread status is sufficient
|
||||||
|
|
||||||
|
const plannerStatus: StatusResult = {
|
||||||
|
graph: "planner",
|
||||||
|
runId: lastState.runId,
|
||||||
|
threadId: lastState.threadId,
|
||||||
|
status: plannerStatusValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
plannerStatus.status === "running" ||
|
||||||
|
plannerStatus.status === "paused" ||
|
||||||
|
plannerStatus.status === "error"
|
||||||
|
) {
|
||||||
|
return plannerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plannerThread.values?.programmerSession) {
|
||||||
|
const programmerSession = plannerThread.values.programmerSession;
|
||||||
|
const programmerThread = await client.threads.get<GraphState>(
|
||||||
|
programmerSession.threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use thread status directly for most cases
|
||||||
|
let programmerStatusValue = mapLangGraphToUIStatus(
|
||||||
|
programmerThread.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check task completion when thread is idle - no run status needed
|
||||||
|
if (
|
||||||
|
programmerThread.status === "idle" &&
|
||||||
|
areAllPlanItemsCompleted(programmerThread.values?.taskPlan)
|
||||||
|
) {
|
||||||
|
programmerStatusValue = "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const programmerStatus: StatusResult = {
|
||||||
|
graph: "programmer",
|
||||||
|
runId: programmerSession.runId,
|
||||||
|
threadId: programmerSession.threadId,
|
||||||
|
status: programmerStatusValue,
|
||||||
|
taskPlan: programmerThread.values?.taskPlan,
|
||||||
|
};
|
||||||
|
|
||||||
|
return resolver.resolve(
|
||||||
|
{
|
||||||
|
graph: "manager",
|
||||||
|
runId: "",
|
||||||
|
threadId: lastState.threadId,
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
plannerStatus,
|
||||||
|
programmerStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plannerStatus;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "manager": {
|
||||||
|
const managerThread = await client.threads.get<ManagerGraphState>(
|
||||||
|
lastState.threadId,
|
||||||
|
);
|
||||||
|
const managerStatus: StatusResult = {
|
||||||
|
graph: "manager",
|
||||||
|
runId: "",
|
||||||
|
threadId: lastState.threadId,
|
||||||
|
status: mapLangGraphToUIStatus(managerThread.status),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
managerStatus.status === "running" ||
|
||||||
|
managerStatus.status === "error"
|
||||||
|
) {
|
||||||
|
return managerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performFullStatusCheck(
|
||||||
|
client: Client,
|
||||||
|
threadId: string,
|
||||||
|
resolver: StatusResolver,
|
||||||
|
managerThreadData?: Thread<ManagerGraphState> | null,
|
||||||
|
sessionCache?: SessionCache,
|
||||||
|
): Promise<ThreadStatusData> {
|
||||||
|
let managerThread: Thread<ManagerGraphState>;
|
||||||
|
|
||||||
|
if (managerThreadData) {
|
||||||
|
managerThread = managerThreadData;
|
||||||
|
} else {
|
||||||
|
managerThread = await client.threads.get<ManagerGraphState>(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const managerStatus: StatusResult = {
|
||||||
|
graph: "manager",
|
||||||
|
runId: "",
|
||||||
|
threadId,
|
||||||
|
status: mapLangGraphToUIStatus(managerThread.status),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If manager is running or has error, return immediately without checking sub-sessions
|
||||||
|
if (managerStatus.status === "running" || managerStatus.status === "error") {
|
||||||
|
return resolver.resolve(managerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!managerThread.values?.plannerSession) {
|
||||||
|
return resolver.resolve(managerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannerSession = managerThread.values.plannerSession;
|
||||||
|
const plannerCacheKey = `planner:${plannerSession.threadId}:${plannerSession.runId}`;
|
||||||
|
|
||||||
|
let plannerThread: Thread<PlannerGraphState>;
|
||||||
|
const cachedPlannerData = getCachedSessionData(sessionCache, plannerCacheKey);
|
||||||
|
|
||||||
|
if (cachedPlannerData?.plannerData) {
|
||||||
|
plannerThread = cachedPlannerData.plannerData.thread;
|
||||||
|
} else {
|
||||||
|
plannerThread = await client.threads.get<PlannerGraphState>(
|
||||||
|
plannerSession.threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No run fetch needed for planners - thread status is sufficient
|
||||||
|
|
||||||
|
setCachedSessionData(sessionCache, plannerCacheKey, {
|
||||||
|
plannerData: { thread: plannerThread },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use thread status directly for most cases
|
||||||
|
let plannerStatusValue = mapLangGraphToUIStatus(plannerThread.status);
|
||||||
|
|
||||||
|
// Special case: check for interrupts even if thread status doesn't show interrupted
|
||||||
|
if (
|
||||||
|
plannerThread.interrupts &&
|
||||||
|
Array.isArray(plannerThread.interrupts) &&
|
||||||
|
plannerThread.interrupts.length > 0
|
||||||
|
) {
|
||||||
|
plannerStatusValue = "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
// No run status check needed for planners
|
||||||
|
|
||||||
|
const plannerStatus: StatusResult = {
|
||||||
|
graph: "planner",
|
||||||
|
runId: plannerSession.runId,
|
||||||
|
threadId: plannerSession.threadId,
|
||||||
|
status: plannerStatusValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
plannerStatus.status === "running" ||
|
||||||
|
plannerStatus.status === "paused" ||
|
||||||
|
plannerStatus.status === "error"
|
||||||
|
) {
|
||||||
|
return resolver.resolve(managerStatus, plannerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plannerThread.values?.programmerSession) {
|
||||||
|
return resolver.resolve(managerStatus, plannerStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const programmerSession = plannerThread.values.programmerSession;
|
||||||
|
const programmerCacheKey = `programmer:${programmerSession.threadId}:${programmerSession.runId}`;
|
||||||
|
|
||||||
|
let programmerThread: Thread<GraphState>;
|
||||||
|
const cachedProgrammerData = getCachedSessionData(
|
||||||
|
sessionCache,
|
||||||
|
programmerCacheKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cachedProgrammerData?.programmerData) {
|
||||||
|
programmerThread = cachedProgrammerData.programmerData.thread;
|
||||||
|
} else {
|
||||||
|
programmerThread = await client.threads.get<GraphState>(
|
||||||
|
programmerSession.threadId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No run fetch needed - we only check task completion from thread data
|
||||||
|
|
||||||
|
setCachedSessionData(sessionCache, programmerCacheKey, {
|
||||||
|
programmerData: { thread: programmerThread },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use thread status directly for most cases
|
||||||
|
let programmerStatusValue = mapLangGraphToUIStatus(programmerThread.status);
|
||||||
|
|
||||||
|
// Check task completion when thread is idle - no run status needed
|
||||||
|
if (
|
||||||
|
programmerThread.status === "idle" &&
|
||||||
|
areAllPlanItemsCompleted(programmerThread.values?.taskPlan)
|
||||||
|
) {
|
||||||
|
programmerStatusValue = "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const programmerStatus: StatusResult = {
|
||||||
|
graph: "programmer",
|
||||||
|
runId: programmerSession.runId,
|
||||||
|
threadId: programmerSession.threadId,
|
||||||
|
status: programmerStatusValue,
|
||||||
|
taskPlan: programmerThread.values?.taskPlan,
|
||||||
|
};
|
||||||
|
|
||||||
|
return resolver.resolve(managerStatus, plannerStatus, programmerStatus);
|
||||||
|
}
|
||||||
43
apps/web/src/stores/thread-store.ts
Normal file
43
apps/web/src/stores/thread-store.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified thread store for UI state only
|
||||||
|
* Data caching is handled by SWR hooks directly
|
||||||
|
*/
|
||||||
|
export interface ThreadStoreState {
|
||||||
|
// UI state only - active thread tracking
|
||||||
|
activeThreadId: string | null;
|
||||||
|
|
||||||
|
// UI state only - global polling control
|
||||||
|
isGlobalPollingEnabled: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setActiveThread: (threadId: string | null) => void;
|
||||||
|
setGlobalPolling: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Zustand store for thread UI state management
|
||||||
|
* All data caching moved to SWR for better performance and consistency
|
||||||
|
*/
|
||||||
|
export const useThreadStore = create<ThreadStoreState>((set) => ({
|
||||||
|
// Initial state
|
||||||
|
activeThreadId: null,
|
||||||
|
isGlobalPollingEnabled: true,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setActiveThread: (threadId) => {
|
||||||
|
set({ activeThreadId: threadId });
|
||||||
|
},
|
||||||
|
|
||||||
|
setGlobalPolling: (enabled) => {
|
||||||
|
set({ isGlobalPollingEnabled: enabled });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selector hooks for specific pieces of state
|
||||||
|
export const useActiveThreadId = () =>
|
||||||
|
useThreadStore((state) => state.activeThreadId);
|
||||||
|
|
||||||
|
export const useGlobalPollingEnabled = () =>
|
||||||
|
useThreadStore((state) => state.isGlobalPollingEnabled);
|
||||||
Loading…
Add table
Reference in a new issue