From 39a26e16b57efd94cbe2c9f87ea2668e12ca921a Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 18 Jun 2026 10:09:22 -0700 Subject: [PATCH] fix: optimize agent thread lists (#1570) * fix: optimize agent thread lists Co-authored-by: open-swe[bot] * fix: refresh missing thread run status Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- agent/dashboard/routes.py | 19 + agent/dashboard/thread_api.py | 405 +++++++++++++----- tests/test_dashboard_thread_api.py | 133 +++++- ui/src/components/agents/AgentsHome.tsx | 14 +- ui/src/components/agents/AgentsSidebar.tsx | 28 +- .../components/agents/AgentsThreadsPage.tsx | 16 +- ui/src/lib/agents/api.ts | 37 +- ui/src/lib/agents/queries.ts | 91 ++-- ui/src/routes/agents.tsx | 5 - 9 files changed, 542 insertions(+), 206 deletions(-) diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index c9717c6e..1ee9366f 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -133,6 +133,7 @@ from .thread_api import ( get_dashboard_thread_state, list_dashboard_threads, list_dashboard_threads_page, + list_dashboard_threads_sidebar, proxy_dashboard_thread_commands, proxy_dashboard_thread_history, 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) +@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") async def api_list_threads_page( limit: int = 25, diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index d8945703..793d87de 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import base64 import binascii import json @@ -434,58 +435,71 @@ async def _refresh_latest_run_metadata( return thread, latest_run_status, latest_run_id -async def list_dashboard_threads( - login: str, *, email: str | None = None, limit: int = 50, include_all: bool = False +_THREADS_SEARCH_PAGE = 500 +_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]]: - client = langgraph_client() - searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}] - if not include_all and email and email.strip(): + if include_all: + return [{}] + searches = [{"github_login": login}] + if email and email.strip(): searches.append({"triggering_user_email": email.strip().lower()}) - - 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] + return searches -# Threads are paged out of `client.threads.search` in batches of this size, -# scanning up to `_THREADS_PAGE_SCAN_CAP` so matches older than a single batch -# are still found (the page is the "show all"/search surface). -_THREADS_SEARCH_PAGE = 100 -_THREADS_PAGE_SCAN_CAP = 2000 +def _search_metadata_filter( + owner_filter: dict[str, Any], *, resolved: bool | None = None, source: str | None = None +) -> dict[str, Any]: + metadata = dict(owner_filter) + 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") - 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( @@ -532,50 +546,210 @@ def _summary_matches_filters( 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, searches: list[dict[str, Any]], *, include_all: bool, login: str, 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]]: - """Page through `threads.search` (up to the scan cap) and dedupe by id.""" seen: dict[str, dict[str, Any]] = {} - for metadata_filter in searches: + for owner_filter in searches: + matched_for_search = 0 offset = 0 + metadata_filter = _search_metadata_filter(owner_filter, resolved=resolved, source=source) while offset < _THREADS_PAGE_SCAN_CAP: - batch = await client.threads.search( - metadata=metadata_filter, + batch = await _search_threads_batch( + client, + metadata_filter, limit=_THREADS_SEARCH_PAGE, offset=offset, - sort_by="updated_at", - sort_order="desc", ) if not batch: break 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 - meta = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} - if not include_all and not _user_owns_thread(meta, login, email): + if not _metadata_matches_filters( + metadata, + resolved=resolved, + source=source, + query=query, + ): 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 + thread_id = _thread_id(thread) + if not thread_id: + 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: break offset += _THREADS_SEARCH_PAGE - return list(seen.values()) - -async def _summarize_thread(client: Any, thread: dict[str, Any]) -> dict[str, Any]: - refreshed, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread) - return _thread_summary( - refreshed, - latest_run_status=latest_run_status, - latest_run_id=latest_run_id, + active_candidates = sorted(active.values(), key=_thread_updated_ms, reverse=True) + resolved_candidates = sorted(resolved_threads.values(), key=_thread_updated_ms, reverse=True) + active_window = active_candidates[:safe_active_limit] + resolved_window = resolved_candidates[:safe_resolved_limit] + active_items, resolved_items = await asyncio.gather( + _summarize_threads( + 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( @@ -591,63 +765,58 @@ async def list_dashboard_threads_page( status: str | None = None, query: str | None = None, ) -> dict[str, Any]: - """Paginated + filterable thread list for the full threads page.""" client = langgraph_client() - searches: list[dict[str, Any]] = [{}] if include_all else [{"github_login": login}] - 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, - ) - + searches = _owner_search_filters(login, email=email, include_all=include_all) 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 - # after enrichment. When neither is requested we enrich just the page window. - if viewed is None and status is None: - total = len(matched) - window = matched[safe_offset : safe_offset + safe_limit] - items = [await _summarize_thread(client, thread) for thread in window] - return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset} + candidates = await _collect_thread_candidates( + client, + searches, + include_all=include_all, + login=login, + email=email, + resolved=resolved, + source=source, + query=query, + target_per_search=target, + ) - summaries: list[dict[str, Any]] = [] - for thread in matched: - summary = await _summarize_thread(client, thread) - if _summary_matches_filters( - summary, - resolved=resolved, - viewed=viewed, - source=source, - status=status, - query=query, - ): - summaries.append(summary) + if summary_filters: + summaries = await _summarize_threads( + client, + candidates, + owner_login=None if include_all else login, + owner_email=None if include_all else email, + ) + filtered = [ + summary + for summary in summaries + if _summary_matches_filters( + 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) - total = len(summaries) - items = summaries[safe_offset : safe_offset + safe_limit] - return {"items": items, "total": total, "limit": safe_limit, "offset": safe_offset} + return {"items": items, "limit": safe_limit, "offset": safe_offset, "hasMore": has_more} async def _mark_thread_viewed( diff --git a/tests/test_dashboard_thread_api.py b/tests/test_dashboard_thread_api.py index a16b8f3e..34cc1820 100644 --- a/tests/test_dashboard_thread_api.py +++ b/tests/test_dashboard_thread_api.py @@ -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: - # The 100 most-recent threads are resolved; the unresolved ones only appear - # in the second search batch (offset >= 100). - threads = _make_threads(150, resolved_before=100) + page_size = thread_api._THREADS_SEARCH_PAGE + threads = _make_threads(page_size + 50, resolved_before=page_size) + for thread in threads: + thread["metadata"]["latest_run_status"] = "success" offsets: list[int] = [] + run_list_calls = 0 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) + 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] 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()) - result = await thread_api.list_dashboard_threads_page( - "octocat", email=None, limit=25, offset=0, resolved=False + result = await thread_api.list_dashboard_threads_sidebar( + "octocat", email=None, active_limit=5, resolved_limit=5 ) - assert result["total"] == 50 - assert len(result["items"]) == 25 - assert all(item["resolved"] is False for item in result["items"]) - assert 100 in offsets + assert len(result["active"]["items"]) == 5 + assert len(result["resolved"]["items"]) == 5 + assert result["active"]["hasMore"] is True + 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"} diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index 9e84ef4a..9339e2e4 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -3,7 +3,7 @@ import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { useQueryClient } from "@tanstack/react-query" 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 { ModelSelection } from "@/lib/agents/provider/useModelOptions" import { AgentPromptBar } from "@/components/agents/AgentPromptBar" @@ -13,6 +13,7 @@ import { agentThreadKeys, invalidateAgentThreadLists, optimisticThread, + seedAgentThreadLists, } from "@/lib/agents/queries" import { useModelOptions } from "@/lib/agents/provider/useModelOptions" import { useProfile, useRepos } from "@/lib/profile" @@ -67,12 +68,7 @@ export function AgentsHome() { draftRef.current = null const thread = optimisticThread(id, draft) queryClient.setQueryData(agentThreadKeys.detail(id), thread) - // Surface the thread in the sidebar immediately; the list's running - // refetch reconciles to server truth once the run.start stamps it. - queryClient.setQueryData>(agentThreadKeys.all, (prev) => [ - thread, - ...(prev?.filter((existing) => existing.id !== id) ?? []), - ]) + seedAgentThreadLists(queryClient, thread) invalidateAgentThreadLists(queryClient) void navigate({ to: "/agents/$threadId", params: { threadId: id } }) }, [stream.threadId, queryClient, navigate]) @@ -101,7 +97,9 @@ export function AgentsHome() { stream .submit( - { messages: [{ type: "human", content: promptContent(prompt, images) }] }, + { + messages: [{ type: "human", content: promptContent(prompt, images) }], + }, { config: { configurable } } ) .catch(() => { diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index dad1f38e..9da0504f 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -44,6 +44,7 @@ import { useSeedAgentThreadDetails, useSidebarThreads, } from "@/lib/agents/queries" +import { useRunCompletionNotifier } from "@/lib/agents/useRunCompletionNotifier" import { cn } from "@/lib/utils" const RESOLVED_SIDEBAR_LIMIT = 20 @@ -98,14 +99,13 @@ const NAV = [ ] as const export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { - const { active, resolved } = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) - const activeThreads = active.data?.items ?? [] - const resolvedThreads = resolved.data?.items ?? [] - const resolvedTotal = resolved.data?.total ?? resolvedThreads.length - useSeedAgentThreadDetails( - [...activeThreads, ...resolvedThreads], - activeThreadId - ) + const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) + const activeThreads = sidebar.data?.active.items ?? [] + const resolvedThreads = sidebar.data?.resolved.items ?? [] + const resolvedHasMore = sidebar.data?.resolved.hasMore ?? false + const visibleThreads = [...activeThreads, ...resolvedThreads] + useSeedAgentThreadDetails(visibleThreads, activeThreadId) + useRunCompletionNotifier(visibleThreads, activeThreadId) const groups = groupThreads(activeThreads) const layout = useSidebarLayout() const reviewSidebar = useReviewSidebarData() @@ -190,7 +190,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { /> @@ -249,12 +249,12 @@ function ThreadGroup({ function ResolvedThreadGroup({ threads, - total, + hasMore, activeThreadId, onNavigate, }: { threads: Array - total: number + hasMore: boolean activeThreadId?: string onNavigate?: () => void }) { @@ -263,7 +263,6 @@ function ResolvedThreadGroup({ const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon const visible = threads.slice(0, RESOLVED_SIDEBAR_LIMIT) - const hasMore = total > visible.length return (
@@ -275,7 +274,10 @@ function ResolvedThreadGroup({ > Resolved - {total} + + {threads.length} + {hasMore ? "+" : ""} + {!collapsed && ( <> diff --git a/ui/src/components/agents/AgentsThreadsPage.tsx b/ui/src/components/agents/AgentsThreadsPage.tsx index dae100b1..8021de16 100644 --- a/ui/src/components/agents/AgentsThreadsPage.tsx +++ b/ui/src/components/agents/AgentsThreadsPage.tsx @@ -82,8 +82,9 @@ export function AgentsThreadsPage({ const data = query.data const items = data?.items ?? [] - const total = data?.total ?? 0 - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const hasMore = data?.hasMore ?? false + const exactTotal = data?.total + const end = offset + items.length const update = (patch: Partial) => { onFiltersChange({ ...filters, ...patch, page: patch.page ?? 1 }) @@ -167,10 +168,11 @@ export function AgentsThreadsPage({ )}
- {total > 0 && ( + {(items.length > 0 || filters.page > 1) && (
- {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} + {items.length > 0 ? `${offset + 1}–${end}` : "No results"} + {exactTotal != null ? ` of ${exactTotal}` : hasMore ? "+" : ""}
- - Page {filters.page} / {totalPages} - + Page {filters.page}