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 { useQueryClient } from "@tanstack/react-query"
import type { SessionUser } from "@/lib/api"
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
import type { AgentThread, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { AgentsShell } from "@/components/agents/AgentsSidebar"
import { MessageView } from "@/components/agents/ported"
import {
agentThreadKeys,
@ -21,7 +19,6 @@ import { useAgentThreadStream } from "@/lib/agents/useThreadStream"
import { useModelOptions } from "@/lib/agents/useModelOptions"
interface AgentThreadViewProps {
user: SessionUser
thread: AgentThread
}
@ -58,7 +55,7 @@ function isPendingPromptConfirmed(
})
}
export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
export function AgentThreadView({ thread }: AgentThreadViewProps) {
const queryClient = useQueryClient()
const sendMessage = useSendAgentMessage(thread.id)
const cancelThread = useCancelAgentThread(thread.id)
@ -125,48 +122,19 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
const isStreaming = hasActiveRun || pendingPrompts.length > 0
return (
<AgentsShell user={user} activeThreadId={thread.id}>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<MessageView
messages={displayMessages}
isStreaming={isStreaming}
contentWidthClass="max-w-3xl"
/>
<div className="shrink-0 px-4 pb-4">
<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">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<MessageView
messages={displayMessages}
isStreaming={isStreaming}
contentWidthClass="max-w-3xl"
/>
<div className="shrink-0 px-4 pb-4">
<div className="mx-auto w-full max-w-3xl min-w-0">
<AgentPromptBar
placeholder="Send the first message"
placeholder="Add a follow up"
compact
busy={hasActiveRun}
disabled={sendMessage.isPending}
@ -186,9 +154,36 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
/>
</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>
</AgentsShell>
</div>
)
}

View file

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

View file

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { agentsApi } from "./api"
import { addPendingPrompt } from "./pendingPrompts"
@ -15,6 +16,29 @@ export const agentScheduleKeys = {
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() {
return useQuery({
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 agentsCss from "@/styles/agents.css?url";
import { useSession } from "@/lib/session";
import { AgentsShell } from "@/components/agents/AgentsSidebar"
import { Skeleton } from "@/components/ui/skeleton"
import agentsCss from "@/styles/agents.css?url"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/agents")({
head: () => ({
links: [{ rel: "stylesheet", href: agentsCss }],
}),
component: 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) {
return (
<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" />
</main>
);
)
}
if (!session.data) return <Navigate to="/login" />;
if (!session.data) return <Navigate to="/login" />
return (
<div className="agents-ui h-svh overflow-hidden">
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
<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 { Skeleton } from "@/components/ui/skeleton";
import { useAgentThread } from "@/lib/agents/queries";
import { useSession } from "@/lib/session";
import { AgentThreadView } from "@/components/agents/AgentThreadView"
import { Skeleton } from "@/components/ui/skeleton"
import { useAgentThread } from "@/lib/agents/queries"
export const Route = createFileRoute("/agents/$threadId")({
component: AgentThreadPage,
});
})
function AgentThreadPage() {
const { threadId } = Route.useParams();
const session = useSession();
const threadQuery = useAgentThread(threadId);
if (session.isLoading) return null;
if (!session.data) return <Navigate to="/login" />;
const { threadId } = Route.useParams()
const threadQuery = useAgentThread(threadId)
if (threadQuery.isLoading) {
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" />
</main>
);
)
}
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 { AgentsShell } from "@/components/agents/AgentsSidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgentSchedules } from "@/lib/agents/queries";
import { useSession } from "@/lib/session";
import { AutomationEditor } from "@/components/agents/AutomationEditor"
import { Skeleton } from "@/components/ui/skeleton"
import { useAgentSchedules } from "@/lib/agents/queries"
export const Route = createFileRoute("/agents/automations/$scheduleId")({
component: EditAutomationPage,
});
})
function EditAutomationPage() {
const session = useSession();
const { scheduleId } = Route.useParams();
const schedulesQuery = useAgentSchedules();
const { scheduleId } = Route.useParams()
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 (
<AgentsShell user={session.data}>
{schedulesQuery.isLoading ? (
<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>
) : schedule ? (
<AutomationEditor mode="edit" schedule={schedule} />
) : (
<div className="mx-auto w-full max-w-3xl px-6 py-16 text-center">
<p className="text-sm text-[var(--ui-text-muted)]">
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>
);
<div className="mx-auto w-full max-w-3xl px-6 py-16 text-center">
<p className="text-sm text-[var(--ui-text-muted)]">
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>
)
}

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 { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
import { AutomationsList } from "@/components/agents/AutomationsList"
export const Route = createFileRoute("/agents/automations/")({
component: AutomationsIndexPage,
});
})
function AutomationsIndexPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationsList />
</AgentsShell>
);
return <AutomationsList />
}

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 { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
import { AutomationEditor } from "@/components/agents/AutomationEditor"
export const Route = createFileRoute("/agents/automations/new")({
component: NewAutomationPage,
});
})
function NewAutomationPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationEditor mode="create" />
</AgentsShell>
);
return <AutomationEditor mode="create" />
}

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 { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
import { AgentsHome } from "@/components/agents/AgentsHome"
export const Route = createFileRoute("/agents/")({
component: AgentsIndexPage,
});
})
function AgentsIndexPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AgentsHome />
</AgentsShell>
);
return <AgentsHome />
}