feat: mark threads as resolved to hide from sidebar (#1500)

* feat: mark threads resolved to hide from sidebar [closes resolve-threads]

Add a resolved flag (thread metadata) so users can clear finished
threads from the sidebar without deleting them. Resolved threads move to
a collapsible "Resolved" group (capped at 20 with a "Show all" link) and
are fully searchable/filterable + paginated on a new /agents/threads
page driven by URL query params. Sending a new message auto-unresolves.

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

* fix: refresh paginated agent thread lists

Invalidate all agent thread list queries when thread lifecycle events change sidebar data, keeping the new paginated sidebar cache in sync.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
Caroline di Vittorio 2026-06-12 12:01:42 -07:00 • committed by GitHub
parent 09d5d00e59
commit e8bb6b497b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1141 additions and 35 deletions

View file

@ -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,

View file

@ -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]:

View file

@ -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

View file

@ -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])

View file

@ -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<SVGProps<SVGSVGElement>>
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
@ -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}
/>
<ResolvedThreadGroup
threads={resolvedThreads}
total={resolvedTotal}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
</div>
)}
@ -231,6 +247,62 @@ function ThreadGroup({
)
}
function ResolvedThreadGroup({
threads,
total,
activeThreadId,
onNavigate,
}: {
threads: Array<AgentThread>
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 (
<div className="mb-3">
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase transition-colors hover:text-[var(--ui-text-muted)]"
aria-expanded={!collapsed}
>
<ToggleIcon className="size-3" />
<span className="min-w-0 flex-1 truncate">Resolved</span>
<span>{total}</span>
</button>
{!collapsed && (
<>
{visible.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))}
{hasMore && (
<Link
to="/agents/threads"
search={{ resolved: true, page: 1 }}
onClick={onNavigate}
className="mt-0.5 flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
>
Show all
</Link>
)}
</>
)}
</div>
)
}
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}
</span>
)}
<button
type="button"
aria-label={isResolved ? "Unresolve thread" : "Resolve thread"}
title={isResolved ? "Unresolve thread" : "Resolve thread"}
onClick={onToggleResolved}
disabled={resolveThread.isPending}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{isResolved ? (
<ArrowCounterClockwiseIcon className="size-3" weight="bold" />
) : (
<CheckCircleIcon className="size-3" weight="bold" />
)}
</button>
<button
type="button"
aria-label="Delete thread"
@ -362,6 +457,18 @@ function ThreadRow({
<TreeStructureIcon className="size-3.5" />
Open trace
</ContextMenu.Item>
<ContextMenu.Item
onClick={() => onToggleResolved()}
disabled={resolveThread.isPending}
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
>
{isResolved ? (
<ArrowCounterClockwiseIcon className="size-3.5" />
) : (
<CheckCircleIcon className="size-3.5" />
)}
{isResolved ? "Unresolve thread" : "Resolve thread"}
</ContextMenu.Item>
<ContextMenu.Item
onClick={onDelete}
disabled={isDeleting}

View file

@ -0,0 +1,302 @@
import { Link } from "@tanstack/react-router"
import {
ArrowCounterClockwiseIcon,
CaretLeftIcon,
CaretRightIcon,
CheckCircleIcon,
} from "@phosphor-icons/react"
import { useState } from "react"
import type { AgentSource, AgentStatus, AgentThread } from "@/lib/agents/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useResolveAgentThread, useThreadsPage } from "@/lib/agents/queries"
import { cn } from "@/lib/utils"
const PAGE_SIZE = 25
export interface ThreadsPageFilters {
resolved?: boolean
viewed?: boolean
source?: AgentSource
status?: AgentStatus
q?: string
page: number
}
type TriState = "any" | "true" | "false"
const TRI_OPTIONS: Array<{ value: TriState; label: string }> = [
{ value: "any", label: "Any" },
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
]
const SOURCE_OPTIONS: Array<{ value: AgentSource | "any"; label: string }> = [
{ value: "any", label: "Any source" },
{ value: "dashboard", label: "Dashboard" },
{ value: "github", label: "GitHub" },
{ value: "slack", label: "Slack" },
{ value: "linear", label: "Linear" },
{ value: "schedule", label: "Schedule" },
]
const STATUS_OPTIONS: Array<{ value: AgentStatus | "any"; label: string }> = [
{ value: "any", label: "Any status" },
{ value: "running", label: "Running" },
{ value: "finished", label: "Finished" },
{ value: "error", label: "Error" },
{ value: "idle", label: "Idle" },
]
function boolToTri(value?: boolean): TriState {
if (value === true) return "true"
if (value === false) return "false"
return "any"
}
function triToBool(value: TriState): boolean | undefined {
if (value === "true") return true
if (value === "false") return false
return undefined
}
export function AgentsThreadsPage({
filters,
onFiltersChange,
}: {
filters: ThreadsPageFilters
onFiltersChange: (next: ThreadsPageFilters) => void
}) {
const [search, setSearch] = useState(filters.q ?? "")
const offset = (filters.page - 1) * PAGE_SIZE
const query = useThreadsPage({
limit: PAGE_SIZE,
offset,
resolved: filters.resolved,
viewed: filters.viewed,
source: filters.source,
status: filters.status,
q: filters.q,
})
const data = query.data
const items = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const update = (patch: Partial<ThreadsPageFilters>) => {
onFiltersChange({ ...filters, ...patch, page: patch.page ?? 1 })
}
const onSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
update({ q: search.trim() || undefined })
}
return (
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6">
<div>
<h1 className="font-heading text-lg font-medium text-[var(--ui-text)]">
Threads
</h1>
<p className="text-xs text-[var(--ui-text-muted)]">
Search and filter every thread, including resolved ones.
</p>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-3">
<form onSubmit={onSearchSubmit} className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title..."
className="h-8"
/>
<Button type="submit" size="sm" variant="outline">
Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<TriFilter
label="Resolved"
value={boolToTri(filters.resolved)}
onChange={(value) => update({ resolved: triToBool(value) })}
/>
<TriFilter
label="Viewed"
value={boolToTri(filters.viewed)}
onChange={(value) => update({ viewed: triToBool(value) })}
/>
<SelectFilter
value={filters.source ?? "any"}
options={SOURCE_OPTIONS}
onChange={(value) =>
update({
source: value === "any" ? undefined : (value as AgentSource),
})
}
/>
<SelectFilter
value={filters.status ?? "any"}
options={STATUS_OPTIONS}
onChange={(value) =>
update({
status: value === "any" ? undefined : (value as AgentStatus),
})
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{query.isLoading ? (
<p className="px-2 py-8 text-center text-xs text-[var(--ui-text-muted)]">
Loading...
</p>
) : items.length === 0 ? (
<p className="px-2 py-8 text-center text-xs text-[var(--ui-text-muted)]">
No threads match these filters.
</p>
) : (
items.map((thread) => (
<ThreadListItem key={thread.id} thread={thread} />
))
)}
</div>
{total > 0 && (
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-[var(--ui-text-muted)]">
<span>
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={filters.page <= 1}
onClick={() => update({ page: filters.page - 1 })}
>
<CaretLeftIcon className="size-3" />
Prev
</Button>
<span>
Page {filters.page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={filters.page >= totalPages}
onClick={() => update({ page: filters.page + 1 })}
>
Next
<CaretRightIcon className="size-3" />
</Button>
</div>
</div>
)}
</div>
</main>
)
}
function TriFilter({
label,
value,
onChange,
}: {
label: string
value: TriState
onChange: (value: TriState) => void
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-[var(--ui-text-dim)]">{label}</span>
<div className="flex overflow-hidden rounded-md border border-[var(--ui-border)]">
{TRI_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-2 py-0.5 transition-colors",
value === option.value
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]"
)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
function SelectFilter({
value,
options,
onChange,
}: {
value: string
options: Array<{ value: string; label: string }>
onChange: (value: string) => void
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-7 rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel)] px-2 text-xs text-[var(--ui-text)] outline-none"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
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 (
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
className="group flex items-center gap-3 rounded-lg border border-transparent px-3 py-2 transition-colors hover:border-[var(--ui-border)] hover:bg-[var(--ui-sidebar-hover)]"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-[var(--ui-text)]">{thread.title}</p>
<p className="truncate text-[11px] text-[var(--ui-text-dim)]">
{thread.repoFullName || "no repo"} · {thread.status}
{isResolved ? " · resolved" : ""}
</p>
</div>
<button
type="button"
aria-label={isResolved ? "Unresolve thread" : "Resolve thread"}
title={isResolved ? "Unresolve thread" : "Resolve thread"}
onClick={onToggleResolved}
disabled={resolveThread.isPending}
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{isResolved ? (
<ArrowCounterClockwiseIcon className="size-4" />
) : (
<CheckCircleIcon className="size-4" />
)}
</button>
</Link>
)
}

View file

@ -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<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items
if (!items) return
const items = e.clipboardData.items
const files: Array<File> = []
for (const item of Array.from(items)) {
if (item.kind === "file") {

View file

@ -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 (

View file

@ -57,6 +57,23 @@ export interface ThreadPrDiff {
files: Array<ThreadPrDiffFile>
}
export interface ThreadsPageParams {
limit?: number
offset?: number
resolved?: boolean
viewed?: boolean
source?: string
status?: string
q?: string
}
export interface ThreadsPage {
items: Array<AgentThread>
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<T>(
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<Array<AgentThread>>("/threads"),
listThreadsPage: (params: ThreadsPageParams = {}) =>
agentsRequest<ThreadsPage>(`/threads/page${buildThreadsPageQuery(params)}`),
resolveThread: (threadId: string, resolved: boolean) =>
agentsRequest<AgentThread>(
`/threads/${encodeURIComponent(threadId)}/resolve`,
{
method: "POST",
body: JSON.stringify({ resolved }),
}
),
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
createSchedule: (body: ScheduleCreateRequest) =>
agentsRequest<AgentSchedule>("/schedules", {

View file

@ -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);
},
});
}

View file

@ -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<AgentThread> } }
}) {
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,
})
}

View file

@ -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

View file

@ -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<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View file

@ -28,6 +28,7 @@ function AgentsLayout() {
section === "agents" &&
threadId &&
threadId !== "automations" &&
threadId !== "threads" &&
threadId !== "reviews"
? threadId
: undefined

View file

@ -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<AgentSource> = [
"dashboard",
"github",
"slack",
"linear",
"schedule",
]
const STATUSES: ReadonlyArray<AgentStatus> = [
"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<string, unknown>): 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 (
<AgentsThreadsPage
filters={filters}
onFiltersChange={(next) =>
navigate({
search: {
resolved: next.resolved,
viewed: next.viewed,
source: next.source,
status: next.status,
q: next.q,
page: next.page,
},
})
}
/>
)
}