mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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:
parent
09d5d00e59
commit
e8bb6b497b
15 changed files with 1141 additions and 35 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
302
ui/src/components/agents/AgentsThreadsPage.tsx
Normal file
302
ui/src/components/agents/AgentsThreadsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function AgentsLayout() {
|
|||
section === "agents" &&
|
||||
threadId &&
|
||||
threadId !== "automations" &&
|
||||
threadId !== "threads" &&
|
||||
threadId !== "reviews"
|
||||
? threadId
|
||||
: undefined
|
||||
|
|
|
|||
79
ui/src/routes/agents/threads.tsx
Normal file
79
ui/src/routes/agents/threads.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue