fix: show current shared thread in sidebar (#1799)

* fix: show current shared thread in sidebar

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

* fix: preserve resolved active sidebar threads

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

---------

Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
(cherry picked from commit a77c4e475643b4a55bb2f0c93c0aa2669014fbac)
This commit is contained in:
Ramon Nogueira 2026-07-23 15:01:10 -04:00 • committed by Adam Moussa
parent d4c7cbea1d
commit cb574793a3
No known key found for this signature in database
7 changed files with 288 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SidebarThreads>(
`/threads/sidebar${buildSidebarThreadsQuery(params)}`

View file

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