perf: preload agent thread details (#1448)

* perf: preload agent thread details

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* perf: persist agent layout across thread switches

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
Ramon Nogueira 2026-06-08 17:07:55 -04:00 • committed by GitHub
parent ab2e0d1da3
commit 9350361f0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 152 additions and 150 deletions

View file

@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import type { SessionUser } from "@/lib/api"
import type { PendingPrompt } from "@/lib/agents/pendingPrompts" import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
import type { AgentThread, Message } from "@/lib/agents/types" import type { AgentThread, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/useModelOptions" import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar" import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { AgentsShell } from "@/components/agents/AgentsSidebar"
import { MessageView } from "@/components/agents/ported" import { MessageView } from "@/components/agents/ported"
import { import {
agentThreadKeys, agentThreadKeys,
@ -21,7 +19,6 @@ import { useAgentThreadStream } from "@/lib/agents/useThreadStream"
import { useModelOptions } from "@/lib/agents/useModelOptions" import { useModelOptions } from "@/lib/agents/useModelOptions"
interface AgentThreadViewProps { interface AgentThreadViewProps {
user: SessionUser
thread: AgentThread thread: AgentThread
} }
@ -58,7 +55,7 @@ function isPendingPromptConfirmed(
}) })
} }
export function AgentThreadView({ user, thread }: AgentThreadViewProps) { export function AgentThreadView({ thread }: AgentThreadViewProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const sendMessage = useSendAgentMessage(thread.id) const sendMessage = useSendAgentMessage(thread.id)
const cancelThread = useCancelAgentThread(thread.id) const cancelThread = useCancelAgentThread(thread.id)
@ -125,48 +122,19 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
const isStreaming = hasActiveRun || pendingPrompts.length > 0 const isStreaming = hasActiveRun || pendingPrompts.length > 0
return ( return (
<AgentsShell user={user} activeThreadId={thread.id}> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col"> {hasMessages ? (
{hasMessages ? ( <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <MessageView
<MessageView messages={displayMessages}
messages={displayMessages} isStreaming={isStreaming}
isStreaming={isStreaming} contentWidthClass="max-w-3xl"
contentWidthClass="max-w-3xl" />
/> <div className="shrink-0 px-4 pb-4">
<div className="shrink-0 px-4 pb-4"> <div className="mx-auto w-full max-w-3xl min-w-0">
<div className="mx-auto w-full max-w-3xl min-w-0">
<AgentPromptBar
placeholder="Add a follow up"
compact
busy={hasActiveRun}
disabled={sendMessage.isPending}
onSubmit={(content, images) =>
sendMessage.mutate({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
onStop={() => cancelThread.mutate()}
stopping={cancelThread.isPending}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-sm text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
<div className="w-full max-w-3xl">
<AgentPromptBar <AgentPromptBar
placeholder="Send the first message" placeholder="Add a follow up"
compact compact
busy={hasActiveRun} busy={hasActiveRun}
disabled={sendMessage.isPending} disabled={sendMessage.isPending}
@ -186,9 +154,36 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
/> />
</div> </div>
</div> </div>
)} </div>
</div> ) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-sm text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
<div className="w-full max-w-3xl">
<AgentPromptBar
placeholder="Send the first message"
compact
busy={hasActiveRun}
disabled={sendMessage.isPending}
onSubmit={(content, images) =>
sendMessage.mutate({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
onStop={() => cancelThread.mutate()}
stopping={cancelThread.isPending}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
)}
</div> </div>
</AgentsShell> </div>
) )
} }

View file

@ -26,7 +26,11 @@ import {
useSidebarLayout, useSidebarLayout,
} from "@/components/sidebar-layout" } from "@/components/sidebar-layout"
import { groupThreads } from "@/lib/agents/api" import { groupThreads } from "@/lib/agents/api"
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries" import {
useAgentThreads,
useDeleteAgentThread,
usePrefetchAgentThreadDetails,
} from "@/lib/agents/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>> type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
@ -52,6 +56,7 @@ const NAV = [
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
const threadsQuery = useAgentThreads() const threadsQuery = useAgentThreads()
const threads = threadsQuery.data ?? [] const threads = threadsQuery.data ?? []
usePrefetchAgentThreadDetails(threads, activeThreadId)
const groups = groupThreads(threads) const groups = groupThreads(threads)
const layout = useSidebarLayout() const layout = useSidebarLayout()

View file

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { agentsApi } from "./api" import { agentsApi } from "./api"
import { addPendingPrompt } from "./pendingPrompts" import { addPendingPrompt } from "./pendingPrompts"
@ -15,6 +16,29 @@ export const agentScheduleKeys = {
all: ["agent-schedules"] as const, all: ["agent-schedules"] as const,
} }
const PREFETCH_THREAD_DETAIL_LIMIT = 12
export function usePrefetchAgentThreadDetails(
threads: Array<{ id: string }>,
activeThreadId?: string
) {
const queryClient = useQueryClient()
useEffect(() => {
const threadIds = threads
.map((thread) => thread.id)
.filter((threadId) => threadId !== activeThreadId)
.slice(0, PREFETCH_THREAD_DETAIL_LIMIT)
threadIds.forEach((threadId) => {
void queryClient.prefetchQuery({
queryKey: agentThreadKeys.detail(threadId),
queryFn: () => agentsApi.getThread(threadId),
})
})
}, [activeThreadId, queryClient, threads])
}
export function useAgentThreads() { export function useAgentThreads() {
return useQuery({ return useQuery({
queryKey: agentThreadKeys.all, queryKey: agentThreadKeys.all,

View file

@ -1,32 +1,46 @@
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router"; import {
Navigate,
Outlet,
createFileRoute,
useRouterState,
} from "@tanstack/react-router"
import { Skeleton } from "@/components/ui/skeleton"; import { AgentsShell } from "@/components/agents/AgentsSidebar"
import agentsCss from "@/styles/agents.css?url"; import { Skeleton } from "@/components/ui/skeleton"
import { useSession } from "@/lib/session"; import agentsCss from "@/styles/agents.css?url"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/agents")({ export const Route = createFileRoute("/agents")({
head: () => ({ head: () => ({
links: [{ rel: "stylesheet", href: agentsCss }], links: [{ rel: "stylesheet", href: agentsCss }],
}), }),
component: AgentsLayout, component: AgentsLayout,
}); })
function AgentsLayout() { function AgentsLayout() {
const session = useSession(); const session = useSession()
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const [, section, threadId] = pathname.split("/")
const activeThreadId =
section === "agents" && threadId && threadId !== "automations"
? threadId
: undefined
if (session.isLoading) { if (session.isLoading) {
return ( return (
<main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6"> <main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6">
<Skeleton className="h-40 w-full max-w-md" /> <Skeleton className="h-40 w-full max-w-md" />
</main> </main>
); )
} }
if (!session.data) return <Navigate to="/login" />; if (!session.data) return <Navigate to="/login" />
return ( return (
<div className="agents-ui h-svh overflow-hidden"> <AgentsShell user={session.data} activeThreadId={activeThreadId}>
<Outlet /> <Outlet />
</div> </AgentsShell>
); )
} }

View file

@ -1,33 +1,28 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"; import { Navigate, createFileRoute } from "@tanstack/react-router"
import { AgentThreadView } from "@/components/agents/AgentThreadView"; import { AgentThreadView } from "@/components/agents/AgentThreadView"
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton"
import { useAgentThread } from "@/lib/agents/queries"; import { useAgentThread } from "@/lib/agents/queries"
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/$threadId")({ export const Route = createFileRoute("/agents/$threadId")({
component: AgentThreadPage, component: AgentThreadPage,
}); })
function AgentThreadPage() { function AgentThreadPage() {
const { threadId } = Route.useParams(); const { threadId } = Route.useParams()
const session = useSession(); const threadQuery = useAgentThread(threadId)
const threadQuery = useAgentThread(threadId);
if (session.isLoading) return null;
if (!session.data) return <Navigate to="/login" />;
if (threadQuery.isLoading) { if (threadQuery.isLoading) {
return ( return (
<main className="agents-ui flex h-svh items-center justify-center bg-[var(--ui-bg)] p-6"> <main className="flex min-w-0 flex-1 items-center justify-center p-6">
<Skeleton className="h-40 w-full max-w-md" /> <Skeleton className="h-40 w-full max-w-md" />
</main> </main>
); )
} }
if (threadQuery.isError || !threadQuery.data) { if (threadQuery.isError || !threadQuery.data) {
return <Navigate to="/agents" />; return <Navigate to="/agents" />
} }
return <AgentThreadView user={session.data} thread={threadQuery.data} />; return <AgentThreadView thread={threadQuery.data} />
} }

View file

@ -1,46 +1,42 @@
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router"
import { AutomationEditor } from "@/components/agents/AutomationEditor"; import { AutomationEditor } from "@/components/agents/AutomationEditor"
import { AgentsShell } from "@/components/agents/AgentsSidebar"; import { Skeleton } from "@/components/ui/skeleton"
import { Skeleton } from "@/components/ui/skeleton"; import { useAgentSchedules } from "@/lib/agents/queries"
import { useAgentSchedules } from "@/lib/agents/queries";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/$scheduleId")({ export const Route = createFileRoute("/agents/automations/$scheduleId")({
component: EditAutomationPage, component: EditAutomationPage,
}); })
function EditAutomationPage() { function EditAutomationPage() {
const session = useSession(); const { scheduleId } = Route.useParams()
const { scheduleId } = Route.useParams(); const schedulesQuery = useAgentSchedules()
const schedulesQuery = useAgentSchedules(); const schedule = schedulesQuery.data?.find((s) => s.id === scheduleId)
if (!session.data) return null; if (schedulesQuery.isLoading) {
return (
<div className="mx-auto w-full max-w-3xl px-6 py-10">
<Skeleton className="h-9 w-64" />
<Skeleton className="mt-6 h-32 w-full" />
</div>
)
}
const schedule = schedulesQuery.data?.find((s) => s.id === scheduleId); if (schedule) {
return <AutomationEditor mode="edit" schedule={schedule} />
}
return ( return (
<AgentsShell user={session.data}> <div className="mx-auto w-full max-w-3xl px-6 py-16 text-center">
{schedulesQuery.isLoading ? ( <p className="text-sm text-[var(--ui-text-muted)]">
<div className="mx-auto w-full max-w-3xl px-6 py-10"> This automation could not be found.
<Skeleton className="h-9 w-64" /> </p>
<Skeleton className="mt-6 h-32 w-full" /> <Link
</div> to="/agents/automations"
) : schedule ? ( className="mt-3 inline-block text-sm text-[var(--ui-accent)] hover:underline"
<AutomationEditor mode="edit" schedule={schedule} /> >
) : ( Back to Automations
<div className="mx-auto w-full max-w-3xl px-6 py-16 text-center"> </Link>
<p className="text-sm text-[var(--ui-text-muted)]"> </div>
This automation could not be found. )
</p>
<Link
to="/agents/automations"
className="mt-3 inline-block text-sm text-[var(--ui-accent)] hover:underline"
>
Back to Automations
</Link>
</div>
)}
</AgentsShell>
);
} }

View file

@ -1,20 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router"
import { AutomationsList } from "@/components/agents/AutomationsList"; import { AutomationsList } from "@/components/agents/AutomationsList"
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/")({ export const Route = createFileRoute("/agents/automations/")({
component: AutomationsIndexPage, component: AutomationsIndexPage,
}); })
function AutomationsIndexPage() { function AutomationsIndexPage() {
const session = useSession(); return <AutomationsList />
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationsList />
</AgentsShell>
);
} }

View file

@ -1,20 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router"
import { AutomationEditor } from "@/components/agents/AutomationEditor"; import { AutomationEditor } from "@/components/agents/AutomationEditor"
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/new")({ export const Route = createFileRoute("/agents/automations/new")({
component: NewAutomationPage, component: NewAutomationPage,
}); })
function NewAutomationPage() { function NewAutomationPage() {
const session = useSession(); return <AutomationEditor mode="create" />
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationEditor mode="create" />
</AgentsShell>
);
} }

View file

@ -1,20 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router"
import { AgentsHome } from "@/components/agents/AgentsHome"; import { AgentsHome } from "@/components/agents/AgentsHome"
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/")({ export const Route = createFileRoute("/agents/")({
component: AgentsIndexPage, component: AgentsIndexPage,
}); })
function AgentsIndexPage() { function AgentsIndexPage() {
const session = useSession(); return <AgentsHome />
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AgentsHome />
</AgentsShell>
);
} }