mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 00:53:20 +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 (
|
from .thread_api import (
|
||||||
ThreadMessageBody,
|
ThreadMessageBody,
|
||||||
|
ThreadResolveBody,
|
||||||
cancel_dashboard_thread,
|
cancel_dashboard_thread,
|
||||||
delete_dashboard_thread,
|
delete_dashboard_thread,
|
||||||
get_dashboard_thread,
|
get_dashboard_thread,
|
||||||
get_dashboard_thread_pr_diff,
|
get_dashboard_thread_pr_diff,
|
||||||
get_dashboard_thread_state,
|
get_dashboard_thread_state,
|
||||||
list_dashboard_threads,
|
list_dashboard_threads,
|
||||||
|
list_dashboard_threads_page,
|
||||||
proxy_dashboard_thread_commands,
|
proxy_dashboard_thread_commands,
|
||||||
proxy_dashboard_thread_history,
|
proxy_dashboard_thread_history,
|
||||||
proxy_dashboard_thread_run_cancel,
|
proxy_dashboard_thread_run_cancel,
|
||||||
proxy_dashboard_thread_stream_events,
|
proxy_dashboard_thread_stream_events,
|
||||||
|
resolve_dashboard_thread,
|
||||||
send_dashboard_message,
|
send_dashboard_message,
|
||||||
stream_dashboard_thread,
|
stream_dashboard_thread,
|
||||||
)
|
)
|
||||||
|
|
@ -980,9 +983,39 @@ async def api_list_threads(
|
||||||
all: bool = False,
|
all: bool = False,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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)
|
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}")
|
@router.get("/threads/{thread_id}")
|
||||||
async def api_get_thread(
|
async def api_get_thread(
|
||||||
thread_id: str,
|
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"))
|
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")
|
@router.post("/threads/{thread_id}/runs/{run_id}/cancel")
|
||||||
async def api_cancel_thread_run(
|
async def api_cancel_thread_run(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,10 @@ class ThreadMessageBody(BaseModel):
|
||||||
effort: str | None = None
|
effort: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadResolveBody(BaseModel):
|
||||||
|
resolved: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _normalize_model_choice(
|
def _normalize_model_choice(
|
||||||
model_id: str | None, effort: str | None
|
model_id: str | None, effort: str | None
|
||||||
) -> tuple[str | None, 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))
|
return isinstance(viewed_at, (int, float))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_thread_resolved(metadata: dict[str, Any]) -> bool:
|
||||||
|
return metadata.get("resolved") is True
|
||||||
|
|
||||||
|
|
||||||
def _thread_summary(
|
def _thread_summary(
|
||||||
thread: dict[str, Any],
|
thread: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
|
|
@ -327,6 +335,12 @@ def _thread_summary(
|
||||||
if isinstance(metadata.get("last_viewed_at_ms"), (int, float))
|
if isinstance(metadata.get("last_viewed_at_ms"), (int, float))
|
||||||
else None
|
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(),
|
"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(),
|
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
|
||||||
"traceUrl": trace_url,
|
"traceUrl": trace_url,
|
||||||
|
|
@ -443,6 +457,180 @@ async def list_dashboard_threads(
|
||||||
return summaries[:limit]
|
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(
|
async def _mark_thread_viewed(
|
||||||
client: Any,
|
client: Any,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
|
|
@ -735,15 +923,18 @@ async def _enrich_run_start_command(
|
||||||
overrides["agent_effort"] = chosen_effort
|
overrides["agent_effort"] = chosen_effort
|
||||||
else:
|
else:
|
||||||
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
|
_validate_command_images(content, model_id=chosen_model or _metadata_model_id(metadata))
|
||||||
|
metadata_update: dict[str, Any] = {}
|
||||||
if chosen_model and chosen_effort:
|
if chosen_model and chosen_effort:
|
||||||
overrides["agent_model_id"] = chosen_model
|
overrides["agent_model_id"] = chosen_model
|
||||||
overrides["agent_effort"] = chosen_effort
|
overrides["agent_effort"] = chosen_effort
|
||||||
metadata = {
|
metadata_update["model"] = chosen_model
|
||||||
**metadata,
|
metadata_update["effort"] = chosen_effort
|
||||||
"model": chosen_model,
|
if _is_thread_resolved(metadata):
|
||||||
"effort": chosen_effort,
|
metadata_update["resolved"] = False
|
||||||
"updated_at_ms": _now_ms(),
|
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)
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||||
|
|
||||||
merged_configurable = await _build_dashboard_configurable(
|
merged_configurable = await _build_dashboard_configurable(
|
||||||
|
|
@ -786,6 +977,9 @@ async def send_dashboard_message(
|
||||||
if chosen_model and chosen_effort:
|
if chosen_model and chosen_effort:
|
||||||
metadata_update["model"] = chosen_model
|
metadata_update["model"] = chosen_model
|
||||||
metadata_update["effort"] = chosen_effort
|
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)
|
active = await get_thread_active_status(thread_id)
|
||||||
if active is None:
|
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)
|
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(
|
async def _authorized_thread_metadata(
|
||||||
thread_id: str, login: str, *, email: str | None = None
|
thread_id: str, login: str, *, email: str | None = None
|
||||||
) -> dict[str, Any]:
|
) -> 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
|
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 { AgentPromptBar } from "@/components/agents/AgentPromptBar"
|
||||||
import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
|
||||||
import { Logo } from "@/components/agents/ported/Logo"
|
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 { useModelOptions } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { useProfile, useRepos } from "@/lib/profile"
|
import { useProfile, useRepos } from "@/lib/profile"
|
||||||
|
|
||||||
|
|
@ -65,6 +69,7 @@ export function AgentsHome() {
|
||||||
thread,
|
thread,
|
||||||
...(prev?.filter((existing) => existing.id !== id) ?? []),
|
...(prev?.filter((existing) => existing.id !== id) ?? []),
|
||||||
])
|
])
|
||||||
|
invalidateAgentThreadLists(queryClient)
|
||||||
void navigate({ to: "/agents/$threadId", params: { threadId: id } })
|
void navigate({ to: "/agents/$threadId", params: { threadId: id } })
|
||||||
}, [stream.threadId, queryClient, navigate])
|
}, [stream.threadId, queryClient, navigate])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { ContextMenu } from "@base-ui/react/context-menu"
|
||||||
import { Dialog } from "@base-ui/react/dialog"
|
import { Dialog } from "@base-ui/react/dialog"
|
||||||
import { Link } from "@tanstack/react-router"
|
import { Link } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
|
ArrowCounterClockwiseIcon,
|
||||||
CalendarBlankIcon,
|
CalendarBlankIcon,
|
||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
CaretRightIcon,
|
CaretRightIcon,
|
||||||
ChartLineUpIcon,
|
ChartLineUpIcon,
|
||||||
ChatCircleIcon,
|
ChatCircleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
CircleNotchIcon,
|
CircleNotchIcon,
|
||||||
GitMergeIcon,
|
GitMergeIcon,
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
|
|
@ -37,12 +39,15 @@ import {
|
||||||
} from "@/components/sidebar-layout"
|
} from "@/components/sidebar-layout"
|
||||||
import { groupThreads } from "@/lib/agents/api"
|
import { groupThreads } from "@/lib/agents/api"
|
||||||
import {
|
import {
|
||||||
useAgentThreads,
|
|
||||||
useDeleteAgentThread,
|
useDeleteAgentThread,
|
||||||
|
useResolveAgentThread,
|
||||||
useSeedAgentThreadDetails,
|
useSeedAgentThreadDetails,
|
||||||
|
useSidebarThreads,
|
||||||
} from "@/lib/agents/queries"
|
} from "@/lib/agents/queries"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RESOLVED_SIDEBAR_LIMIT = 20
|
||||||
|
|
||||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
||||||
|
|
@ -93,10 +98,15 @@ const NAV = [
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
const threadsQuery = useAgentThreads()
|
const { active, resolved } = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
||||||
const threads = threadsQuery.data ?? []
|
const activeThreads = active.data?.items ?? []
|
||||||
useSeedAgentThreadDetails(threads, activeThreadId)
|
const resolvedThreads = resolved.data?.items ?? []
|
||||||
const groups = groupThreads(threads)
|
const resolvedTotal = resolved.data?.total ?? resolvedThreads.length
|
||||||
|
useSeedAgentThreadDetails(
|
||||||
|
[...activeThreads, ...resolvedThreads],
|
||||||
|
activeThreadId
|
||||||
|
)
|
||||||
|
const groups = groupThreads(activeThreads)
|
||||||
const layout = useSidebarLayout()
|
const layout = useSidebarLayout()
|
||||||
const reviewSidebar = useReviewSidebarData()
|
const reviewSidebar = useReviewSidebarData()
|
||||||
|
|
||||||
|
|
@ -178,6 +188,12 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
activeThreadId={activeThreadId}
|
activeThreadId={activeThreadId}
|
||||||
onNavigate={layout.closeOnMobile}
|
onNavigate={layout.closeOnMobile}
|
||||||
/>
|
/>
|
||||||
|
<ResolvedThreadGroup
|
||||||
|
threads={resolvedThreads}
|
||||||
|
total={resolvedTotal}
|
||||||
|
activeThreadId={activeThreadId}
|
||||||
|
onNavigate={layout.closeOnMobile}
|
||||||
|
/>
|
||||||
</div>
|
</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({
|
function ThreadRow({
|
||||||
thread,
|
thread,
|
||||||
isActive,
|
isActive,
|
||||||
|
|
@ -241,6 +313,7 @@ function ThreadRow({
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
}) {
|
}) {
|
||||||
const deleteThread = useDeleteAgentThread()
|
const deleteThread = useDeleteAgentThread()
|
||||||
|
const resolveThread = useResolveAgentThread()
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const badge =
|
const badge =
|
||||||
thread.diffStats && thread.diffStats.additions > 0
|
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 =
|
const source =
|
||||||
thread.source && thread.source !== "dashboard"
|
thread.source && thread.source !== "dashboard"
|
||||||
? SOURCE_META[thread.source]
|
? SOURCE_META[thread.source]
|
||||||
|
|
@ -341,6 +422,20 @@ function ThreadRow({
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Delete thread"
|
aria-label="Delete thread"
|
||||||
|
|
@ -362,6 +457,18 @@ function ThreadRow({
|
||||||
<TreeStructureIcon className="size-3.5" />
|
<TreeStructureIcon className="size-3.5" />
|
||||||
Open trace
|
Open trace
|
||||||
</ContextMenu.Item>
|
</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
|
<ContextMenu.Item
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={isDeleting}
|
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 type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { RepoSelector } from "@/components/agents/RepoSelector"
|
import { RepoSelector } from "@/components/agents/RepoSelector"
|
||||||
import { useIsInAgentThreadStream } from "@/lib/agents/provider/useIsInAgentThreadStream"
|
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 { formatModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||||
import { IconButton } from "@/components/ui/button"
|
import { IconButton } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
@ -71,7 +71,7 @@ function StreamSubmitButton(props: SubmitButtonProps) {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
prev ? { ...prev, status: "interrupted" as const } : prev
|
prev ? { ...prev, status: "interrupted" as const } : prev
|
||||||
)
|
)
|
||||||
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
invalidateAgentThreadLists(queryClient)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setStopping(false)
|
setStopping(false)
|
||||||
|
|
@ -273,8 +273,7 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData.items
|
||||||
if (!items) return
|
|
||||||
const files: Array<File> = []
|
const files: Array<File> = []
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === "file") {
|
if (item.kind === "file") {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { overrideFetchImplementation } from "@langchain/langgraph-sdk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { agentsApi } from "./api";
|
import { agentsApi } from "./api";
|
||||||
import { agentThreadKeys } from "./queries";
|
import { agentThreadKeys, invalidateAgentThreadLists } from "./queries";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const AGENT_ASSISTANT_ID = "agent";
|
const AGENT_ASSISTANT_ID = "agent";
|
||||||
|
|
@ -73,7 +73,7 @@ export function AgentThreadStreamProvider({
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
|
|
||||||
const onCreated = useCallback(() => {
|
const onCreated = useCallback(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true });
|
invalidateAgentThreadLists(queryClient);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const onCompleted = useCallback(() => {
|
const onCompleted = useCallback(() => {
|
||||||
|
|
@ -81,7 +81,7 @@ export function AgentThreadStreamProvider({
|
||||||
if (id) {
|
if (id) {
|
||||||
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.detail(id) });
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.detail(id) });
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true });
|
invalidateAgentThreadLists(queryClient);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,23 @@ export interface ThreadPrDiff {
|
||||||
files: Array<ThreadPrDiffFile>
|
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(
|
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
|
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 = {
|
export const agentsApi = {
|
||||||
langGraphApiUrl: agentsLangGraphApiUrl,
|
langGraphApiUrl: agentsLangGraphApiUrl,
|
||||||
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
|
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"),
|
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
|
||||||
createSchedule: (body: ScheduleCreateRequest) =>
|
createSchedule: (body: ScheduleCreateRequest) =>
|
||||||
agentsRequest<AgentSchedule>("/schedules", {
|
agentsRequest<AgentSchedule>("/schedules", {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useStreamContext as useAgentThreadStream } from "@langchain/react";
|
||||||
|
|
||||||
import type { SendAgentMessageVariables } from "@/lib/agents/queries";
|
import type { SendAgentMessageVariables } from "@/lib/agents/queries";
|
||||||
import { AgentsApiError, agentsApi } from "@/lib/agents/api";
|
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.
|
* Construct the message content for the LangGraph run.
|
||||||
|
|
@ -82,7 +82,7 @@ export function useSubmitAgentMessage(threadId: string) {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) =>
|
||||||
prev ? { ...prev, status: "running" as const } : 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 { useEffect } from "react"
|
||||||
|
|
||||||
import { agentsApi } from "./api"
|
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"
|
import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||||
|
|
||||||
export const agentThreadKeys = {
|
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,
|
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
||||||
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
|
prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const,
|
||||||
|
page: (params: ThreadsPageParams) =>
|
||||||
|
["agent-threads", "lists", "page", params] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAgentThreadLists(queryClient: QueryClient): void {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: agentThreadKeys.lists })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentScheduleKeys = {
|
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) {
|
export function useAgentThread(threadId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentThreadKeys.detail(threadId),
|
queryKey: agentThreadKeys.detail(threadId),
|
||||||
|
|
@ -181,7 +226,7 @@ export function useCancelAgentThread(threadId: string) {
|
||||||
mutationFn: () => agentsApi.cancelThread(threadId),
|
mutationFn: () => agentsApi.cancelThread(threadId),
|
||||||
onSuccess: (thread) => {
|
onSuccess: (thread) => {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), 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),
|
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
||||||
onSuccess: (_, threadId) => {
|
onSuccess: (_, threadId) => {
|
||||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all, exact: true })
|
invalidateAgentThreadLists(queryClient)
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
if (path.includes(`/agents/${threadId}`)) {
|
if (path.includes(`/agents/${threadId}`)) {
|
||||||
navigate({ to: "/agents" })
|
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
|
status: AgentStatus
|
||||||
viewed: boolean
|
viewed: boolean
|
||||||
viewedAt?: number | null
|
viewedAt?: number | null
|
||||||
|
resolved?: boolean
|
||||||
|
resolvedAt?: number | null
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
traceUrl?: string | null
|
traceUrl?: string | null
|
||||||
|
|
@ -227,7 +229,7 @@ export type GitFileStatus =
|
||||||
| "added-by-us"
|
| "added-by-us"
|
||||||
| "added-by-them"
|
| "added-by-them"
|
||||||
| "deleted-by-us"
|
| "deleted-by-us"
|
||||||
| "deleted-by-them";
|
| "deleted-by-them"
|
||||||
|
|
||||||
export interface GitStatusEntry {
|
export interface GitStatusEntry {
|
||||||
path: string
|
path: string
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
||||||
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
|
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 AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||||
import { Route as AgentsReviewsIndexRouteImport } from './routes/agents/reviews/index'
|
import { Route as AgentsReviewsIndexRouteImport } from './routes/agents/reviews/index'
|
||||||
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
||||||
|
|
@ -89,6 +90,11 @@ const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
|
||||||
path: '/agents/instructions',
|
path: '/agents/instructions',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentsThreadsRoute = AgentsThreadsRouteImport.update({
|
||||||
|
id: '/threads',
|
||||||
|
path: '/threads',
|
||||||
|
getParentRoute: () => AgentsRoute,
|
||||||
|
} as any)
|
||||||
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
||||||
id: '/$threadId',
|
id: '/$threadId',
|
||||||
path: '/$threadId',
|
path: '/$threadId',
|
||||||
|
|
@ -138,6 +144,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/review': typeof ReviewRoute
|
'/review': typeof ReviewRoute
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
|
|
@ -158,6 +165,7 @@ export interface FileRoutesByTo {
|
||||||
'/review': typeof ReviewRoute
|
'/review': typeof ReviewRoute
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents': typeof AgentsIndexRoute
|
'/agents': typeof AgentsIndexRoute
|
||||||
|
|
@ -180,6 +188,7 @@ export interface FileRoutesById {
|
||||||
'/review': typeof ReviewRoute
|
'/review': typeof ReviewRoute
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
|
'/agents/threads': typeof AgentsThreadsRoute
|
||||||
'/agents_/instructions': typeof AgentsInstructionsRoute
|
'/agents_/instructions': typeof AgentsInstructionsRoute
|
||||||
'/review_/styles': typeof ReviewStylesRoute
|
'/review_/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
|
|
@ -203,6 +212,7 @@ export interface FileRouteTypes {
|
||||||
| '/review'
|
| '/review'
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
|
| '/agents/threads'
|
||||||
| '/agents/instructions'
|
| '/agents/instructions'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
|
|
@ -223,6 +233,7 @@ export interface FileRouteTypes {
|
||||||
| '/review'
|
| '/review'
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
|
| '/agents/threads'
|
||||||
| '/agents/instructions'
|
| '/agents/instructions'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents'
|
| '/agents'
|
||||||
|
|
@ -244,6 +255,7 @@ export interface FileRouteTypes {
|
||||||
| '/review'
|
| '/review'
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
|
| '/agents/threads'
|
||||||
| '/agents_/instructions'
|
| '/agents_/instructions'
|
||||||
| '/review_/styles'
|
| '/review_/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
|
|
@ -356,6 +368,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AgentsInstructionsRouteImport
|
preLoaderRoute: typeof AgentsInstructionsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agents/threads': {
|
||||||
|
id: '/agents/threads'
|
||||||
|
path: '/threads'
|
||||||
|
fullPath: '/agents/threads'
|
||||||
|
preLoaderRoute: typeof AgentsThreadsRouteImport
|
||||||
|
parentRoute: typeof AgentsRoute
|
||||||
|
}
|
||||||
'/agents/$threadId': {
|
'/agents/$threadId': {
|
||||||
id: '/agents/$threadId'
|
id: '/agents/$threadId'
|
||||||
path: '/$threadId'
|
path: '/$threadId'
|
||||||
|
|
@ -410,6 +429,7 @@ declare module '@tanstack/react-router' {
|
||||||
|
|
||||||
interface AgentsRouteChildren {
|
interface AgentsRouteChildren {
|
||||||
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
||||||
|
AgentsThreadsRoute: typeof AgentsThreadsRoute
|
||||||
AgentsIndexRoute: typeof AgentsIndexRoute
|
AgentsIndexRoute: typeof AgentsIndexRoute
|
||||||
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
||||||
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
||||||
|
|
@ -420,6 +440,7 @@ interface AgentsRouteChildren {
|
||||||
|
|
||||||
const AgentsRouteChildren: AgentsRouteChildren = {
|
const AgentsRouteChildren: AgentsRouteChildren = {
|
||||||
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
||||||
|
AgentsThreadsRoute: AgentsThreadsRoute,
|
||||||
AgentsIndexRoute: AgentsIndexRoute,
|
AgentsIndexRoute: AgentsIndexRoute,
|
||||||
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
||||||
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
||||||
|
|
@ -448,12 +469,3 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._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" &&
|
section === "agents" &&
|
||||||
threadId &&
|
threadId &&
|
||||||
threadId !== "automations" &&
|
threadId !== "automations" &&
|
||||||
|
threadId !== "threads" &&
|
||||||
threadId !== "reviews"
|
threadId !== "reviews"
|
||||||
? threadId
|
? threadId
|
||||||
: undefined
|
: 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