Compare commits

...

5 commits

Author SHA1 Message Date
Adam Moussa
d306a5a35d
Merge branch 'dev' into feat/readd-fable5-bedrock 2026-07-10 13:59:43 -04:00
733e3fde4a
fix(open-swe): correct Fable copy to describe provider data sharing, not ZDR
The ported admin toggle description and code comments described Fable 5 as
incompatible with Zero Data Retention. That is backwards: Fable 5 requires
the account to opt into Bedrock provider_data_share — prompts/completions are
retained and shared with Anthropic (up to 30 days, incl. human review). The
old UI copy would lead an admin to believe the opposite of what enabling the
toggle does. Reword the toggle description and the gate_fable_model /
team_settings comments accordingly. Still off by default. Refs #171.
2026-07-10 13:52:10 -04:00
Adam Moussa
bc59c05642
feat(dashboard): surface thread sandbox ID in sidebar (#169)
* 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).
2026-07-10 13:17:03 -04:00
Adam Moussa
2211c1f2c9
chore: update pr title lint to include dashboard and triage scopes (#170)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
2026-07-10 13:05:51 -04:00
Adam Moussa
7432d39580
chore(triage): mark upstream #1667 (fireworks-ai a86) landed as superseded (#167)
dev already pins fireworks-ai>=1.2.0a88 (uv.lock resolves a88) via #158
(efeb6b12), which jumped a85 -> a88 and exceeds the a86 that upstream
#1667 (fbc6de85) bumps to. Porting it would downgrade the constraint, so
mark the row landed/superseded rather than picking it.

Closes #141. Part of #134.
2026-07-10 13:00:12 -04:00
13 changed files with 245 additions and 97 deletions

View file

@ -41,6 +41,8 @@ jobs:
deploy
ci
infra
dashboard
triage
requireScope: false
ignoreLabels: |
ignore-lint-pr-title

View file

@ -101,10 +101,13 @@ def fable_disabled_fallback(effort: object = None) -> tuple[str, str]:
def gate_fable_model(
model_id: str, effort: str | None, *, fable_enabled: bool
) -> tuple[str, str | None]:
"""ZDR guard: if Fable is disabled but a Fable id was resolved, swap in a
safe non-Fable model. Non-Fable selections pass through unchanged. Applied
at every model-construction entrypoint so a disabled Fable model can never
reach ``make_model``, no matter which layer selected it."""
"""Provider-data-share gate: if Fable is disabled but a Fable id was
resolved, swap in a safe non-Fable model. Fable requires the account to opt
into Bedrock ``provider_data_share`` (prompts/completions shared with the
provider), so it is gated off by default. Non-Fable selections pass through
unchanged. Applied at every model-construction entrypoint so a disabled
Fable model can never reach ``make_model``, no matter which layer selected
it."""
if not fable_enabled and isinstance(model_id, str) and model_id in FABLE_MODEL_IDS:
return fable_disabled_fallback(effort)
return model_id, effort

View file

@ -135,7 +135,7 @@ class TeamSettingsUpdate(BaseModel):
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
)
if not self.fable_enabled:
# Disabling Fable is the ZDR kill switch and must always succeed: rather
# Disabling Fable is the provider-data-share kill switch and must always succeed: rather
# than reject a payload that still carries a Fable default, swap each
# Fable default to its safe non-Fable fallback (mirrors the runtime
# gate_fable_model guard) so the stored record can't advertise Fable.

View file

@ -378,6 +378,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,
@ -406,6 +414,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"] = {

View file

@ -60,7 +60,7 @@
{"sha": "ab4eea4b", "pr": 1663, "subject": "chore(deps): bump the major group across 1 directory with 3 updates (#1663)", "disposition": "wont-merge", "reason": "already in dev — CI already on checkout@v7/setup-node@v6/upload-artifact@v7", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
{"sha": "2bf207fb", "pr": 1664, "subject": "chore(deps): bump python in the minor-and-patch group (#1664)", "disposition": "wont-merge", "reason": "already in dev — Dockerfile already at python 3.14.6-slim-trixie", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
{"sha": "13b40113", "pr": 1666, "subject": "chore(deps): bump cryptography from 48.0.1 to 49.0.0 in the major group (#1666)", "disposition": "wont-merge", "reason": "already in dev — same 48->49 cryptography bump dev did via #105", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
{"sha": "fbc6de85", "pr": 1667, "subject": "chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667)", "disposition": "deferred", "reason": "conflicts on pick — dev diverged to fireworks-ai a85; a86 still wanted, needs manual bump + uv lock", "branch": "deps", "local_sha": null, "updated": "2026-07-08T23:17:35Z"}
{"sha": "fbc6de85", "pr": 1667, "subject": "chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667)", "disposition": "landed", "reason": "Superseded by #158 (fireworks-ai a85 -> a88, efeb6b12); dev already exceeds a86. No port needed.", "branch": "deps", "local_sha": null, "updated": "2026-07-10T16:38:22Z"}
{"sha": "290d0fee", "pr": 1669, "subject": "chore(deps): update langgraph-cli[inmem] requirement (#1669)", "disposition": "wont-merge", "reason": "already in dev — langgraph-cli[inmem] at 0.4.30", "branch": "", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
{"sha": "c9f6dd86", "pr": 1694, "subject": "fix: fall back on model stream transport errors (#1694)", "disposition": "landed", "reason": "adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-09T18:59:20Z"}
{"sha": "c9a9a7cd", "pr": 1695, "subject": "fix: retry model fallback exhaustion (#1695)", "disposition": "landed", "reason": "alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path", "branch": "model-fallback", "local_sha": null, "updated": "2026-07-09T18:59:20Z"}
@ -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"}

View file

@ -46,6 +46,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `4f913198` | #1647 | widen split review diffs | Landed | already present in dev; empty pick confirmed in #127 | reviewer-misc |
| `20f63e8c` | #1646 | install missing deps before verification | Landed | already in dev via #81 (upstream-sync); ledger was stale | prompt-tweaks |
| `2f237b53` | #1626 | fall back to vision model for image threads | Landed | ported (adapted to Bedrock/Fireworks vision) in #128 | gateway-routing |
| `fbc6de85` | #1667 | chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667) | Landed | Superseded by #158 (fireworks-ai a85 -> a88, efeb6b12); dev already exceeds a86. No port needed. | deps |
| `c9f6dd86` | #1694 | fix: fall back on model stream transport errors (#1694) | Landed | adds httpx.TransportError to transient set; small conflict w/ dev's diverged Bedrock fallback | model-fallback |
| `c9a9a7cd` | #1695 | fix: retry model fallback exhaustion (#1695) | Landed | alternating retry+backoff rewrite; reconcile by hand w/ dev's Bedrock + sync wrap path | model-fallback |
| `e5dbc788` | #1696 | fix: Harden durable agent runs (#1696) | Landed | large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion | durable-dispatch |
@ -55,6 +56,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `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 |
| `c75cbb1f` | #1677 | feat: re-add Fable 5 with an admin toggle to disable it (#1677) | Landed | Ported + Bedrock-converted onto dev via feat/readd-fable5-bedrock; anthropic: Fable ID mapped to bedrock_converse:us.anthropic.claude-fable-5. | fable-admin-toggle |
| `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 |
@ -87,11 +89,9 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
| `c0a7e93e` | #1691 | fix: reconnect sandbox backend on resumed runs (#1691) | Deferred | reconnect proxy (has_backend/reconnect); assumes async create_sandbox | sandbox-refactor |
| `4f8bc2dd` | #1692 | refactor: simplify open-swe agent sandbox lifecycle (#1692) | Deferred | FLAG-HUMAN: structural rewrite of ensure_sandbox_for_thread (drops __creating__ 4-case sentinel) | sandbox-refactor |
| `48217b68` | #1489 | feat(open-swe): add E2B sandbox provider (#1489) | Deferred | additive E2B provider; separable but ships on the async sandbox.py base | sandbox-refactor |
| `fbc6de85` | #1667 | chore(deps): bump fireworks-ai from 1.2.0a75 to 1.2.0a86 (#1667) | Deferred | conflicts on pick — dev diverged to fireworks-ai a85; a86 still wanted, needs manual bump + uv lock | deps |
| `67abf5b0` | #1659 | fix: surface attributed PR creation failures (#1659) | Deferred | PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py | pr-attribution |
| `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 |

View file

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

View file

@ -317,6 +317,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]:

View file

@ -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 (
<>
<ContextMenu.Root>
<ContextMenu.Trigger
render={
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn(
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 transition-colors",
compact ? "h-7 gap-1.5" : "h-8",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50"
)}
/>
}
<div className={cn("group relative mb-0.5", isDeleting && "opacity-50")}>
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 transition-colors group-hover:pr-8 [@media(hover:none)]:pr-8",
compact ? "h-7 gap-1.5" : "h-8",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] group-hover:bg-[var(--ui-sidebar-hover)]"
)}
>
{thread.status === "running" ? (
<CircleNotchIcon
@ -461,73 +462,75 @@ function ThreadRow({
{badge}
</span>
)}
{!isReadOnly && (
<button
type="button"
aria-label={isResolved ? "Unresolve thread" : "Resolve thread"}
title={isResolved ? "Unresolve thread" : "Resolve thread"}
onClick={onToggleResolved}
disabled={resolveThread.isPending}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{isResolved ? (
<ArrowCounterClockwiseIcon className="size-3" weight="bold" />
) : (
<CheckCircleIcon className="size-3" weight="bold" />
)}
</button>
)}
{!isReadOnly && (
<button
type="button"
aria-label="Delete thread"
onClick={onDelete}
disabled={isDeleting}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<XIcon className="size-3" weight="bold" />
</button>
)}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Positioner className="z-50 outline-none">
<ContextMenu.Popup className="min-w-[10rem] overflow-hidden rounded-md border border-[var(--ui-border)] bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
<ContextMenu.Item
disabled={!thread.traceUrl}
onClick={openTrace}
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"
</Link>
{/* 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. */}
<Menu.Root>
<Menu.Trigger
render={
<button
type="button"
aria-label="Thread actions"
className="absolute top-1/2 right-1 hidden size-5 -translate-y-1/2 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] data-popup-open:flex [@media(hover:none)]:flex"
>
<TreeStructureIcon className="size-3.5" />
Open trace
</ContextMenu.Item>
{!isReadOnly && (
<ContextMenu.Item
onClick={() => 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"
<DotsThreeVerticalIcon className="size-4" weight="bold" />
</button>
}
/>
<Menu.Portal>
<Menu.Positioner
align="end"
sideOffset={4}
className="z-50 outline-none"
>
<Menu.Popup className="min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
<Menu.Item
disabled={!thread.traceUrl}
onClick={openTrace}
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 ? (
<ArrowCounterClockwiseIcon className="size-3.5" />
) : (
<CheckCircleIcon className="size-3.5" />
)}
{isResolved ? "Unresolve thread" : "Resolve thread"}
</ContextMenu.Item>
)}
{!isReadOnly && (
<ContextMenu.Item
onClick={onDelete}
disabled={isDeleting}
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--ui-danger)] outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
<TreeStructureIcon className="size-3.5" />
Open trace
</Menu.Item>
<Menu.Item
disabled={!thread.sandboxId}
onClick={copySandboxId}
title={thread.sandboxId ?? undefined}
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"
>
<TrashIcon className="size-3.5" />
Delete thread
</ContextMenu.Item>
)}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
<CopyIcon className="size-3.5" />
Copy sandbox ID
</Menu.Item>
{!isReadOnly && (
<Menu.Item
onClick={() => 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 ? (
<ArrowCounterClockwiseIcon className="size-3.5" />
) : (
<CheckCircleIcon className="size-3.5" />
)}
{isResolved ? "Unresolve thread" : "Resolve thread"}
</Menu.Item>
)}
{!isReadOnly && (
<Menu.Item
onClick={() => 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"
>
<TrashIcon className="size-3.5" />
Delete thread
</Menu.Item>
)}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
<Dialog.Root open={deleteOpen} onOpenChange={setDeleteOpen}>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />

View file

@ -259,6 +259,7 @@ export function optimisticThread(
createdAt: now,
updatedAt: now,
traceUrl: null,
sandboxId: null,
messages: message.chunks.length > 0 ? [message] : [],
}
}

View file

@ -203,6 +203,7 @@ export interface AgentThread {
createdAt: number
updatedAt: number
traceUrl?: string | null
sandboxId?: string | null
messages: Array<Message>
queuedMessages?: Array<QueuedThreadMessage>
pr?: {

View file

@ -713,7 +713,7 @@ function FableSection() {
return (
<SettingsSection
title="Fable"
description="Claude Fable 5 runs safety classifiers that inspect and may retain requests, so it is not compatible with Zero Data Retention (ZDR). Off by default; enable only if your workspace does not require ZDR."
description="Claude Fable 5 requires opting into Bedrock provider data sharing: prompts and completions are retained and shared with Anthropic (up to 30 days, including human review). Off by default; enable only if your workspace accepts provider data sharing for this model."
>
<div className="divide-y divide-border">
<SettingsRow

View file

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