diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 4369218d..68e89cb5 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -113,16 +113,19 @@ from .team_settings import ( ) from .thread_api import ( ThreadMessageBody, + ThreadResolveBody, cancel_dashboard_thread, delete_dashboard_thread, get_dashboard_thread, get_dashboard_thread_pr_diff, get_dashboard_thread_state, list_dashboard_threads, + list_dashboard_threads_page, proxy_dashboard_thread_commands, proxy_dashboard_thread_history, proxy_dashboard_thread_run_cancel, proxy_dashboard_thread_stream_events, + resolve_dashboard_thread, send_dashboard_message, stream_dashboard_thread, ) @@ -980,9 +983,39 @@ async def api_list_threads( all: bool = False, session: dict[str, Any] = _SESSION_DEP, ) -> list[dict[str, Any]]: + if all and not is_admin(session.get("email")): + raise HTTPException(403, "admin only") return await list_dashboard_threads(session["sub"], email=session.get("email"), include_all=all) +@router.get("/threads/page") +async def api_list_threads_page( + limit: int = 25, + offset: int = 0, + all: bool = False, + resolved: bool | None = None, + viewed: bool | None = None, + source: str | None = None, + status: str | None = None, + q: str | None = None, + 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_page( + session["sub"], + email=session.get("email"), + limit=limit, + offset=offset, + include_all=all, + resolved=resolved, + viewed=viewed, + source=source, + status=status, + query=q, + ) + + @router.get("/threads/{thread_id}") async def api_get_thread( thread_id: str, @@ -1018,6 +1051,20 @@ async def api_send_thread_message( return await send_dashboard_message(thread_id, session["sub"], body, email=session.get("email")) +@router.post("/threads/{thread_id}/resolve") +async def api_resolve_thread( + thread_id: str, + body: ThreadResolveBody, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + return await resolve_dashboard_thread( + thread_id, + session["sub"], + resolved=body.resolved, + email=session.get("email"), + ) + + @router.post("/threads/{thread_id}/runs/{run_id}/cancel") async def api_cancel_thread_run( thread_id: str, diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 50620d44..bfaebdbe 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -115,6 +115,10 @@ class ThreadMessageBody(BaseModel): effort: str | None = None +class ThreadResolveBody(BaseModel): + resolved: bool = True + + def _normalize_model_choice( model_id: str | None, effort: str | None ) -> tuple[str | None, str | None]: @@ -283,6 +287,10 @@ def _is_thread_viewed(metadata: dict[str, Any], latest_run_id: str | None) -> bo return isinstance(viewed_at, (int, float)) +def _is_thread_resolved(metadata: dict[str, Any]) -> bool: + return metadata.get("resolved") is True + + def _thread_summary( thread: dict[str, Any], *, @@ -327,6 +335,12 @@ def _thread_summary( if isinstance(metadata.get("last_viewed_at_ms"), (int, float)) else None ), + "resolved": _is_thread_resolved(metadata), + "resolvedAt": ( + int(metadata["resolved_at_ms"]) + if isinstance(metadata.get("resolved_at_ms"), (int, float)) + else None + ), "createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(), "updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(), "traceUrl": trace_url, @@ -443,6 +457,180 @@ async def list_dashboard_threads( return summaries[:limit] +# 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 _thread_updated_ms(metadata: dict[str, Any]) -> int: + value = metadata.get("updated_at_ms") + return int(value) if isinstance(value, (int, float)) else 0 + + +def _metadata_matches_filters( + metadata: dict[str, Any], + *, + resolved: bool | None, + source: str | None, + query: str | None, +) -> bool: + """Metadata-only filters that don't require fetching the latest run.""" + if resolved is not None and _is_thread_resolved(metadata) is not resolved: + return False + if source and _thread_source(metadata) != source: + return False + if query: + title = metadata.get("title") + title = title if isinstance(title, str) else "Untitled agent" + if query.lower() not in title.lower(): + return False + return True + + +def _summary_matches_filters( + summary: dict[str, Any], + *, + resolved: bool | None, + viewed: bool | None, + source: str | None, + status: str | None, + query: str | None, +) -> bool: + if resolved is not None and bool(summary.get("resolved")) is not resolved: + return False + if viewed is not None and bool(summary.get("viewed")) is not viewed: + return False + if source and summary.get("source") != source: + return False + if status and summary.get("status") != status: + return False + if query: + title = summary.get("title") + if not isinstance(title, str) or query.lower() not in title.lower(): + return False + return True + + +async def _gather_candidate_threads( + client: Any, + searches: list[dict[str, Any]], + *, + include_all: bool, + login: str, + email: str | 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: + offset = 0 + while offset < _THREADS_PAGE_SCAN_CAP: + batch = await client.threads.search( + metadata=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): + 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 + 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, + ) + + +async def list_dashboard_threads_page( + login: str, + *, + email: str | None = None, + limit: int = 25, + offset: int = 0, + include_all: bool = False, + resolved: bool | None = None, + viewed: bool | None = None, + source: str | None = None, + 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, + ) + + safe_offset = max(offset, 0) + safe_limit = max(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} + + 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) + + 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} + + async def _mark_thread_viewed( client: Any, thread_id: str, @@ -735,15 +923,18 @@ async def _enrich_run_start_command( overrides["agent_effort"] = chosen_effort else: _validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata)) + metadata_update: dict[str, Any] = {} if chosen_model and chosen_effort: overrides["agent_model_id"] = chosen_model overrides["agent_effort"] = chosen_effort - metadata = { - **metadata, - "model": chosen_model, - "effort": chosen_effort, - "updated_at_ms": _now_ms(), - } + metadata_update["model"] = chosen_model + metadata_update["effort"] = chosen_effort + if _is_thread_resolved(metadata): + metadata_update["resolved"] = False + metadata_update["resolved_at_ms"] = None + if metadata_update: + metadata_update["updated_at_ms"] = _now_ms() + metadata = {**metadata, **metadata_update} await client.threads.update(thread_id=thread_id, metadata=metadata) merged_configurable = await _build_dashboard_configurable( @@ -786,6 +977,9 @@ async def send_dashboard_message( if chosen_model and chosen_effort: metadata_update["model"] = chosen_model metadata_update["effort"] = chosen_effort + if _is_thread_resolved(metadata): + metadata_update["resolved"] = False + metadata_update["resolved_at_ms"] = None active = await get_thread_active_status(thread_id) if active is None: @@ -862,6 +1056,26 @@ async def delete_dashboard_thread(thread_id: str, login: str, *, email: str | No await client.threads.delete(thread_id) +async def resolve_dashboard_thread( + thread_id: str, login: str, *, resolved: bool, email: str | None = None +) -> dict[str, Any]: + """Mark a thread resolved/unresolved via thread metadata.""" + client = langgraph_client() + thread = await _authorized_thread(thread_id, login, email=email) + metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} + metadata_update: dict[str, Any] = { + "resolved": resolved, + "resolved_at_ms": _now_ms() if resolved else None, + } + try: + await client.threads.update(thread_id=thread_id, metadata=metadata_update) + except Exception as exc: # noqa: BLE001 + logger.debug("Could not update resolved state for thread %s", thread_id, exc_info=True) + raise HTTPException(502, "failed to update thread") from exc + thread = {**thread, "metadata": {**metadata, **metadata_update}} + return _thread_summary(thread) + + async def _authorized_thread_metadata( thread_id: str, login: str, *, email: str | None = None ) -> dict[str, Any]: diff --git a/tests/test_dashboard_thread_api.py b/tests/test_dashboard_thread_api.py index 54072e9a..14342464 100644 --- a/tests/test_dashboard_thread_api.py +++ b/tests/test_dashboard_thread_api.py @@ -455,3 +455,235 @@ async def test_send_dashboard_message_returns_502_when_activity_unknown(monkeypa ) assert exc_info.value.status_code == 502 + + +def test_thread_summary_exposes_resolved_state() -> None: + summary = thread_api._thread_summary( + { + "thread_id": "tid", + "metadata": { + "source": "dashboard", + "github_login": "octocat", + "resolved": True, + "resolved_at_ms": 1700, + }, + } + ) + + assert summary["resolved"] is True + assert summary["resolvedAt"] == 1700 + + +def test_thread_summary_defaults_to_not_resolved() -> None: + summary = thread_api._thread_summary( + {"thread_id": "tid", "metadata": {"source": "dashboard", "github_login": "octocat"}} + ) + + assert summary["resolved"] is False + assert summary["resolvedAt"] is None + + +async def test_resolve_dashboard_thread_marks_resolved(monkeypatch) -> None: + updates: list[dict[str, object]] = [] + + class FakeThreads: + async def get(self, thread_id: str) -> dict[str, object]: + return { + "thread_id": thread_id, + "metadata": {"source": "dashboard", "github_login": "octocat"}, + } + + async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None: + updates.append(dict(metadata)) + + class FakeClient: + threads = FakeThreads() + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + summary = await thread_api.resolve_dashboard_thread("tid", "octocat", resolved=True) + + assert updates[-1]["resolved"] is True + assert isinstance(updates[-1]["resolved_at_ms"], int) + assert summary["resolved"] is True + + +async def test_resolve_dashboard_thread_clears_resolved(monkeypatch) -> None: + updates: list[dict[str, object]] = [] + + class FakeThreads: + async def get(self, thread_id: str) -> dict[str, object]: + return { + "thread_id": thread_id, + "metadata": { + "source": "dashboard", + "github_login": "octocat", + "resolved": True, + "resolved_at_ms": 1700, + }, + } + + async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None: + updates.append(dict(metadata)) + + class FakeClient: + threads = FakeThreads() + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + summary = await thread_api.resolve_dashboard_thread("tid", "octocat", resolved=False) + + assert updates[-1]["resolved"] is False + assert updates[-1]["resolved_at_ms"] is None + assert summary["resolved"] is False + + +async def test_resolve_dashboard_thread_enforces_ownership(monkeypatch) -> None: + class FakeThreads: + async def get(self, thread_id: str) -> dict[str, object]: + return { + "thread_id": thread_id, + "metadata": {"source": "dashboard", "github_login": "owner"}, + } + + class FakeClient: + threads = FakeThreads() + + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + with pytest.raises(HTTPException) as exc_info: + await thread_api.resolve_dashboard_thread("tid", "intruder", resolved=True) + assert exc_info.value.status_code == 404 + + +async def test_enrich_run_start_command_unresolves_thread(monkeypatch) -> None: + updates: list[dict[str, object]] = [] + + class FakeThreads: + async def update(self, *, thread_id: str, metadata: dict[str, object]) -> None: + updates.append(dict(metadata)) + + class FakeClient: + threads = FakeThreads() + + _patch_new_thread_deps(monkeypatch, profile={}) + monkeypatch.setattr(thread_api, "langgraph_client", lambda: FakeClient()) + + async def fake_build(thread_id, login, metadata, *, overrides): + return {"github_login": login, "source": "dashboard"} + + monkeypatch.setattr(thread_api, "_build_dashboard_configurable", fake_build) + + command = { + "method": "run.start", + "params": { + "input": {"messages": [{"type": "human", "content": "follow up"}]}, + "config": {"configurable": {}}, + }, + } + + await thread_api._enrich_run_start_command( + "tid", + "octocat", + command, + metadata={ + "source": "dashboard", + "github_login": "octocat", + "resolved": True, + "resolved_at_ms": 1700, + }, + ) + + assert updates, "expected metadata update to clear resolved state" + assert updates[-1]["resolved"] is False + assert updates[-1]["resolved_at_ms"] is None + + +def test_summary_matches_filters() -> None: + summary = { + "resolved": True, + "viewed": False, + "source": "github", + "status": "finished", + "title": "Fix the flaky test", + } + + assert thread_api._summary_matches_filters( + summary, resolved=True, viewed=None, source=None, status=None, query=None + ) + assert not thread_api._summary_matches_filters( + summary, resolved=False, viewed=None, source=None, status=None, query=None + ) + assert thread_api._summary_matches_filters( + summary, resolved=None, viewed=None, source="github", status=None, query="flaky" + ) + assert not thread_api._summary_matches_filters( + summary, resolved=None, viewed=None, source=None, status=None, query="missing" + ) + + +def test_metadata_matches_filters() -> None: + metadata = {"source": "dashboard", "title": "Fix login bug", "resolved": True} + + assert thread_api._metadata_matches_filters(metadata, resolved=True, source=None, query=None) + assert not thread_api._metadata_matches_filters( + metadata, resolved=False, source=None, query=None + ) + assert thread_api._metadata_matches_filters( + metadata, resolved=None, source="dashboard", query="login" + ) + assert not thread_api._metadata_matches_filters( + metadata, resolved=None, source="github", query=None + ) + + +def _make_threads(count: int, *, resolved_before: int) -> list[dict[str, object]]: + threads: list[dict[str, object]] = [] + for index in range(count): + threads.append( + { + "thread_id": f"t{index}", + "metadata": { + "source": "dashboard", + "github_login": "octocat", + "title": f"Thread {index}", + "updated_at_ms": count - index, + "resolved": index < resolved_before, + }, + } + ) + return threads + + +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) + offsets: list[int] = [] + + class FakeThreads: + async def search(self, *, metadata, limit, offset, sort_by, sort_order): + offsets.append(offset) + return threads[offset : offset + limit] + + async def update(self, *, thread_id, metadata): + return None + + class FakeRuns: + async def list(self, thread_id, limit=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["total"] == 50 + assert len(result["items"]) == 25 + assert all(item["resolved"] is False for item in result["items"]) + assert 100 in offsets diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx index 87d482e0..c1a0b5b2 100644 --- a/ui/src/components/agents/AgentsHome.tsx +++ b/ui/src/components/agents/AgentsHome.tsx @@ -9,7 +9,11 @@ import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import { AgentPromptBar } from "@/components/agents/AgentPromptBar" import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog" import { Logo } from "@/components/agents/ported/Logo" -import { agentThreadKeys, optimisticThread } from "@/lib/agents/queries" +import { + agentThreadKeys, + invalidateAgentThreadLists, + optimisticThread, +} from "@/lib/agents/queries" import { useModelOptions } from "@/lib/agents/provider/useModelOptions" import { useProfile, useRepos } from "@/lib/profile" @@ -65,6 +69,7 @@ export function AgentsHome() { thread, ...(prev?.filter((existing) => existing.id !== id) ?? []), ]) + invalidateAgentThreadLists(queryClient) void navigate({ to: "/agents/$threadId", params: { threadId: id } }) }, [stream.threadId, queryClient, navigate]) diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index 4fff720d..9da961a1 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -2,11 +2,13 @@ import { ContextMenu } from "@base-ui/react/context-menu" import { Dialog } from "@base-ui/react/dialog" import { Link } from "@tanstack/react-router" import { + ArrowCounterClockwiseIcon, CalendarBlankIcon, CaretDownIcon, CaretRightIcon, ChartLineUpIcon, ChatCircleIcon, + CheckCircleIcon, CircleNotchIcon, GitMergeIcon, GitPullRequestIcon, @@ -37,12 +39,15 @@ import { } from "@/components/sidebar-layout" import { groupThreads } from "@/lib/agents/api" import { - useAgentThreads, useDeleteAgentThread, + useResolveAgentThread, useSeedAgentThreadDetails, + useSidebarThreads, } from "@/lib/agents/queries" import { cn } from "@/lib/utils" +const RESOLVED_SIDEBAR_LIMIT = 20 + type SourceIcon = ComponentType> const SOURCE_META: Record = { @@ -93,10 +98,15 @@ const NAV = [ ] as const export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { - const threadsQuery = useAgentThreads() - const threads = threadsQuery.data ?? [] - useSeedAgentThreadDetails(threads, activeThreadId) - const groups = groupThreads(threads) + 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 groups = groupThreads(activeThreads) const layout = useSidebarLayout() const reviewSidebar = useReviewSidebarData() @@ -178,6 +188,12 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { activeThreadId={activeThreadId} onNavigate={layout.closeOnMobile} /> + )} @@ -231,6 +247,62 @@ function ThreadGroup({ ) } +function ResolvedThreadGroup({ + threads, + total, + activeThreadId, + onNavigate, +}: { + threads: Array + total: number + activeThreadId?: string + onNavigate?: () => void +}) { + const [collapsed, setCollapsed] = useState(true) + if (threads.length === 0) return null + + const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon + const visible = threads.slice(0, RESOLVED_SIDEBAR_LIMIT) + const hasMore = total > visible.length + + return ( +
+ + {!collapsed && ( + <> + {visible.map((thread) => ( + + ))} + {hasMore && ( + + Show all + + )} + + )} +
+ ) +} + function ThreadRow({ thread, isActive, @@ -241,6 +313,7 @@ function ThreadRow({ onNavigate?: () => void }) { const deleteThread = useDeleteAgentThread() + const resolveThread = useResolveAgentThread() const [deleteOpen, setDeleteOpen] = useState(false) const badge = thread.diffStats && thread.diffStats.additions > 0 @@ -263,6 +336,14 @@ function ThreadRow({ }) } + const isResolved = thread.resolved === true + const onToggleResolved = (e?: React.MouseEvent) => { + e?.preventDefault() + e?.stopPropagation() + if (resolveThread.isPending) return + resolveThread.mutate({ threadId: thread.id, resolved: !isResolved }) + } + const source = thread.source && thread.source !== "dashboard" ? SOURCE_META[thread.source] @@ -341,6 +422,20 @@ function ThreadRow({ {badge} )} + + + +
+ update({ resolved: triToBool(value) })} + /> + update({ viewed: triToBool(value) })} + /> + + update({ + source: value === "any" ? undefined : (value as AgentSource), + }) + } + /> + + update({ + status: value === "any" ? undefined : (value as AgentStatus), + }) + } + /> +
+ + +
+ {query.isLoading ? ( +

+ Loading... +

+ ) : items.length === 0 ? ( +

+ No threads match these filters. +

+ ) : ( + items.map((thread) => ( + + )) + )} +
+ + {total > 0 && ( +
+ + {offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total} + +
+ + + Page {filters.page} / {totalPages} + + +
+
+ )} + + + ) +} + +function TriFilter({ + label, + value, + onChange, +}: { + label: string + value: TriState + onChange: (value: TriState) => void +}) { + return ( +
+ {label} +
+ {TRI_OPTIONS.map((option) => ( + + ))} +
+
+ ) +} + +function SelectFilter({ + value, + options, + onChange, +}: { + value: string + options: Array<{ value: string; label: string }> + onChange: (value: string) => void +}) { + return ( + + ) +} + +function ThreadListItem({ thread }: { thread: AgentThread }) { + const resolveThread = useResolveAgentThread() + const isResolved = thread.resolved === true + + const onToggleResolved = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (resolveThread.isPending) return + resolveThread.mutate({ threadId: thread.id, resolved: !isResolved }) + } + + return ( + +
+

{thread.title}

+

+ {thread.repoFullName || "no repo"} · {thread.status} + {isResolved ? " · resolved" : ""} +

+
+ + + ) +} diff --git a/ui/src/components/agents/ported/CloudPromptBar.tsx b/ui/src/components/agents/ported/CloudPromptBar.tsx index 32559a7e..2b365fbe 100644 --- a/ui/src/components/agents/ported/CloudPromptBar.tsx +++ b/ui/src/components/agents/ported/CloudPromptBar.tsx @@ -17,7 +17,7 @@ import type { ImageChunk } from "@/lib/agents/types" import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import { RepoSelector } from "@/components/agents/RepoSelector" import { useIsInAgentThreadStream } from "@/lib/agents/provider/useIsInAgentThreadStream" -import { agentThreadKeys } from "@/lib/agents/queries" +import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/queries" import { formatModelSelection } from "@/lib/agents/provider/useModelOptions" import { IconButton } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -71,7 +71,7 @@ function StreamSubmitButton(props: SubmitButtonProps) { queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) => prev ? { ...prev, status: "interrupted" as const } : prev ) - void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }) + invalidateAgentThreadLists(queryClient) } } finally { setStopping(false) @@ -273,8 +273,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({ const handlePaste = useCallback( (e: React.ClipboardEvent) => { - const items = e.clipboardData?.items - if (!items) return + const items = e.clipboardData.items const files: Array = [] for (const item of Array.from(items)) { if (item.kind === "file") { diff --git a/ui/src/lib/agents/AgentThreadStreamProvider.tsx b/ui/src/lib/agents/AgentThreadStreamProvider.tsx index ebe2dc63..fc1d0f95 100644 --- a/ui/src/lib/agents/AgentThreadStreamProvider.tsx +++ b/ui/src/lib/agents/AgentThreadStreamProvider.tsx @@ -4,7 +4,7 @@ import { overrideFetchImplementation } from "@langchain/langgraph-sdk"; import { useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "./api"; -import { agentThreadKeys } from "./queries"; +import { agentThreadKeys, invalidateAgentThreadLists } from "./queries"; import type { ReactNode } from "react"; const AGENT_ASSISTANT_ID = "agent"; @@ -73,7 +73,7 @@ export function AgentThreadStreamProvider({ threadIdRef.current = threadId; const onCreated = useCallback(() => { - void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }); + invalidateAgentThreadLists(queryClient); }, [queryClient]); const onCompleted = useCallback(() => { @@ -81,7 +81,7 @@ export function AgentThreadStreamProvider({ if (id) { void queryClient.invalidateQueries({ queryKey: agentThreadKeys.detail(id) }); } - void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }); + invalidateAgentThreadLists(queryClient); }, [queryClient]); return ( diff --git a/ui/src/lib/agents/api.ts b/ui/src/lib/agents/api.ts index 71899115..8a6de3b9 100644 --- a/ui/src/lib/agents/api.ts +++ b/ui/src/lib/agents/api.ts @@ -57,6 +57,23 @@ export interface ThreadPrDiff { files: Array } +export interface ThreadsPageParams { + limit?: number + offset?: number + resolved?: boolean + viewed?: boolean + source?: string + status?: string + q?: string +} + +export interface ThreadsPage { + items: Array + total: number + limit: number + offset: number +} + const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace( /\/$/, "" @@ -95,9 +112,32 @@ async function agentsRequest( return (await res.json()) as T } +function buildThreadsPageQuery(params: ThreadsPageParams): string { + const search = new URLSearchParams() + if (params.limit != null) search.set("limit", String(params.limit)) + if (params.offset != null) search.set("offset", String(params.offset)) + if (params.resolved != null) search.set("resolved", String(params.resolved)) + if (params.viewed != null) search.set("viewed", String(params.viewed)) + if (params.source) search.set("source", params.source) + if (params.status) search.set("status", params.status) + if (params.q) search.set("q", params.q) + const query = search.toString() + return query ? `?${query}` : "" +} + export const agentsApi = { langGraphApiUrl: agentsLangGraphApiUrl, listThreads: () => agentsRequest>("/threads"), + listThreadsPage: (params: ThreadsPageParams = {}) => + agentsRequest(`/threads/page${buildThreadsPageQuery(params)}`), + resolveThread: (threadId: string, resolved: boolean) => + agentsRequest( + `/threads/${encodeURIComponent(threadId)}/resolve`, + { + method: "POST", + body: JSON.stringify({ resolved }), + } + ), listSchedules: () => agentsRequest>("/schedules"), createSchedule: (body: ScheduleCreateRequest) => agentsRequest("/schedules", { diff --git a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts index 6624f879..ca9166a8 100644 --- a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts +++ b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts @@ -3,7 +3,7 @@ import { useStreamContext as useAgentThreadStream } from "@langchain/react"; import type { SendAgentMessageVariables } from "@/lib/agents/queries"; import { AgentsApiError, agentsApi } from "@/lib/agents/api"; -import { agentThreadKeys } from "@/lib/agents/queries"; +import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/queries"; /** * Construct the message content for the LangGraph run. @@ -82,7 +82,7 @@ export function useSubmitAgentMessage(threadId: string) { queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) => prev ? { ...prev, status: "running" as const } : prev, ); - void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }); + invalidateAgentThreadLists(queryClient); }, }); } diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts index f9279fb3..bd6d177d 100644 --- a/ui/src/lib/agents/queries.ts +++ b/ui/src/lib/agents/queries.ts @@ -3,13 +3,21 @@ import { useNavigate } from "@tanstack/react-router" import { useEffect } from "react" import { agentsApi } from "./api" -import type { ScheduleUpdateRequest } from "./api" +import type { QueryClient } from "@tanstack/react-query" +import type { ScheduleUpdateRequest, ThreadsPageParams } from "./api" import type { AgentThread, Chunk, ImageChunk, Message } from "./types" export const agentThreadKeys = { - all: ["agent-threads"] as const, + lists: ["agent-threads", "lists"] as const, + all: ["agent-threads", "lists", "all"] as const, detail: (threadId: string) => ["agent-threads", threadId] as const, prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const, + page: (params: ThreadsPageParams) => + ["agent-threads", "lists", "page", params] as const, +} + +export function invalidateAgentThreadLists(queryClient: QueryClient): void { + void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists }) } export const agentScheduleKeys = { @@ -52,6 +60,43 @@ export function useAgentThreads() { }) } +// The sidebar fetches active (unresolved) and resolved threads separately so +// resolving the most-recent threads can't hide older active ones behind a +// shared cap — each list is filled server-side from its own filtered query. +const SIDEBAR_ACTIVE_LIMIT = 50 + +function sidebarRefetchInterval(query: { + state: { data?: { items: Array } } +}) { + return query.state.data?.items.some((thread) => thread.status === "running") + ? 2000 + : false +} + +export function useSidebarThreads(resolvedLimit: number) { + const active = useQuery({ + queryKey: agentThreadKeys.page({ + resolved: false, + limit: SIDEBAR_ACTIVE_LIMIT, + }), + queryFn: () => + agentsApi.listThreadsPage({ + resolved: false, + limit: SIDEBAR_ACTIVE_LIMIT, + }), + refetchInterval: sidebarRefetchInterval, + placeholderData: (prev) => prev, + }) + const resolved = useQuery({ + queryKey: agentThreadKeys.page({ resolved: true, limit: resolvedLimit }), + queryFn: () => + agentsApi.listThreadsPage({ resolved: true, limit: resolvedLimit }), + refetchInterval: sidebarRefetchInterval, + placeholderData: (prev) => prev, + }) + return { active, resolved } +} + export function useAgentThread(threadId: string) { return useQuery({ queryKey: agentThreadKeys.detail(threadId), @@ -181,7 +226,7 @@ export function useCancelAgentThread(threadId: string) { mutationFn: () => agentsApi.cancelThread(threadId), onSuccess: (thread) => { queryClient.setQueryData(agentThreadKeys.detail(threadId), thread) - queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }) + invalidateAgentThreadLists(queryClient) }, }) } @@ -194,7 +239,7 @@ export function useDeleteAgentThread() { mutationFn: (threadId: string) => agentsApi.deleteThread(threadId), onSuccess: (_, threadId) => { queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) }) - queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true }) + invalidateAgentThreadLists(queryClient) const path = window.location.pathname if (path.includes(`/agents/${threadId}`)) { navigate({ to: "/agents" }) @@ -202,3 +247,24 @@ export function useDeleteAgentThread() { }, }) } + +export function useResolveAgentThread() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars: { threadId: string; resolved: boolean }) => + agentsApi.resolveThread(vars.threadId, vars.resolved), + onSuccess: (thread, vars) => { + queryClient.setQueryData(agentThreadKeys.detail(vars.threadId), thread) + invalidateAgentThreadLists(queryClient) + }, + }) +} + +export function useThreadsPage(params: ThreadsPageParams) { + return useQuery({ + queryKey: agentThreadKeys.page(params), + queryFn: () => agentsApi.listThreadsPage(params), + placeholderData: (prev) => prev, + }) +} diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index e5a10109..b5357e22 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -184,6 +184,8 @@ export interface AgentThread { status: AgentStatus viewed: boolean viewedAt?: number | null + resolved?: boolean + resolvedAt?: number | null createdAt: number updatedAt: number traceUrl?: string | null @@ -227,7 +229,7 @@ export type GitFileStatus = | "added-by-us" | "added-by-them" | "deleted-by-us" - | "deleted-by-them"; + | "deleted-by-them" export interface GitStatusEntry { path: string diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 769e8540..568ed87f 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as AgentsIndexRouteImport } from './routes/agents/index' import { Route as ReviewStylesRouteImport } from './routes/review_.styles' import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions' +import { Route as AgentsThreadsRouteImport } from './routes/agents/threads' import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId' import { Route as AgentsReviewsIndexRouteImport } from './routes/agents/reviews/index' import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index' @@ -89,6 +90,11 @@ const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({ path: '/agents/instructions', getParentRoute: () => rootRouteImport, } as any) +const AgentsThreadsRoute = AgentsThreadsRouteImport.update({ + id: '/threads', + path: '/threads', + getParentRoute: () => AgentsRoute, +} as any) const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({ id: '/$threadId', path: '/$threadId', @@ -138,6 +144,7 @@ export interface FileRoutesByFullPath { '/review': typeof ReviewRoute '/usage': typeof UsageRoute '/agents/$threadId': typeof AgentsThreadIdRoute + '/agents/threads': typeof AgentsThreadsRoute '/agents/instructions': typeof AgentsInstructionsRoute '/review/styles': typeof ReviewStylesRoute '/agents/': typeof AgentsIndexRoute @@ -158,6 +165,7 @@ export interface FileRoutesByTo { '/review': typeof ReviewRoute '/usage': typeof UsageRoute '/agents/$threadId': typeof AgentsThreadIdRoute + '/agents/threads': typeof AgentsThreadsRoute '/agents/instructions': typeof AgentsInstructionsRoute '/review/styles': typeof ReviewStylesRoute '/agents': typeof AgentsIndexRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/review': typeof ReviewRoute '/usage': typeof UsageRoute '/agents/$threadId': typeof AgentsThreadIdRoute + '/agents/threads': typeof AgentsThreadsRoute '/agents_/instructions': typeof AgentsInstructionsRoute '/review_/styles': typeof ReviewStylesRoute '/agents/': typeof AgentsIndexRoute @@ -203,6 +212,7 @@ export interface FileRouteTypes { | '/review' | '/usage' | '/agents/$threadId' + | '/agents/threads' | '/agents/instructions' | '/review/styles' | '/agents/' @@ -223,6 +233,7 @@ export interface FileRouteTypes { | '/review' | '/usage' | '/agents/$threadId' + | '/agents/threads' | '/agents/instructions' | '/review/styles' | '/agents' @@ -244,6 +255,7 @@ export interface FileRouteTypes { | '/review' | '/usage' | '/agents/$threadId' + | '/agents/threads' | '/agents_/instructions' | '/review_/styles' | '/agents/' @@ -356,6 +368,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AgentsInstructionsRouteImport parentRoute: typeof rootRouteImport } + '/agents/threads': { + id: '/agents/threads' + path: '/threads' + fullPath: '/agents/threads' + preLoaderRoute: typeof AgentsThreadsRouteImport + parentRoute: typeof AgentsRoute + } '/agents/$threadId': { id: '/agents/$threadId' path: '/$threadId' @@ -410,6 +429,7 @@ declare module '@tanstack/react-router' { interface AgentsRouteChildren { AgentsThreadIdRoute: typeof AgentsThreadIdRoute + AgentsThreadsRoute: typeof AgentsThreadsRoute AgentsIndexRoute: typeof AgentsIndexRoute AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute @@ -420,6 +440,7 @@ interface AgentsRouteChildren { const AgentsRouteChildren: AgentsRouteChildren = { AgentsThreadIdRoute: AgentsThreadIdRoute, + AgentsThreadsRoute: AgentsThreadsRoute, AgentsIndexRoute: AgentsIndexRoute, AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute, AgentsAutomationsNewRoute: AgentsAutomationsNewRoute, @@ -448,12 +469,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/ui/src/routes/agents.tsx b/ui/src/routes/agents.tsx index ef9f45b0..e93d3433 100644 --- a/ui/src/routes/agents.tsx +++ b/ui/src/routes/agents.tsx @@ -28,6 +28,7 @@ function AgentsLayout() { section === "agents" && threadId && threadId !== "automations" && + threadId !== "threads" && threadId !== "reviews" ? threadId : undefined diff --git a/ui/src/routes/agents/threads.tsx b/ui/src/routes/agents/threads.tsx new file mode 100644 index 00000000..24309ed4 --- /dev/null +++ b/ui/src/routes/agents/threads.tsx @@ -0,0 +1,79 @@ +import { createFileRoute } from "@tanstack/react-router" + +import type { AgentSource, AgentStatus } from "@/lib/agents/types" +import { + AgentsThreadsPage, + type ThreadsPageFilters, +} from "@/components/agents/AgentsThreadsPage" + +const SOURCES: ReadonlyArray = [ + "dashboard", + "github", + "slack", + "linear", + "schedule", +] +const STATUSES: ReadonlyArray = [ + "idle", + "running", + "finished", + "interrupted", + "error", +] + +function parseBool(value: unknown): boolean | undefined { + if (value === true || value === "true") return true + if (value === false || value === "false") return false + return undefined +} + +export const Route = createFileRoute("/agents/threads")({ + validateSearch: (search: Record): ThreadsPageFilters => { + const source = + typeof search.source === "string" && + SOURCES.includes(search.source as AgentSource) + ? (search.source as AgentSource) + : undefined + const status = + typeof search.status === "string" && + STATUSES.includes(search.status as AgentStatus) + ? (search.status as AgentStatus) + : undefined + const page = + typeof search.page === "number" && search.page >= 1 + ? Math.floor(search.page) + : 1 + return { + resolved: parseBool(search.resolved), + viewed: parseBool(search.viewed), + source, + status, + q: typeof search.q === "string" && search.q ? search.q : undefined, + page, + } + }, + component: AgentsThreadsRoute, +}) + +function AgentsThreadsRoute() { + const filters = Route.useSearch() + const navigate = Route.useNavigate() + + return ( + + navigate({ + search: { + resolved: next.resolved, + viewed: next.viewed, + source: next.source, + status: next.status, + q: next.q, + page: next.page, + }, + }) + } + /> + ) +}