mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 11:22:10 +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(
|
async def api_list_threads_sidebar(
|
||||||
active_limit: int = 50,
|
active_limit: int = 50,
|
||||||
resolved_limit: int = 20,
|
resolved_limit: int = 20,
|
||||||
|
active_thread_id: str | None = None,
|
||||||
all: bool = False,
|
all: bool = False,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -1563,6 +1564,7 @@ async def api_list_threads_sidebar(
|
||||||
email=session.get("email"),
|
email=session.get("email"),
|
||||||
active_limit=active_limit,
|
active_limit=active_limit,
|
||||||
resolved_limit=resolved_limit,
|
resolved_limit=resolved_limit,
|
||||||
|
active_thread_id=active_thread_id,
|
||||||
include_all=all,
|
include_all=all,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -722,12 +722,52 @@ async def list_dashboard_threads(
|
||||||
return page["items"]
|
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(
|
async def list_dashboard_threads_sidebar(
|
||||||
login: str,
|
login: str,
|
||||||
*,
|
*,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
active_limit: int = 50,
|
active_limit: int = 50,
|
||||||
resolved_limit: int = 20,
|
resolved_limit: int = 20,
|
||||||
|
active_thread_id: str | None = None,
|
||||||
include_all: bool = False,
|
include_all: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
client = langgraph_client()
|
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)
|
resolved_candidates = sorted(resolved_threads.values(), key=_thread_updated_ms, reverse=True)
|
||||||
active_window = active_candidates[:safe_active_limit]
|
active_window = active_candidates[:safe_active_limit]
|
||||||
resolved_window = resolved_candidates[:safe_resolved_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(
|
_summarize_threads(
|
||||||
client,
|
client,
|
||||||
active_window,
|
active_window,
|
||||||
|
|
@ -788,17 +829,52 @@ async def list_dashboard_threads_sidebar(
|
||||||
owner_login=None if include_all else login,
|
owner_login=None if include_all else login,
|
||||||
owner_email=None if include_all else email,
|
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 {
|
return {
|
||||||
"active": {
|
"active": {
|
||||||
"items": active_items,
|
"items": active_items,
|
||||||
"limit": safe_active_limit,
|
"limit": safe_active_limit,
|
||||||
"hasMore": len(active_candidates) > safe_active_limit,
|
"hasMore": active_has_more,
|
||||||
},
|
},
|
||||||
"resolved": {
|
"resolved": {
|
||||||
"items": resolved_items,
|
"items": resolved_items,
|
||||||
"limit": safe_resolved_limit,
|
"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}
|
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:
|
async def test_list_dashboard_threads_page_refreshes_only_unsettled_threads(monkeypatch) -> None:
|
||||||
threads = _make_threads(3, resolved_before=0)
|
threads = _make_threads(3, resolved_before=0)
|
||||||
threads[0]["metadata"]["latest_run_status"] = "success"
|
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
|
// 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.
|
// copies that same id. Only the LLM/GitHub/Slack boundaries are faked.
|
||||||
const SAME_USER = { login: "alice", email: "alice@example.com" };
|
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 }) {
|
async function loginAs(page: Page, user: { login: string; email: string }) {
|
||||||
const res = await page.request.post("/control/login", { data: user });
|
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();
|
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) {
|
}: AgentsSidebarProps) {
|
||||||
const { prefs, setGroup, setCompact, setFilters, resetFilters } =
|
const { prefs, setGroup, setCompact, setFilters, resetFilters } =
|
||||||
useSidebarPrefs()
|
useSidebarPrefs()
|
||||||
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT, activeThreadId)
|
||||||
const activeThreads = sidebar.data?.active.items ?? []
|
const activeThreads = sidebar.data?.active.items ?? []
|
||||||
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
||||||
const resolvedHasMore = sidebar.data?.resolved.hasMore ?? false
|
const resolvedHasMore = sidebar.data?.resolved.hasMore ?? false
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,7 @@ function buildThreadsPageQuery(params: ThreadsPageParams): string {
|
||||||
function buildSidebarThreadsQuery(params: {
|
function buildSidebarThreadsQuery(params: {
|
||||||
activeLimit?: number
|
activeLimit?: number
|
||||||
resolvedLimit?: number
|
resolvedLimit?: number
|
||||||
|
activeThreadId?: string
|
||||||
}): string {
|
}): string {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
if (params.activeLimit != null) {
|
if (params.activeLimit != null) {
|
||||||
|
|
@ -233,6 +234,9 @@ function buildSidebarThreadsQuery(params: {
|
||||||
if (params.resolvedLimit != null) {
|
if (params.resolvedLimit != null) {
|
||||||
search.set("resolved_limit", String(params.resolvedLimit))
|
search.set("resolved_limit", String(params.resolvedLimit))
|
||||||
}
|
}
|
||||||
|
if (params.activeThreadId) {
|
||||||
|
search.set("active_thread_id", params.activeThreadId)
|
||||||
|
}
|
||||||
const query = search.toString()
|
const query = search.toString()
|
||||||
return query ? `?${query}` : ""
|
return query ? `?${query}` : ""
|
||||||
}
|
}
|
||||||
|
|
@ -242,6 +246,7 @@ export const agentsApi = {
|
||||||
listSidebarThreads: (params: {
|
listSidebarThreads: (params: {
|
||||||
activeLimit?: number
|
activeLimit?: number
|
||||||
resolvedLimit?: number
|
resolvedLimit?: number
|
||||||
|
activeThreadId?: string
|
||||||
}) =>
|
}) =>
|
||||||
agentsRequest<SidebarThreads>(
|
agentsRequest<SidebarThreads>(
|
||||||
`/threads/sidebar${buildSidebarThreadsQuery(params)}`
|
`/threads/sidebar${buildSidebarThreadsQuery(params)}`
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ import type { AgentThread, Chunk, ImageChunk, Message } from "./types"
|
||||||
|
|
||||||
export const agentThreadKeys = {
|
export const agentThreadKeys = {
|
||||||
lists: ["agent-threads", "lists"] as const,
|
lists: ["agent-threads", "lists"] as const,
|
||||||
sidebar: (params: { activeLimit: number; resolvedLimit: number }) =>
|
sidebar: (params: {
|
||||||
["agent-threads", "lists", "sidebar", params] as const,
|
activeLimit: number
|
||||||
|
resolvedLimit: number
|
||||||
|
activeThreadId?: string
|
||||||
|
}) => ["agent-threads", "lists", "sidebar", params] 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,
|
||||||
workflowApprovals: (threadId: string) =>
|
workflowApprovals: (threadId: string) =>
|
||||||
|
|
@ -93,10 +96,14 @@ function sidebarRefetchInterval(query: { state: { data?: SidebarThreads } }) {
|
||||||
: false
|
: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarThreads(resolvedLimit: number) {
|
export function useSidebarThreads(
|
||||||
|
resolvedLimit: number,
|
||||||
|
activeThreadId?: string
|
||||||
|
) {
|
||||||
const params = {
|
const params = {
|
||||||
activeLimit: SIDEBAR_ACTIVE_LIMIT,
|
activeLimit: SIDEBAR_ACTIVE_LIMIT,
|
||||||
resolvedLimit,
|
resolvedLimit,
|
||||||
|
activeThreadId,
|
||||||
}
|
}
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentThreadKeys.sidebar(params),
|
queryKey: agentThreadKeys.sidebar(params),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue