From bc59c05642203b6cfde010f778265d4fa8326751 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:17:03 -0400 Subject: [PATCH] feat(dashboard): surface thread sandbox ID in sidebar (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): surface thread sandbox ID in sidebar Port of upstream #1689 (feb7ac98): expose the thread's sandbox_id on the dashboard thread summary and surface it in the sidebar via a "Copy sandbox ID" action. - thread_api: add sandboxId to the thread summary, hiding the "__creating__" in-flight sentinel. - queries/types: thread the sandboxId field through AgentThread and the optimistic thread. - AgentsSidebar: replace the hover-only resolve/delete buttons and the right-click context menu with one touch-friendly kebab (⋮) menu that works on both pointer and touch, and add the Copy sandbox ID item. - vite: disable the PWA service worker in dev (it precaches assets and defeats HMR). - tests: unit test for the summary field + e2e spec in the real-backend harness. Closes #140. Part of #134. * chore(triage): mark feb7ac98 (#1689) landed Ported to dev via feat/thread-sandbox-id-sidebar (#140). --- agent/dashboard/thread_api.py | 9 ++ docs/upstream-sync/triage.jsonl | 2 +- docs/upstream-sync/triage.md | 2 +- tests/e2e/tests/sandbox_id.spec.ts | 115 ++++++++++++++ tests/test_dashboard_thread_api.py | 12 ++ ui/src/components/agents/AgentsSidebar.tsx | 175 +++++++++++---------- ui/src/lib/agents/queries.ts | 1 + ui/src/lib/agents/types.ts | 1 + ui/vite.config.ts | 4 +- 9 files changed, 232 insertions(+), 89 deletions(-) create mode 100644 tests/e2e/tests/sandbox_id.spec.ts diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 6648514a..5bb69e42 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -374,6 +374,14 @@ def _thread_summary( thread_id = thread.get("thread_id") or thread.get("id") trace_url = get_langsmith_trace_url(thread_id) if isinstance(thread_id, str) else None + raw_sandbox_id = metadata.get("sandbox_id") + # "__creating__" is the in-flight sentinel written before the real id lands. + sandbox_id = ( + raw_sandbox_id + if isinstance(raw_sandbox_id, str) and raw_sandbox_id and raw_sandbox_id != "__creating__" + else None + ) + summary: dict[str, Any] = { "id": thread_id, "title": title, @@ -402,6 +410,7 @@ def _thread_summary( "updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(), "isOwner": (_user_owns_thread(metadata, owner_login, owner_email) if owner_login else True), "traceUrl": trace_url, + "sandboxId": sandbox_id, } if isinstance(pr_number, int) and isinstance(pr_url, str): summary["pr"] = { diff --git a/docs/upstream-sync/triage.jsonl b/docs/upstream-sync/triage.jsonl index ff391952..3f7d848e 100644 --- a/docs/upstream-sync/triage.jsonl +++ b/docs/upstream-sync/triage.jsonl @@ -74,7 +74,7 @@ {"sha": "bb104d93", "pr": 1679, "subject": "fix: submit plan comments with cmd enter (#1679)", "disposition": "landed", "reason": "applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"} {"sha": "304032fa", "pr": 1680, "subject": "chore: clarify question answering prompt (#1680)", "disposition": "deferred", "reason": "reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt", "branch": "prompt-tweaks", "local_sha": null, "updated": "2026-07-08T20:14:42Z"} {"sha": "5003c953", "pr": 1683, "subject": "feat: open Linear-triggered PRs as the triggering user (#1683)", "disposition": "deferred", "reason": "FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py", "branch": "linear-pr-as-user", "local_sha": null, "updated": "2026-07-08T20:14:42Z"} -{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "deferred", "reason": "applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-08T23:17:35Z"} +{"sha": "feb7ac98", "pr": 1689, "subject": "feat(web): surface thread sandbox ID with touch-friendly menu (#1689)", "disposition": "landed", "reason": "Ported to dev via feat/thread-sandbox-id-sidebar.", "branch": "dashboard-ui", "local_sha": null, "updated": "2026-07-10T16:48:54Z"} {"sha": "7f7af715", "pr": 1684, "subject": "feat: auto-load scoped AGENTS on reads (#1684)", "disposition": "landed", "reason": "ported in #129 (SubdirAgentsReadMiddleware)", "branch": "subdir-agents", "local_sha": null, "updated": "2026-07-08T22:58:22Z"} {"sha": "88b62322", "pr": 1685, "subject": "feat: add platform issue reporting tool (#1685)", "disposition": "landed", "reason": "ported in #129 (report_platform_issue tool)", "branch": "small-tools", "local_sha": null, "updated": "2026-07-08T22:58:22Z"} {"sha": "90cb6caa", "pr": 1681, "subject": "feat: terse Slack replies, share long content via plan-review page (#1681)", "disposition": "landed", "reason": "terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"} diff --git a/docs/upstream-sync/triage.md b/docs/upstream-sync/triage.md index 354566b4..bc053790 100644 --- a/docs/upstream-sync/triage.md +++ b/docs/upstream-sync/triage.md @@ -55,6 +55,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only. | plan-approval | | `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval | | `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval | +| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Landed | Ported to dev via feat/thread-sandbox-id-sidebar. | dashboard-ui | | `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents | | `88b62322` | #1685 | feat: add platform issue reporting tool (#1685) | Landed | ported in #129 (report_platform_issue tool) | small-tools | | `90cb6caa` | #1681 | feat: terse Slack replies, share long content via plan-review page (#1681) | Landed | terse Slack + long-content-via-plan-page; conflicts w/ fork prompt + diverged plan stack | plan-approval | @@ -91,7 +92,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro | `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Deferred | FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate | fable-admin-toggle | | `304032fa` | #1680 | chore: clarify question answering prompt (#1680) | Deferred | reword Slack info-only answer guidance; conflicts w/ fork's customized Slack prompt | prompt-tweaks | | `5003c953` | #1683 | feat: open Linear-triggered PRs as the triggering user (#1683) | Deferred | FLAG-HUMAN: adds linear to resolve_github_token per-user OAuth branch (auth surface); depends on #1626 linear.py | linear-pr-as-user | -| `feb7ac98` | #1689 | feat(web): surface thread sandbox ID with touch-friendly menu (#1689) | Deferred | applies clean but frontend<->backend contract (thread_api->queries->types->sidebar); needs UI build + e2e validation — separate PR | dashboard-ui | | `f53caff1` | #1701 | fix: fall back to core GitHub App scope when optional grants missing (#1701) | Deferred | FLAG-HUMAN: GitHub-App permission-ladder degrade (auth surface); heavy conflict on diverged github_app.py/_resolve_proxy_token | github-app-scope | | `22e024cb` | #1704 | fix: link issue PRs and prompt repo conventions (#1704) | Deferred | issue/PR linking + repo-convention prompt; clean but prompt-conflict risk vs #113 | webhook-issue-linking | | `27b0ddeb` | #1708 | feat: add GPT-5.6 OpenAI models (#1708) | Deferred | FLAG-HUMAN: adds OpenAI GPT-5.6 to the model picker; fork's picker is Bedrock/Fireworks-only — needs a product decision before adopting OpenAI models. Gateway (#155) can route OpenAI if adopted. | model-picker | diff --git a/tests/e2e/tests/sandbox_id.spec.ts b/tests/e2e/tests/sandbox_id.spec.ts new file mode 100644 index 00000000..8d21f7a2 --- /dev/null +++ b/tests/e2e/tests/sandbox_id.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, type Locator, type Page } from "@playwright/test"; + +// Exercises the sidebar's "Copy sandbox ID" action against the REAL dashboard +// UI and a REAL (local-provider) sandbox: the Slack flow runs the agent, which +// 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" }; + +async function loginAs(page: Page, user: { login: string; email: string }) { + const res = await page.request.post("/control/login", { data: user }); + expect(res.ok()).toBeTruthy(); +} + +// Drive the Slack flow so the real agent creates a thread + sandbox, then follow +// the bot's "Open in Web" link. Returns the created thread id. +async function createThreadWithSandbox(page: Page): Promise { + await page.goto("/mock/slack"); + await page.locator("#reset").click(); + await expect(page.locator("#thread")).toContainText("No messages yet"); + await page + .locator("#text") + .fill("<@U0BOT> please add a greet() helper and open a PR"); + await page.locator("#send").click(); + await expect( + page.locator(".msg.bot").filter({ hasText: "Add greet() helper" }), + ).toBeVisible(); + + const webLink = page.locator('.msg.bot a[href*="/agents/"]').first(); + await expect(webLink).toBeVisible(); + await webLink.click(); + await expect(page).toHaveURL(/\/agents\//); + + const id = new URL(page.url()).pathname.split("/").filter(Boolean).pop(); + expect(id).toBeTruthy(); + return id as string; +} + +const copyItem = (page: Page) => + page.getByRole("menuitem", { name: "Copy sandbox ID" }); + +// The kebab sits beside the row Link (not inside the anchor), so reach it via +// their shared wrapper — the Link's parent. +const kebabFor = (row: Locator) => + row.locator("..").getByRole("button", { name: "Thread actions" }); + +test.describe("thread sandbox id (real dashboard UI)", () => { + test("desktop: kebab menu copies the real sandbox id", async ({ + page, + baseURL, + }) => { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: baseURL, + }); + await loginAs(page, SAME_USER); + const threadId = await createThreadWithSandbox(page); + + const row = page.locator(`a[href$="/agents/${threadId}"]`).first(); + await expect(row).toBeVisible(); + // The kebab is revealed on hover on pointer devices. + await row.hover(); + await kebabFor(row).click(); + + await expect(copyItem(page)).toBeEnabled(); + await copyItem(page).click(); + + const clip = await page.evaluate(() => navigator.clipboard.readText()); + expect(clip.length).toBeGreaterThan(0); + }); + + test("iPad: kebab copies the sandbox id without navigating", async ({ + browser, + baseURL, + }) => { + // iPad-class device: Chromium in mobile mode reports (hover: none), which + // gates the touch-only kebab. 834px is wider than the 767px mobile + // breakpoint, so the sidebar renders inline. + const context = await browser.newContext({ + baseURL, + viewport: { width: 834, height: 1112 }, + isMobile: true, + hasTouch: true, + }); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { + origin: baseURL, + }); + const page = await context.newPage(); + await loginAs(page, SAME_USER); + + // Two threads: we sit on B's page and act on A's sidebar row, so a stray + // Link navigation would be observable as a URL change to A. + const threadA = await createThreadWithSandbox(page); + const threadB = await createThreadWithSandbox(page); + await expect(page).toHaveURL(new RegExp(`/agents/${threadB}$`)); + + const rowA = page.locator(`a[href$="/agents/${threadA}"]`).first(); + await expect(rowA).toBeVisible(); + + const kebab = kebabFor(rowA); + await expect(kebab).toBeVisible(); + await kebab.tap(); + + await expect(copyItem(page)).toBeEnabled(); + await copyItem(page).tap(); + + const clip = await page.evaluate(() => navigator.clipboard.readText()); + expect(clip.length).toBeGreaterThan(0); + + // Tapping the kebab must open the menu, not follow A's Link — we stay on B. + await expect(page).toHaveURL(new RegExp(`/agents/${threadB}$`)); + + await context.close(); + }); +}); diff --git a/tests/test_dashboard_thread_api.py b/tests/test_dashboard_thread_api.py index 20c49b99..856856f8 100644 --- a/tests/test_dashboard_thread_api.py +++ b/tests/test_dashboard_thread_api.py @@ -314,6 +314,18 @@ def test_thread_summary_omits_pr_when_no_pr_metadata() -> None: assert "diffStats" not in summary +def test_thread_summary_exposes_sandbox_id() -> None: + summary = thread_api._thread_summary(_thread_with_metadata({"sandbox_id": "sb-abc123"})) + + assert summary["sandboxId"] == "sb-abc123" + + +def test_thread_summary_hides_creating_sandbox_sentinel() -> None: + summary = thread_api._thread_summary(_thread_with_metadata({"sandbox_id": "__creating__"})) + + assert summary["sandboxId"] is None + + async def test_recovery_patch_requires_thread_owner(monkeypatch) -> None: class FakeThreads: async def get(self, thread_id: str) -> dict[str, object]: diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index 1ba8a3dc..07f1b749 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -1,4 +1,4 @@ -import { ContextMenu } from "@base-ui/react/context-menu" +import { Menu } from "@base-ui/react/menu" import { Dialog } from "@base-ui/react/dialog" import { Link } from "@tanstack/react-router" import { @@ -10,13 +10,14 @@ import { ChatCircleIcon, CheckCircleIcon, CircleNotchIcon, + CopyIcon, + DotsThreeVerticalIcon, GitMergeIcon, GitPullRequestIcon, LightningIcon, PlusIcon, TrashIcon, TreeStructureIcon, - XIcon, } from "@phosphor-icons/react" import { IoLogoGithub, IoLogoSlack } from "react-icons/io5" import { SiLinear } from "react-icons/si" @@ -360,9 +361,9 @@ function ThreadRow({ const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id - const onDelete = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const onDelete = (e?: React.MouseEvent) => { + e?.preventDefault() + e?.stopPropagation() if (isDeleting) return setDeleteOpen(true) } @@ -396,25 +397,25 @@ function ThreadRow({ window.open(thread.traceUrl, "_blank", "noopener,noreferrer") } + const copySandboxId = () => { + if (!thread.sandboxId) return + void navigator.clipboard.writeText(thread.sandboxId) + } + return ( <> - - - } +
+ {thread.status === "running" ? ( )} - {!isReadOnly && ( - - )} - {!isReadOnly && ( - - )} - - - - - + {/* One actions menu for every input: revealed on hover, kept while + open, and always shown on devices that can't hover (touch). It sits + outside the Link so opening it never navigates the row. */} + + - - Open trace - - {!isReadOnly && ( - 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 ? ( - - ) : ( - - )} - {isResolved ? "Unresolve thread" : "Resolve thread"} - - )} - {!isReadOnly && ( - + Open trace + + - - Delete thread - - )} - - - - + + Copy sandbox ID + + {!isReadOnly && ( + 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-muted data-disabled:pointer-events-none data-disabled:opacity-50" + > + {isResolved ? ( + + ) : ( + + )} + {isResolved ? "Unresolve thread" : "Resolve thread"} + + )} + {!isReadOnly && ( + onDelete()} + disabled={isDeleting} + className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-destructive outline-none select-none data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50" + > + + Delete thread + + )} + + + + +
diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts index d9fe82c0..02b426f4 100644 --- a/ui/src/lib/agents/queries.ts +++ b/ui/src/lib/agents/queries.ts @@ -259,6 +259,7 @@ export function optimisticThread( createdAt: now, updatedAt: now, traceUrl: null, + sandboxId: null, messages: message.chunks.length > 0 ? [message] : [], } } diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index 7e74164b..1abac667 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -203,6 +203,7 @@ export interface AgentThread { createdAt: number updatedAt: number traceUrl?: string | null + sandboxId?: string | null messages: Array queuedMessages?: Array pr?: { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 73adc0f6..f320b2e2 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -177,7 +177,9 @@ const config = defineConfig({ registerType: "prompt", outDir: ".output/public", devOptions: { - enabled: true, + // Off in dev: the service worker precaches assets and defeats HMR (and + // a stale registration lingers per-origin). Dev is HMR-only. + enabled: false, }, integration: { closeBundleOrder: "pre",