fix: optimize agent thread lists (#1570)

* fix: optimize agent thread lists

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

* fix: refresh missing thread run status

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

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-18 10:09:22 -07:00 • committed by GitHub
parent 4030001ebf
commit 39a26e16b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 542 additions and 206 deletions

View file

@ -133,6 +133,7 @@ from .thread_api import (
get_dashboard_thread_state, get_dashboard_thread_state,
list_dashboard_threads, list_dashboard_threads,
list_dashboard_threads_page, list_dashboard_threads_page,
list_dashboard_threads_sidebar,
proxy_dashboard_thread_commands, proxy_dashboard_thread_commands,
proxy_dashboard_thread_history, proxy_dashboard_thread_history,
proxy_dashboard_thread_run_cancel, proxy_dashboard_thread_run_cancel,
@ -1183,6 +1184,24 @@ async def api_list_threads(
return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all) return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all)
@router.get("/threads/sidebar")
async def api_list_threads_sidebar(
active_limit: int = 50,
resolved_limit: int = 20,
all: bool = False,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
if all and not is_admin(session.get("email")):
raise HTTPException(403, "admin only")
return await list_dashboard_threads_sidebar(
session["sub"],
email=session.get("email"),
active_limit=active_limit,
resolved_limit=resolved_limit,
include_all=all,
)
@router.get("/threads/page") @router.get("/threads/page")
async def api_list_threads_page( async def api_list_threads_page(
limit: int = 25, limit: int = 25,

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import binascii import binascii
import json import json
@ -434,58 +435,71 @@ async def _refresh_latest_run_metadata(
return thread, latest_run_status, latest_run_id return thread, latest_run_status, latest_run_id
async def list_dashboard_threads( _THREADS_SEARCH_PAGE = 500
login: str, *, email: str | None = None, limit: int = 50, include_all: bool = False _THREADS_PAGE_SCAN_CAP = 5000
_THREAD_LIST_SELECT = ["thread_id", "status", "metadata", "updated_at"]
_RUN_REFRESH_CONCURRENCY = 8
_RUNNING_METADATA_STATUSES = {"pending", "running"}
def _thread_id(thread: dict[str, Any]) -> str | None:
thread_id = thread.get("thread_id") or thread.get("id")
return thread_id if isinstance(thread_id, str) and thread_id else None
def _thread_metadata(thread: dict[str, Any]) -> dict[str, Any]:
return thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
def _owner_search_filters(
login: str, *, email: str | None = None, include_all: bool = False
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
client = langgraph_client() if include_all:
searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}] return [{}]
if not include_all and email and email.strip(): searches = [{"github_login": login}]
if email and email.strip():
searches.append({"triggering_user_email": email.strip().lower()}) searches.append({"triggering_user_email": email.strip().lower()})
return searches
seen: dict[str, dict[str, Any]] = {}
for metadata_filter in searches:
threads = await client.threads.search(
metadata=metadata_filter,
limit=limit,
sort_by="updated_at",
sort_order="desc",
)
for thread in threads or []:
if not isinstance(thread, dict):
continue
meta = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
if not include_all and not _user_owns_thread(meta, login, email):
continue
thread_id = thread.get("thread_id") or thread.get("id")
if isinstance(thread_id, str) and thread_id not in seen:
seen[thread_id] = thread
summaries: list[dict[str, Any]] = []
for thread in seen.values():
refreshed, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(
client, thread
)
summaries.append(
_thread_summary(
refreshed,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
)
)
summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
return summaries[:limit]
# Threads are paged out of `client.threads.search` in batches of this size, def _search_metadata_filter(
# scanning up to `_THREADS_PAGE_SCAN_CAP` so matches older than a single batch owner_filter: dict[str, Any], *, resolved: bool | None = None, source: str | None = None
# are still found (the page is the "show all"/search surface). ) -> dict[str, Any]:
_THREADS_SEARCH_PAGE = 100 metadata = dict(owner_filter)
_THREADS_PAGE_SCAN_CAP = 2000 if resolved is True:
metadata["resolved"] = True
if source and source != _DASHBOARD_SOURCE:
metadata["source"] = source
return metadata
def _thread_updated_ms(metadata: dict[str, Any]) -> int: async def _search_threads_batch(
client: Any, metadata: dict[str, Any], *, limit: int, offset: int
) -> list[dict[str, Any]]:
batch = await client.threads.search(
metadata=metadata,
limit=limit,
offset=offset,
sort_by="updated_at",
sort_order="desc",
select=_THREAD_LIST_SELECT,
)
return [thread for thread in batch or [] if isinstance(thread, dict)]
def _thread_updated_ms(thread: dict[str, Any]) -> int:
metadata = _thread_metadata(thread)
value = metadata.get("updated_at_ms") value = metadata.get("updated_at_ms")
return int(value) if isinstance(value, (int, float)) else 0 if isinstance(value, (int, float)):
return int(value)
updated_at = thread.get("updated_at")
if isinstance(updated_at, str) and updated_at:
try:
parsed = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
except ValueError:
return 0
return int(parsed.timestamp() * 1000)
return 0
def _metadata_matches_filters( def _metadata_matches_filters(
@ -532,50 +546,210 @@ def _summary_matches_filters(
return True return True
async def _gather_candidate_threads( def _should_refresh_latest_run(thread: dict[str, Any]) -> bool:
metadata = _thread_metadata(thread)
metadata_status = metadata.get("latest_run_status")
thread_status = thread.get("status")
return (
thread_status == "busy"
or metadata_status in _RUNNING_METADATA_STATUSES
or not isinstance(metadata_status, str)
)
async def _summarize_thread(
client: Any,
thread: dict[str, Any],
*,
owner_login: str | None = None,
owner_email: str | None = None,
refresh_active_run: bool = True,
) -> dict[str, Any]:
latest_run_status = latest_run_id = None
if refresh_active_run and _should_refresh_latest_run(thread):
thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(
client, thread
)
return _thread_summary(
thread,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
owner_login=owner_login,
owner_email=owner_email,
)
async def _summarize_threads(
client: Any,
threads: list[dict[str, Any]],
*,
owner_login: str | None = None,
owner_email: str | None = None,
) -> list[dict[str, Any]]:
semaphore = asyncio.Semaphore(_RUN_REFRESH_CONCURRENCY)
async def summarize(thread: dict[str, Any]) -> dict[str, Any]:
if not _should_refresh_latest_run(thread):
return await _summarize_thread(
client,
thread,
owner_login=owner_login,
owner_email=owner_email,
refresh_active_run=False,
)
async with semaphore:
return await _summarize_thread(
client,
thread,
owner_login=owner_login,
owner_email=owner_email,
)
return list(await asyncio.gather(*(summarize(thread) for thread in threads)))
async def _collect_thread_candidates(
client: Any, client: Any,
searches: list[dict[str, Any]], searches: list[dict[str, Any]],
*, *,
include_all: bool, include_all: bool,
login: str, login: str,
email: str | None, email: str | None,
resolved: bool | None = None,
source: str | None = None,
query: str | None = None,
target_per_search: int | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Page through `threads.search` (up to the scan cap) and dedupe by id."""
seen: dict[str, dict[str, Any]] = {} seen: dict[str, dict[str, Any]] = {}
for metadata_filter in searches: for owner_filter in searches:
matched_for_search = 0
offset = 0 offset = 0
metadata_filter = _search_metadata_filter(owner_filter, resolved=resolved, source=source)
while offset < _THREADS_PAGE_SCAN_CAP: while offset < _THREADS_PAGE_SCAN_CAP:
batch = await client.threads.search( batch = await _search_threads_batch(
metadata=metadata_filter, client,
metadata_filter,
limit=_THREADS_SEARCH_PAGE, limit=_THREADS_SEARCH_PAGE,
offset=offset, offset=offset,
sort_by="updated_at",
sort_order="desc",
) )
if not batch: if not batch:
break break
for thread in batch: for thread in batch:
if not isinstance(thread, dict): metadata = _thread_metadata(thread)
if not include_all and not _user_owns_thread(metadata, login, email):
continue continue
meta = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} if not _metadata_matches_filters(
if not include_all and not _user_owns_thread(meta, login, email): metadata,
resolved=resolved,
source=source,
query=query,
):
continue continue
thread_id = thread.get("thread_id") or thread.get("id") thread_id = _thread_id(thread)
if isinstance(thread_id, str) and thread_id not in seen: if not thread_id:
seen[thread_id] = thread continue
matched_for_search += 1
seen.setdefault(thread_id, thread)
if len(batch) < _THREADS_SEARCH_PAGE:
break
if target_per_search is not None and matched_for_search >= target_per_search:
break
offset += _THREADS_SEARCH_PAGE
return sorted(seen.values(), key=_thread_updated_ms, reverse=True)
async def list_dashboard_threads(
login: str, *, email: str | None = None, limit: int = 50, include_all: bool = False
) -> list[dict[str, Any]]:
page = await list_dashboard_threads_page(
login,
email=email,
limit=limit,
offset=0,
include_all=include_all,
)
return page["items"]
async def list_dashboard_threads_sidebar(
login: str,
*,
email: str | None = None,
active_limit: int = 50,
resolved_limit: int = 20,
include_all: bool = False,
) -> dict[str, Any]:
client = langgraph_client()
searches = _owner_search_filters(login, email=email, include_all=include_all)
safe_active_limit = min(max(active_limit, 1), 100)
safe_resolved_limit = min(max(resolved_limit, 1), 100)
active_target = safe_active_limit + 1
resolved_target = safe_resolved_limit + 1
active: dict[str, dict[str, Any]] = {}
resolved_threads: dict[str, dict[str, Any]] = {}
for owner_filter in searches:
local_active = 0
local_resolved = 0
offset = 0
while offset < _THREADS_PAGE_SCAN_CAP and (
local_active < active_target or local_resolved < resolved_target
):
batch = await _search_threads_batch(
client,
owner_filter,
limit=_THREADS_SEARCH_PAGE,
offset=offset,
)
if not batch:
break
for thread in batch:
metadata = _thread_metadata(thread)
if not include_all and not _user_owns_thread(metadata, login, email):
continue
thread_id = _thread_id(thread)
if not thread_id or thread_id in active or thread_id in resolved_threads:
continue
if _is_thread_resolved(metadata):
local_resolved += 1
resolved_threads[thread_id] = thread
else:
local_active += 1
active[thread_id] = thread
if len(batch) < _THREADS_SEARCH_PAGE: if len(batch) < _THREADS_SEARCH_PAGE:
break break
offset += _THREADS_SEARCH_PAGE offset += _THREADS_SEARCH_PAGE
return list(seen.values())
active_candidates = sorted(active.values(), key=_thread_updated_ms, reverse=True)
async def _summarize_thread(client: Any, thread: dict[str, Any]) -> dict[str, Any]: resolved_candidates = sorted(resolved_threads.values(), key=_thread_updated_ms, reverse=True)
refreshed, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread) active_window = active_candidates[:safe_active_limit]
return _thread_summary( resolved_window = resolved_candidates[:safe_resolved_limit]
refreshed, active_items, resolved_items = await asyncio.gather(
latest_run_status=latest_run_status, _summarize_threads(
latest_run_id=latest_run_id, client,
active_window,
owner_login=None if include_all else login,
owner_email=None if include_all else email,
),
_summarize_threads(
client,
resolved_window,
owner_login=None if include_all else login,
owner_email=None if include_all else email,
),
) )
return {
"active": {
"items": active_items,
"limit": safe_active_limit,
"hasMore": len(active_candidates) > safe_active_limit,
},
"resolved": {
"items": resolved_items,
"limit": safe_resolved_limit,
"hasMore": len(resolved_candidates) > safe_resolved_limit,
},
}
async def list_dashboard_threads_page( async def list_dashboard_threads_page(
@ -591,63 +765,58 @@ async def list_dashboard_threads_page(
status: str | None = None, status: str | None = None,
query: str | None = None, query: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Paginated + filterable thread list for the full threads page."""
client = langgraph_client() client = langgraph_client()
searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}] searches = _owner_search_filters(login, email=email, include_all=include_all)
if not include_all and email and email.strip():
searches.append({"triggering_user_email": email.strip().lower()})
candidates = await _gather_candidate_threads(
client, searches, include_all=include_all, login=login, email=email
)
# Apply metadata-only filters first so the frequent sidebar polls only fetch
# the latest run for threads that can actually match.
matched = [
thread
for thread in candidates
if _metadata_matches_filters(
thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {},
resolved=resolved,
source=source,
query=query,
)
]
matched.sort(
key=lambda thread: _thread_updated_ms(
thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
),
reverse=True,
)
safe_offset = max(offset, 0) safe_offset = max(offset, 0)
safe_limit = max(limit, 1) safe_limit = min(max(limit, 1), 100)
summary_filters = viewed is not None or status is not None
target = None if summary_filters else safe_offset + safe_limit + 1
# `viewed`/`status` derive from the latest run, so they can only be applied candidates = await _collect_thread_candidates(
# after enrichment. When neither is requested we enrich just the page window. client,
if viewed is None and status is None: searches,
total = len(matched) include_all=include_all,
window = matched[safe_offset : safe_offset + safe_limit] login=login,
items = [await _summarize_thread(client, thread) for thread in window] email=email,
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset} resolved=resolved,
source=source,
query=query,
target_per_search=target,
)
summaries: list[dict[str, Any]] = [] if summary_filters:
for thread in matched: summaries = await _summarize_threads(
summary = await _summarize_thread(client, thread) client,
if _summary_matches_filters( candidates,
summary, owner_login=None if include_all else login,
resolved=resolved, owner_email=None if include_all else email,
viewed=viewed, )
source=source, filtered = [
status=status, summary
query=query, for summary in summaries
): if _summary_matches_filters(
summaries.append(summary) summary,
resolved=resolved,
viewed=viewed,
source=source,
status=status,
query=query,
)
]
filtered.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
items = filtered[safe_offset : safe_offset + safe_limit]
has_more = len(filtered) > safe_offset + safe_limit
else:
window = candidates[safe_offset : safe_offset + safe_limit]
items = await _summarize_threads(
client,
window,
owner_login=None if include_all else login,
owner_email=None if include_all else email,
)
has_more = len(candidates) > safe_offset + safe_limit
summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True) return {"items": items, "limit": safe_limit, "offset": safe_offset, "hasMore": has_more}
total = len(summaries)
items = summaries[safe_offset : safe_offset + safe_limit]
return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset}
async def _mark_thread_viewed( async def _mark_thread_viewed(

View file

@ -801,14 +801,54 @@ def _make_threads(count: int, *, resolved_before: int) -> list[dict[str, object]
async def test_list_dashboard_threads_page_pages_beyond_first_search_batch(monkeypatch) -> None: async def test_list_dashboard_threads_page_pages_beyond_first_search_batch(monkeypatch) -> None:
# The 100 most-recent threads are resolved; the unresolved ones only appear page_size = thread_api._THREADS_SEARCH_PAGE
# in the second search batch (offset >= 100). threads = _make_threads(page_size + 50, resolved_before=page_size)
threads = _make_threads(150, resolved_before=100) for thread in threads:
thread["metadata"]["latest_run_status"] = "success"
offsets: list[int] = [] offsets: list[int] = []
run_list_calls = 0
class FakeThreads: class FakeThreads:
async def search(self, *, metadata, limit, offset, sort_by, sort_order): async def search(self, *, metadata, limit, offset, sort_by, sort_order, select):
offsets.append(offset) offsets.append(offset)
assert select == thread_api._THREAD_LIST_SELECT
return threads[offset : offset + limit]
async def update(self, *, thread_id, metadata):
return None
class FakeRuns:
async def list(self, thread_id, limit=1):
nonlocal run_list_calls
run_list_calls += 1
return []
class FakeClient:
threads = FakeThreads()
runs = FakeRuns()
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
result = await thread_api.list_dashboard_threads_page(
"octocat", email=None, limit=25, offset=0, resolved=False
)
assert result["hasMore"] is True
assert len(result["items"]) == 25
assert all(item["resolved"] is False for item in result["items"])
assert page_size in offsets
assert run_list_calls == 0
async def test_list_dashboard_threads_sidebar_fills_buckets_with_one_endpoint(monkeypatch) -> None:
page_size = thread_api._THREADS_SEARCH_PAGE
threads = _make_threads(page_size + 10, resolved_before=page_size)
searches: list[dict[str, object]] = []
class FakeThreads:
async def search(self, *, metadata, limit, offset, sort_by, sort_order, select):
searches.append({"metadata": metadata, "offset": offset})
assert select == thread_api._THREAD_LIST_SELECT
return threads[offset : offset + limit] return threads[offset : offset + limit]
async def update(self, *, thread_id, metadata): async def update(self, *, thread_id, metadata):
@ -824,11 +864,84 @@ async def test_list_dashboard_threads_page_pages_beyond_first_search_batch(monke
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
result = await thread_api.list_dashboard_threads_page( result = await thread_api.list_dashboard_threads_sidebar(
"octocat", email=None, limit=25, offset=0, resolved=False "octocat", email=None, active_limit=5, resolved_limit=5
) )
assert result["total"] == 50 assert len(result["active"]["items"]) == 5
assert len(result["items"]) == 25 assert len(result["resolved"]["items"]) == 5
assert all(item["resolved"] is False for item in result["items"]) assert result["active"]["hasMore"] is True
assert 100 in offsets assert result["resolved"]["hasMore"] is True
assert {call["offset"] for call in searches} == {0, page_size}
async def test_list_dashboard_threads_page_refreshes_only_unsettled_threads(monkeypatch) -> None:
threads = _make_threads(3, resolved_before=0)
threads[0]["metadata"]["latest_run_status"] = "success"
threads[1]["metadata"]["latest_run_status"] = "pending"
threads[2]["metadata"]["latest_run_status"] = "error"
run_list_thread_ids: list[str] = []
updates: list[dict[str, object]] = []
class FakeThreads:
async def search(self, *, metadata, limit, offset, sort_by, sort_order, select):
return threads[offset : offset + limit]
async def update(self, *, thread_id, metadata):
updates.append({"thread_id": thread_id, "metadata": metadata})
class FakeRuns:
async def list(self, thread_id, limit=1):
run_list_thread_ids.append(thread_id)
return [{"id": "run-1", "status": "success"}]
class FakeClient:
threads = FakeThreads()
runs = FakeRuns()
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
result = await thread_api.list_dashboard_threads_page("octocat", email=None, limit=3, offset=0)
assert run_list_thread_ids == ["t1"]
assert updates == [
{
"thread_id": "t1",
"metadata": {"latest_run_status": "success", "latest_run_id": "run-1"},
}
]
assert [item["status"] for item in result["items"]] == ["finished", "finished", "error"]
async def test_status_filter_refreshes_threads_missing_run_status(monkeypatch) -> None:
threads = _make_threads(2, resolved_before=0)
for thread in threads:
thread["metadata"]["source"] = "slack"
run_statuses = {"t0": "success", "t1": "error"}
run_list_thread_ids: list[str] = []
class FakeThreads:
async def search(self, *, metadata, limit, offset, sort_by, sort_order, select):
return threads[offset : offset + limit]
async def update(self, *, thread_id, metadata):
return None
class FakeRuns:
async def list(self, thread_id, limit=1):
run_list_thread_ids.append(thread_id)
return [{"id": f"run-{thread_id}", "status": run_statuses[thread_id]}]
class FakeClient:
threads = FakeThreads()
runs = FakeRuns()
monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient())
result = await thread_api.list_dashboard_threads_page(
"octocat", email=None, limit=25, offset=0, status="finished"
)
assert {item["id"] for item in result["items"]} == {"t0"}
assert result["items"][0]["status"] == "finished"
assert set(run_list_thread_ids) == {"t0", "t1"}

View file

@ -3,7 +3,7 @@ import { useStreamContext as useAgentThreadStream } from "@langchain/react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import type { AgentThread, ImageChunk } from "@/lib/agents/types" import type { ImageChunk } from "@/lib/agents/types"
import type { CreateAgentThreadVariables } from "@/lib/agents/queries" import type { CreateAgentThreadVariables } from "@/lib/agents/queries"
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar" import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
@ -13,6 +13,7 @@ import {
agentThreadKeys, agentThreadKeys,
invalidateAgentThreadLists, invalidateAgentThreadLists,
optimisticThread, optimisticThread,
seedAgentThreadLists,
} from "@/lib/agents/queries" } from "@/lib/agents/queries"
import { useModelOptions } from "@/lib/agents/provider/useModelOptions" import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
import { useProfile, useRepos } from "@/lib/profile" import { useProfile, useRepos } from "@/lib/profile"
@ -67,12 +68,7 @@ export function AgentsHome() {
draftRef.current = null draftRef.current = null
const thread = optimisticThread(id, draft) const thread = optimisticThread(id, draft)
queryClient.setQueryData(agentThreadKeys.detail(id), thread) queryClient.setQueryData(agentThreadKeys.detail(id), thread)
// Surface the thread in the sidebar immediately; the list's running seedAgentThreadLists(queryClient, thread)
// refetch reconciles to server truth once the run.start stamps it.
queryClient.setQueryData<Array<AgentThread>>(agentThreadKeys.all, (prev) => [
thread,
...(prev?.filter((existing) => existing.id !== id) ?? []),
])
invalidateAgentThreadLists(queryClient) invalidateAgentThreadLists(queryClient)
void navigate({ to: "/agents/$threadId", params: { threadId: id } }) void navigate({ to: "/agents/$threadId", params: { threadId: id } })
}, [stream.threadId, queryClient, navigate]) }, [stream.threadId, queryClient, navigate])
@ -101,7 +97,9 @@ export function AgentsHome() {
stream stream
.submit( .submit(
{ messages: [{ type: "human", content: promptContent(prompt, images) }] }, {
messages: [{ type: "human", content: promptContent(prompt, images) }],
},
{ config: { configurable } } { config: { configurable } }
) )
.catch(() => { .catch(() => {

View file

@ -44,6 +44,7 @@ import {
useSeedAgentThreadDetails, useSeedAgentThreadDetails,
useSidebarThreads, useSidebarThreads,
} from "@/lib/agents/queries" } from "@/lib/agents/queries"
import { useRunCompletionNotifier } from "@/lib/agents/useRunCompletionNotifier"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const RESOLVED_SIDEBAR_LIMIT = 20 const RESOLVED_SIDEBAR_LIMIT = 20
@ -98,14 +99,13 @@ const NAV = [
] as const ] as const
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
const { active, resolved } = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
const activeThreads = active.data?.items ?? [] const activeThreads = sidebar.data?.active.items ?? []
const resolvedThreads = resolved.data?.items ?? [] const resolvedThreads = sidebar.data?.resolved.items ?? []
const resolvedTotal = resolved.data?.total ?? resolvedThreads.length const resolvedHasMore = sidebar.data?.resolved.hasMore ?? false
useSeedAgentThreadDetails( const visibleThreads = [...activeThreads, ...resolvedThreads]
[...activeThreads, ...resolvedThreads], useSeedAgentThreadDetails(visibleThreads, activeThreadId)
activeThreadId useRunCompletionNotifier(visibleThreads, activeThreadId)
)
const groups = groupThreads(activeThreads) const groups = groupThreads(activeThreads)
const layout = useSidebarLayout() const layout = useSidebarLayout()
const reviewSidebar = useReviewSidebarData() const reviewSidebar = useReviewSidebarData()
@ -190,7 +190,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
/> />
<ResolvedThreadGroup <ResolvedThreadGroup
threads={resolvedThreads} threads={resolvedThreads}
total={resolvedTotal} hasMore={resolvedHasMore}
activeThreadId={activeThreadId} activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile} onNavigate={layout.closeOnMobile}
/> />
@ -249,12 +249,12 @@ function ThreadGroup({
function ResolvedThreadGroup({ function ResolvedThreadGroup({
threads, threads,
total, hasMore,
activeThreadId, activeThreadId,
onNavigate, onNavigate,
}: { }: {
threads: Array<AgentThread> threads: Array<AgentThread>
total: number hasMore: boolean
activeThreadId?: string activeThreadId?: string
onNavigate?: () => void onNavigate?: () => void
}) { }) {
@ -263,7 +263,6 @@ function ResolvedThreadGroup({
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
const visible = threads.slice(0, RESOLVED_SIDEBAR_LIMIT) const visible = threads.slice(0, RESOLVED_SIDEBAR_LIMIT)
const hasMore = total > visible.length
return ( return (
<div className="mb-3"> <div className="mb-3">
@ -275,7 +274,10 @@ function ResolvedThreadGroup({
> >
<ToggleIcon className="size-3" /> <ToggleIcon className="size-3" />
<span className="min-w-0 flex-1 truncate">Resolved</span> <span className="min-w-0 flex-1 truncate">Resolved</span>
<span>{total}</span> <span>
{threads.length}
{hasMore ? "+" : ""}
</span>
</button> </button>
{!collapsed && ( {!collapsed && (
<> <>

View file

@ -82,8 +82,9 @@ export function AgentsThreadsPage({
const data = query.data const data = query.data
const items = data?.items ?? [] const items = data?.items ?? []
const total = data?.total ?? 0 const hasMore = data?.hasMore ?? false
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const exactTotal = data?.total
const end = offset + items.length
const update = (patch: Partial<ThreadsPageFilters>) => { const update = (patch: Partial<ThreadsPageFilters>) => {
onFiltersChange({ ...filters, ...patch, page: patch.page ?? 1 }) onFiltersChange({ ...filters, ...patch, page: patch.page ?? 1 })
@ -167,10 +168,11 @@ export function AgentsThreadsPage({
)} )}
</div> </div>
{total > 0 && ( {(items.length > 0 || filters.page > 1) && (
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-[var(--ui-text-muted)]"> <div className="mt-auto flex items-center justify-between pt-2 text-xs text-[var(--ui-text-muted)]">
<span> <span>
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} {items.length > 0 ? `${offset + 1}–${end}` : "No results"}
{exactTotal != null ? ` of ${exactTotal}` : hasMore ? "+" : ""}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@ -182,13 +184,11 @@ export function AgentsThreadsPage({
<CaretLeftIcon className="size-3" /> <CaretLeftIcon className="size-3" />
Prev Prev
</Button> </Button>
<span> <span>Page {filters.page}</span>
Page {filters.page} / {totalPages}
</span>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={filters.page >= totalPages} disabled={!hasMore}
onClick={() => update({ page: filters.page + 1 })} onClick={() => update({ page: filters.page + 1 })}
> >
Next Next

View file

@ -69,9 +69,21 @@ export interface ThreadsPageParams {
export interface ThreadsPage { export interface ThreadsPage {
items: Array<AgentThread> items: Array<AgentThread>
total: number total?: number
limit: number limit: number
offset: number offset: number
hasMore?: boolean
}
export interface SidebarThreadsGroup {
items: Array<AgentThread>
limit: number
hasMore: boolean
}
export interface SidebarThreads {
active: SidebarThreadsGroup
resolved: SidebarThreadsGroup
} }
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace( const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
@ -125,9 +137,30 @@ function buildThreadsPageQuery(params: ThreadsPageParams): string {
return query ? `?${query}` : "" return query ? `?${query}` : ""
} }
function buildSidebarThreadsQuery(params: {
activeLimit?: number
resolvedLimit?: number
}): string {
const search = new URLSearchParams()
if (params.activeLimit != null) {
search.set("active_limit", String(params.activeLimit))
}
if (params.resolvedLimit != null) {
search.set("resolved_limit", String(params.resolvedLimit))
}
const query = search.toString()
return query ? `?${query}` : ""
}
export const agentsApi = { export const agentsApi = {
langGraphApiUrl: agentsLangGraphApiUrl, langGraphApiUrl: agentsLangGraphApiUrl,
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"), listSidebarThreads: (params: {
activeLimit?: number
resolvedLimit?: number
}) =>
agentsRequest<SidebarThreads>(
`/threads/sidebar${buildSidebarThreadsQuery(params)}`
),
listThreadsPage: (params: ThreadsPageParams = {}) => listThreadsPage: (params: ThreadsPageParams = {}) =>
agentsRequest<ThreadsPage>(`/threads/page${buildThreadsPageQuery(params)}`), agentsRequest<ThreadsPage>(`/threads/page${buildThreadsPageQuery(params)}`),
resolveThread: (threadId: string, resolved: boolean) => resolveThread: (threadId: string, resolved: boolean) =>

View file

@ -4,12 +4,17 @@ import { useEffect } from "react"
import { agentsApi } from "./api" import { agentsApi } from "./api"
import type { QueryClient } from "@tanstack/react-query" import type { QueryClient } from "@tanstack/react-query"
import type { ScheduleUpdateRequest, ThreadsPageParams } from "./api" import type {
ScheduleUpdateRequest,
SidebarThreads,
ThreadsPageParams,
} from "./api"
import type { AgentThread, Chunk, ImageChunk, Message } from "./types" import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
export const agentThreadKeys = { export const agentThreadKeys = {
lists: ["agent-threads", "lists"] as const, lists: ["agent-threads", "lists"] as const,
all: ["agent-threads", "lists", "all"] as const, sidebar: (params: { activeLimit: number; resolvedLimit: number }) =>
["agent-threads", "lists", "sidebar", params] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const, detail: (threadId: string) => ["agent-threads", threadId] as const,
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const, prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
page: (params: ThreadsPageParams) => page: (params: ThreadsPageParams) =>
@ -20,14 +25,37 @@ export function invalidateAgentThreadLists(queryClient: QueryClient): void {
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists }) void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists })
} }
export function seedAgentThreadLists(
queryClient: QueryClient,
thread: AgentThread
): void {
queryClient.setQueriesData<SidebarThreads>(
{ queryKey: ["agent-threads", "lists", "sidebar"] },
(prev) => {
if (!prev) return prev
const activeItems = [
thread,
...prev.active.items.filter((item) => item.id !== thread.id),
].slice(0, prev.active.limit)
const resolvedItems = prev.resolved.items.filter(
(item) => item.id !== thread.id
)
return {
...prev,
active: { ...prev.active, items: activeItems },
resolved: { ...prev.resolved, items: resolvedItems },
}
}
)
}
export const agentScheduleKeys = { export const agentScheduleKeys = {
all: ["agent-schedules"] as const, all: ["agent-schedules"] as const,
} }
// The list endpoint (`GET /threads`) and the detail endpoint // Sidebar lists and detail reads return the same per-thread summary, so warming
// (`GET /threads/{id}`) return the same per-thread summary, so warming the // the detail cache from the already-fetched sidebar avoids a fan-out of one
// detail cache from the already-fetched list avoids a fan-out of one request // request per thread. Navigation stays instant; the real (mark-viewed) fetch
// 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 // fires only when a thread is actually opened. The active thread is skipped so
// its live detail query stays the source of truth. // its live detail query stays the source of truth.
export function useSeedAgentThreadDetails( export function useSeedAgentThreadDetails(
@ -49,52 +77,31 @@ export function useSeedAgentThreadDetails(
}, [activeThreadId, queryClient, threads]) }, [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 const SIDEBAR_ACTIVE_LIMIT = 50
function sidebarRefetchInterval(query: { function sidebarThreads(data?: SidebarThreads): Array<AgentThread> {
state: { data?: { items: Array<AgentThread> } } return [...(data?.active.items ?? []), ...(data?.resolved.items ?? [])]
}) { }
return query.state.data?.items.some((thread) => thread.status === "running")
function sidebarRefetchInterval(query: { state: { data?: SidebarThreads } }) {
return sidebarThreads(query.state.data).some(
(thread) => thread.status === "running"
)
? 2000 ? 2000
: false : false
} }
export function useSidebarThreads(resolvedLimit: number) { export function useSidebarThreads(resolvedLimit: number) {
const active = useQuery({ const params = {
queryKey: agentThreadKeys.page({ activeLimit: SIDEBAR_ACTIVE_LIMIT,
resolved: false, resolvedLimit,
limit: SIDEBAR_ACTIVE_LIMIT, }
}), return useQuery({
queryFn: () => queryKey: agentThreadKeys.sidebar(params),
agentsApi.listThreadsPage({ queryFn: () => agentsApi.listSidebarThreads(params),
resolved: false,
limit: SIDEBAR_ACTIVE_LIMIT,
}),
refetchInterval: sidebarRefetchInterval, refetchInterval: sidebarRefetchInterval,
placeholderData: (prev) => prev, 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) { export function useAgentThread(threadId: string) {

View file

@ -9,8 +9,6 @@ import { AgentsShell } from "@/components/agents/AgentsSidebar"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import agentsCss from "@/styles/agents.css?url" import agentsCss from "@/styles/agents.css?url"
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider" import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
import { useAgentThreads } from "@/lib/agents/queries"
import { useRunCompletionNotifier } from "@/lib/agents/useRunCompletionNotifier"
import { useSession } from "@/lib/session" import { useSession } from "@/lib/session"
export const Route = createFileRoute("/agents")({ export const Route = createFileRoute("/agents")({
@ -35,9 +33,6 @@ function AgentsLayout() {
? threadId ? threadId
: undefined : undefined
const threadsQuery = useAgentThreads()
useRunCompletionNotifier(threadsQuery.data, activeThreadId)
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">