mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
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:
parent
d4c7cbea1d
commit
cb574793a3
7 changed files with 288 additions and 7 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue