From cb574793a32705db71c2e416319b4072f0131361 Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Thu, 23 Jul 2026 15:01:10 -0400 Subject: [PATCH] fix: show current shared thread in sidebar (#1799) * fix: show current shared thread in sidebar Co-authored-by: open-swe[bot] * fix: preserve resolved active sidebar threads Co-authored-by: open-swe[bot] --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> (cherry picked from commit a77c4e475643b4a55bb2f0c93c0aa2669014fbac) --- agent/dashboard/routes.py | 2 + agent/dashboard/thread_api.py | 82 ++++++++- tests/dashboard/test_dashboard_thread_api.py | 155 ++++++++++++++++++ tests/e2e/tests/sandbox_id.spec.ts | 36 ++++ .../agents/components/AgentsSidebar.tsx | 2 +- ui/src/features/agents/lib/api.ts | 5 + ui/src/features/agents/lib/queries.ts | 13 +- 7 files changed, 288 insertions(+), 7 deletions(-) diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 92d34594..448ef0ff 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -1553,6 +1553,7 @@ async def api_list_threads( async def api_list_threads_sidebar( active_limit: int = 50, resolved_limit: int = 20, + active_thread_id: str | None = None, all: bool = False, session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: @@ -1563,6 +1564,7 @@ async def api_list_threads_sidebar( email=session.get("email"), active_limit=active_limit, resolved_limit=resolved_limit, + active_thread_id=active_thread_id, include_all=all, ) diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index cc3f3015..299210a9 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -722,12 +722,52 @@ async def list_dashboard_threads( return page["items"] +async def _sidebar_active_thread_summary( + client: Any, + active_thread_id: str | None, + *, + fallback_threads: Mapping[str, ThreadLike], + visible_thread_ids: set[str], + login: str, + email: str | None, + include_all: bool, +) -> tuple[dict[str, Any], bool] | None: + if not active_thread_id or active_thread_id in visible_thread_ids: + return None + thread = fallback_threads.get(active_thread_id) + if thread is None: + try: + fetched = await client.threads.get(active_thread_id) + except Exception: # noqa: BLE001 + logger.debug( + "Could not fetch active sidebar thread %s", active_thread_id, exc_info=True + ) + return None + if not isinstance(fetched, Mapping): + return None + thread = fetched + metadata = _thread_metadata(thread) + if not include_all: + try: + _assert_thread_readable(metadata) + except HTTPException: + return None + summary = await _summarize_thread( + client, + thread, + owner_login=None if include_all else login, + owner_email=None if include_all else email, + ) + return summary, _is_thread_resolved(metadata) + + async def list_dashboard_threads_sidebar( login: str, *, email: str | None = None, active_limit: int = 50, resolved_limit: int = 20, + active_thread_id: str | None = None, include_all: bool = False, ) -> dict[str, Any]: client = langgraph_client() @@ -775,7 +815,8 @@ async def list_dashboard_threads_sidebar( resolved_candidates = sorted(resolved_threads.values(), key=_thread_updated_ms, reverse=True) active_window = active_candidates[:safe_active_limit] resolved_window = resolved_candidates[:safe_resolved_limit] - active_items, resolved_items = await asyncio.gather( + active_ids = {thread_id for thread in active_window if (thread_id := _thread_id(thread))} + active_items, resolved_items, active_thread = await asyncio.gather( _summarize_threads( client, active_window, @@ -788,17 +829,52 @@ async def list_dashboard_threads_sidebar( owner_login=None if include_all else login, owner_email=None if include_all else email, ), + _sidebar_active_thread_summary( + client, + active_thread_id, + fallback_threads={**active, **resolved_threads}, + visible_thread_ids=active_ids, + login=login, + email=email, + include_all=include_all, + ), ) + active_has_more = len(active_candidates) > safe_active_limit + resolved_has_more = len(resolved_candidates) > safe_resolved_limit + if active_thread: + active_thread_summary, is_resolved_active_thread = active_thread + if is_resolved_active_thread: + resolved_items = [ + active_thread_summary, + *[item for item in resolved_items if item["id"] != active_thread_summary["id"]], + ] + active_items = [ + item for item in active_items if item["id"] != active_thread_summary["id"] + ] + if len(resolved_items) > safe_resolved_limit: + resolved_items = resolved_items[:safe_resolved_limit] + resolved_has_more = True + else: + active_items = [ + active_thread_summary, + *[item for item in active_items if item["id"] != active_thread_summary["id"]], + ] + resolved_items = [ + item for item in resolved_items if item["id"] != active_thread_summary["id"] + ] + if len(active_items) > safe_active_limit: + active_items = active_items[:safe_active_limit] + active_has_more = True return { "active": { "items": active_items, "limit": safe_active_limit, - "hasMore": len(active_candidates) > safe_active_limit, + "hasMore": active_has_more, }, "resolved": { "items": resolved_items, "limit": safe_resolved_limit, - "hasMore": len(resolved_candidates) > safe_resolved_limit, + "hasMore": resolved_has_more, }, } diff --git a/tests/dashboard/test_dashboard_thread_api.py b/tests/dashboard/test_dashboard_thread_api.py index d409f291..7cad7a99 100644 --- a/tests/dashboard/test_dashboard_thread_api.py +++ b/tests/dashboard/test_dashboard_thread_api.py @@ -1360,6 +1360,161 @@ async def test_list_dashboard_threads_sidebar_fills_buckets_with_one_endpoint(mo assert {call["offset"] for call in searches} == {0, page_size} +async def test_list_dashboard_threads_sidebar_includes_readable_active_thread( + monkeypatch, +) -> None: + threads = _make_threads(1, resolved_before=0) + shared_thread = { + "thread_id": "shared-thread", + "metadata": { + "source": "slack", + "github_login": "teammate", + "title": "Teammate thread", + "updated_at_ms": 100, + "latest_run_status": "success", + "sandbox_id": "sandbox-123", + }, + } + + class FakeThreads: + async def search(self, *, metadata, limit, offset, sort_by, sort_order, select): + assert select == thread_api._THREAD_LIST_SELECT + return threads[offset : offset + limit] + + async def get(self, thread_id): + assert thread_id == "shared-thread" + return shared_thread + + 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_sidebar( + "octocat", + email=None, + active_limit=5, + resolved_limit=5, + active_thread_id="shared-thread", + ) + + assert [item["id"] for item in result["active"]["items"]] == ["shared-thread", "t0"] + shared = result["active"]["items"][0] + assert shared["isOwner"] is False + assert shared["sandboxId"] == "sandbox-123" + + +async def test_list_dashboard_threads_sidebar_keeps_resolved_active_thread_resolved( + monkeypatch, +) -> None: + threads = _make_threads(1, resolved_before=0) + shared_thread = { + "thread_id": "shared-resolved-thread", + "metadata": { + "source": "slack", + "github_login": "teammate", + "title": "Resolved teammate thread", + "updated_at_ms": 100, + "latest_run_status": "success", + "resolved": True, + "sandbox_id": "sandbox-456", + }, + } + + class FakeThreads: + async def search(self, *, metadata, limit, offset, sort_by, sort_order, select): + assert select == thread_api._THREAD_LIST_SELECT + return threads[offset : offset + limit] + + async def get(self, thread_id): + assert thread_id == "shared-resolved-thread" + return shared_thread + + 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_sidebar( + "octocat", + email=None, + active_limit=5, + resolved_limit=5, + active_thread_id="shared-resolved-thread", + ) + + assert [item["id"] for item in result["active"]["items"]] == ["t0"] + assert [item["id"] for item in result["resolved"]["items"]] == ["shared-resolved-thread"] + shared = result["resolved"]["items"][0] + assert shared["isOwner"] is False + assert shared["resolved"] is True + assert shared["sandboxId"] == "sandbox-456" + + +async def test_list_dashboard_threads_sidebar_ignores_unreadable_active_thread( + monkeypatch, +) -> None: + threads = _make_threads(1, resolved_before=0) + private_thread = { + "thread_id": "private-thread", + "metadata": { + "source": "internal", + "github_login": "teammate", + "title": "Private thread", + "updated_at_ms": 100, + "latest_run_status": "success", + }, + } + + class FakeThreads: + async def search(self, *, metadata, limit, offset, sort_by, sort_order, select): + assert select == thread_api._THREAD_LIST_SELECT + return threads[offset : offset + limit] + + async def get(self, thread_id): + assert thread_id == "private-thread" + return private_thread + + 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_sidebar( + "octocat", + email=None, + active_limit=5, + resolved_limit=5, + active_thread_id="private-thread", + ) + + assert [item["id"] for item in result["active"]["items"]] == ["t0"] + + async def test_list_dashboard_threads_page_refreshes_only_unsettled_threads(monkeypatch) -> None: threads = _make_threads(3, resolved_before=0) threads[0]["metadata"]["latest_run_status"] = "success" diff --git a/tests/e2e/tests/sandbox_id.spec.ts b/tests/e2e/tests/sandbox_id.spec.ts index 8d21f7a2..3c6ed3c0 100644 --- a/tests/e2e/tests/sandbox_id.spec.ts +++ b/tests/e2e/tests/sandbox_id.spec.ts @@ -5,6 +5,7 @@ import { test, expect, type Locator, type Page } from "@playwright/test"; // creates a sandbox and stamps its id into the thread metadata, and the UI // copies that same id. Only the LLM/GitHub/Slack boundaries are faked. const SAME_USER = { login: "alice", email: "alice@example.com" }; +const OTHER_USER = { login: "bob", email: "bob@example.com" }; async function loginAs(page: Page, user: { login: string; email: string }) { const res = await page.request.post("/control/login", { data: user }); @@ -112,4 +113,39 @@ test.describe("thread sandbox id (real dashboard UI)", () => { await context.close(); }); + + test("shared active thread appears in the sidebar with sandbox action", async ({ + page, + browser, + baseURL, + }, testInfo) => { + await loginAs(page, SAME_USER); + const threadId = await createThreadWithSandbox(page); + + const bobContext = await browser.newContext({ baseURL }); + await bobContext.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: baseURL, + }); + const bobPage = await bobContext.newPage(); + await loginAs(bobPage, OTHER_USER); + await bobPage.goto(`/agents/${threadId}`); + await expect(bobPage).toHaveURL(new RegExp(`/agents/${threadId}$`)); + + const row = bobPage.locator(`a[href$="/agents/${threadId}"]`).first(); + await expect(row).toBeVisible(); + await row.hover(); + await kebabFor(row).click(); + await expect(copyItem(bobPage)).toBeEnabled(); + + const screenshotPath = testInfo.outputPath( + "shared-thread-sidebar-sandbox-menu.png", + ); + await bobPage.screenshot({ path: screenshotPath, fullPage: true }); + await testInfo.attach("shared-thread-sidebar-sandbox-menu", { + path: screenshotPath, + contentType: "image/png", + }); + + await bobContext.close(); + }); }); diff --git a/ui/src/features/agents/components/AgentsSidebar.tsx b/ui/src/features/agents/components/AgentsSidebar.tsx index b6057ac5..63caf9b8 100644 --- a/ui/src/features/agents/components/AgentsSidebar.tsx +++ b/ui/src/features/agents/components/AgentsSidebar.tsx @@ -111,7 +111,7 @@ export function AgentsSidebar({ }: AgentsSidebarProps) { const { prefs, setGroup, setCompact, setFilters, resetFilters } = useSidebarPrefs() - const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT) + const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT, activeThreadId) const activeThreads = sidebar.data?.active.items ?? [] const resolvedThreads = sidebar.data?.resolved.items ?? [] const resolvedHasMore = sidebar.data?.resolved.hasMore ?? false diff --git a/ui/src/features/agents/lib/api.ts b/ui/src/features/agents/lib/api.ts index bb0dc0f6..42d84acd 100644 --- a/ui/src/features/agents/lib/api.ts +++ b/ui/src/features/agents/lib/api.ts @@ -225,6 +225,7 @@ function buildThreadsPageQuery(params: ThreadsPageParams): string { function buildSidebarThreadsQuery(params: { activeLimit?: number resolvedLimit?: number + activeThreadId?: string }): string { const search = new URLSearchParams() if (params.activeLimit != null) { @@ -233,6 +234,9 @@ function buildSidebarThreadsQuery(params: { if (params.resolvedLimit != null) { search.set("resolved_limit", String(params.resolvedLimit)) } + if (params.activeThreadId) { + search.set("active_thread_id", params.activeThreadId) + } const query = search.toString() return query ? `?${query}` : "" } @@ -242,6 +246,7 @@ export const agentsApi = { listSidebarThreads: (params: { activeLimit?: number resolvedLimit?: number + activeThreadId?: string }) => agentsRequest( `/threads/sidebar${buildSidebarThreadsQuery(params)}` diff --git a/ui/src/features/agents/lib/queries.ts b/ui/src/features/agents/lib/queries.ts index a5a50202..925fc1b0 100644 --- a/ui/src/features/agents/lib/queries.ts +++ b/ui/src/features/agents/lib/queries.ts @@ -13,8 +13,11 @@ import type { AgentThread, Chunk, ImageChunk, Message } from "./types" export const agentThreadKeys = { lists: ["agent-threads", "lists"] as const, - sidebar: (params: { activeLimit: number; resolvedLimit: number }) => - ["agent-threads", "lists", "sidebar", params] as const, + sidebar: (params: { + activeLimit: number + resolvedLimit: number + activeThreadId?: string + }) => ["agent-threads", "lists", "sidebar", params] as const, detail: (threadId: string) => ["agent-threads", threadId] as const, prDiff: (threadId: string) => ["agent-threads", threadId, "pr-diff"] as const, workflowApprovals: (threadId: string) => @@ -93,10 +96,14 @@ function sidebarRefetchInterval(query: { state: { data?: SidebarThreads } }) { : false } -export function useSidebarThreads(resolvedLimit: number) { +export function useSidebarThreads( + resolvedLimit: number, + activeThreadId?: string +) { const params = { activeLimit: SIDEBAR_ACTIVE_LIMIT, resolvedLimit, + activeThreadId, } return useQuery({ queryKey: agentThreadKeys.sidebar(params),