mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 20:42:11 +00:00
* feat: mark threads resolved to hide from sidebar [closes resolve-threads] Add a resolved flag (thread metadata) so users can clear finished threads from the sidebar without deleting them. Resolved threads move to a collapsible "Resolved" group (capped at 20 with a "Show all" link) and are fully searchable/filterable + paginated on a new /agents/threads page driven by URL query params. Sending a new message auto-unresolves. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: refresh paginated agent thread lists Invalidate all agent thread list queries when thread lifecycle events change sidebar data, keeping the new paginated sidebar cache in sync. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { useNavigate } from "@tanstack/react-router"
|
|
import { useEffect } from "react"
|
|
|
|
import { agentsApi } from "./api"
|
|
import type { QueryClient } from "@tanstack/react-query"
|
|
import type { ScheduleUpdateRequest, ThreadsPageParams } from "./api"
|
|
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
|
|
|
export const agentThreadKeys = {
|
|
lists: ["agent-threads", "lists"] as const,
|
|
all: ["agent-threads", "lists", "all"] as const,
|
|
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
|
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
|
|
page: (params: ThreadsPageParams) =>
|
|
["agent-threads", "lists", "page", params] as const,
|
|
}
|
|
|
|
export function invalidateAgentThreadLists(queryClient: QueryClient): void {
|
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists })
|
|
}
|
|
|
|
export const agentScheduleKeys = {
|
|
all: ["agent-schedules"] as const,
|
|
}
|
|
|
|
// The list endpoint (`GET /threads`) and the detail endpoint
|
|
// (`GET /threads/{id}`) return the same per-thread summary, so warming the
|
|
// detail cache from the already-fetched list avoids a fan-out of one request
|
|
// per sidebar thread. Navigation stays instant; the real (mark-viewed) fetch
|
|
// fires only when a thread is actually opened. The active thread is skipped so
|
|
// its live detail query stays the source of truth.
|
|
export function useSeedAgentThreadDetails(
|
|
threads: Array<AgentThread>,
|
|
activeThreadId?: string
|
|
) {
|
|
const queryClient = useQueryClient()
|
|
|
|
useEffect(() => {
|
|
for (const thread of threads) {
|
|
if (thread.id === activeThreadId) continue
|
|
// Seed as already-stale: the detail GET is what marks a thread viewed
|
|
// server-side, so opening a seeded entry must still refetch despite the
|
|
// detail query's `staleTime` (which exists for the optimistic seed).
|
|
queryClient.setQueryData(agentThreadKeys.detail(thread.id), thread, {
|
|
updatedAt: 0,
|
|
})
|
|
}
|
|
}, [activeThreadId, queryClient, threads])
|
|
}
|
|
|
|
export function useAgentThreads() {
|
|
return useQuery({
|
|
queryKey: agentThreadKeys.all,
|
|
queryFn: () => agentsApi.listThreads(),
|
|
refetchInterval: (query) =>
|
|
query.state.data?.some((thread) => thread.status === "running")
|
|
? 2000
|
|
: false,
|
|
})
|
|
}
|
|
|
|
// The sidebar fetches active (unresolved) and resolved threads separately so
|
|
// resolving the most-recent threads can't hide older active ones behind a
|
|
// shared cap — each list is filled server-side from its own filtered query.
|
|
const SIDEBAR_ACTIVE_LIMIT = 50
|
|
|
|
function sidebarRefetchInterval(query: {
|
|
state: { data?: { items: Array<AgentThread> } }
|
|
}) {
|
|
return query.state.data?.items.some((thread) => thread.status === "running")
|
|
? 2000
|
|
: false
|
|
}
|
|
|
|
export function useSidebarThreads(resolvedLimit: number) {
|
|
const active = useQuery({
|
|
queryKey: agentThreadKeys.page({
|
|
resolved: false,
|
|
limit: SIDEBAR_ACTIVE_LIMIT,
|
|
}),
|
|
queryFn: () =>
|
|
agentsApi.listThreadsPage({
|
|
resolved: false,
|
|
limit: SIDEBAR_ACTIVE_LIMIT,
|
|
}),
|
|
refetchInterval: sidebarRefetchInterval,
|
|
placeholderData: (prev) => prev,
|
|
})
|
|
const resolved = useQuery({
|
|
queryKey: agentThreadKeys.page({ resolved: true, limit: resolvedLimit }),
|
|
queryFn: () =>
|
|
agentsApi.listThreadsPage({ resolved: true, limit: resolvedLimit }),
|
|
refetchInterval: sidebarRefetchInterval,
|
|
placeholderData: (prev) => prev,
|
|
})
|
|
return { active, resolved }
|
|
}
|
|
|
|
export function useAgentThread(threadId: string) {
|
|
return useQuery({
|
|
queryKey: agentThreadKeys.detail(threadId),
|
|
queryFn: () => agentsApi.getThread(threadId),
|
|
// Lets the optimistic detail seeded by `AgentsHome` survive until the
|
|
// proxied run.start stamps the server-side thread; an immediate refetch
|
|
// would 404 and bounce the route back to /agents.
|
|
staleTime: 30_000,
|
|
})
|
|
}
|
|
|
|
export function useAgentThreadPrDiff(threadId: string, enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: agentThreadKeys.prDiff(threadId),
|
|
queryFn: () => agentsApi.getThreadPrDiff(threadId),
|
|
enabled,
|
|
staleTime: 30_000,
|
|
retry: false,
|
|
})
|
|
}
|
|
|
|
export function useAgentSchedules() {
|
|
return useQuery({
|
|
queryKey: agentScheduleKeys.all,
|
|
queryFn: () => agentsApi.listSchedules(),
|
|
})
|
|
}
|
|
|
|
export function useCreateAgentSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: agentsApi.createSchedule,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useUpdateAgentSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: (vars: { scheduleId: string; body: ScheduleUpdateRequest }) =>
|
|
agentsApi.updateSchedule(vars.scheduleId, vars.body),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useDeleteAgentSchedule() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: agentsApi.deleteSchedule,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
|
},
|
|
})
|
|
}
|
|
|
|
export interface CreateAgentThreadVariables {
|
|
prompt: string
|
|
images?: Array<ImageChunk>
|
|
repo?: string | null
|
|
repo_explicitly_none?: boolean
|
|
model_id?: string | null
|
|
effort?: string | null
|
|
}
|
|
|
|
/**
|
|
* Build the placeholder thread shown the instant a run is started from the
|
|
* home page — before the server has stamped the thread record. Seeded into
|
|
* the detail + list caches by `AgentsHome` so the `$threadId` route renders
|
|
* immediately (the 30s `staleTime` keeps it from refetching into a 404), then
|
|
* reconciled to server truth by the list's running refetch + the stream's
|
|
* `onCreated` / `onCompleted` invalidations.
|
|
*/
|
|
export function optimisticThread(
|
|
threadId: string,
|
|
vars: CreateAgentThreadVariables
|
|
): AgentThread {
|
|
const now = Date.now()
|
|
const text = vars.prompt.trim()
|
|
const repoFullName = vars.repo ?? ""
|
|
const chunks: Array<Chunk> = [
|
|
...(vars.images ?? []),
|
|
...(text ? [{ kind: "text", text } satisfies Chunk] : []),
|
|
]
|
|
const message: Message = {
|
|
id: `optimistic-user-${threadId}`,
|
|
author: "user",
|
|
timestamp: new Date(now).toISOString(),
|
|
chunks,
|
|
}
|
|
return {
|
|
id: threadId,
|
|
title: text.slice(0, 80) || "New agent",
|
|
repo: repoFullName.split("/")[1] ?? "",
|
|
repoFullName,
|
|
branch: "main",
|
|
model: vars.model_id ?? "Default",
|
|
effort: vars.effort ?? null,
|
|
source: "dashboard",
|
|
status: "running",
|
|
viewed: true,
|
|
viewedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
traceUrl: null,
|
|
messages: message.chunks.length > 0 ? [message] : [],
|
|
}
|
|
}
|
|
|
|
export interface SendAgentMessageVariables {
|
|
content: string
|
|
images?: Array<ImageChunk>
|
|
model_id?: string | null
|
|
effort?: string | null
|
|
}
|
|
|
|
export function useCancelAgentThread(threadId: string) {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: () => agentsApi.cancelThread(threadId),
|
|
onSuccess: (thread) => {
|
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
|
|
invalidateAgentThreadLists(queryClient)
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useDeleteAgentThread() {
|
|
const queryClient = useQueryClient()
|
|
const navigate = useNavigate()
|
|
|
|
return useMutation({
|
|
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
|
onSuccess: (_, threadId) => {
|
|
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
|
invalidateAgentThreadLists(queryClient)
|
|
const path = window.location.pathname
|
|
if (path.includes(`/agents/${threadId}`)) {
|
|
navigate({ to: "/agents" })
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useResolveAgentThread() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: (vars: { threadId: string; resolved: boolean }) =>
|
|
agentsApi.resolveThread(vars.threadId, vars.resolved),
|
|
onSuccess: (thread, vars) => {
|
|
queryClient.setQueryData(agentThreadKeys.detail(vars.threadId), thread)
|
|
invalidateAgentThreadLists(queryClient)
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useThreadsPage(params: ThreadsPageParams) {
|
|
return useQuery({
|
|
queryKey: agentThreadKeys.page(params),
|
|
queryFn: () => agentsApi.listThreadsPage(params),
|
|
placeholderData: (prev) => prev,
|
|
})
|
|
}
|