feat: add right-click open trace context menu for threads (#1490)

* feat: add right-click open trace context menu for threads

Add a context menu to sidebar thread rows exposing an Open trace action
that links to the LangSmith thread trace, surfaced via a new traceUrl
field on the thread summary.

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

* chore: drop stray yarn artifacts from PR

Remove accidentally committed ui/.yarnrc.yml and ui/.yarn/install-state.gz
that broke immutable installs against the v1 lockfile, and gitignore yarn
artifacts to prevent recurrence.

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

* chore: reuse thread_id var, drop redundant danger-color fallback

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-10 15:40:43 -07:00 • committed by GitHub
parent d0db48eacd
commit 8c7b789122
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 113 additions and 54 deletions

View file

@ -17,6 +17,7 @@ from langchain_core.messages.content import create_image_block
from langgraph_sdk.errors import InternalServerError
from pydantic import BaseModel, ConfigDict, Field
from ..utils.langsmith import get_langsmith_trace_url
from ..utils.thread_ops import is_thread_active, langgraph_client, queue_message_for_thread
from .agent_overrides import normalize_profile_overrides
from .message_adapter import state_messages_to_ui
@ -272,8 +273,11 @@ def _thread_summary(
pr_title = metadata.get("pr_title")
pr_state = metadata.get("pr_state")
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
summary: dict[str, Any] = {
"id": thread.get("thread_id") or thread.get("id"),
"id": thread_id,
"title": title,
"repo": name,
"repoFullName": full_name,
@ -290,6 +294,7 @@ def _thread_summary(
),
"createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(),
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
"traceUrl": trace_url,
}
if isinstance(pr_number, int) and isinstance(pr_url, str):
summary["pr"] = {

View file

@ -42,6 +42,18 @@ def test_thread_summary_keeps_repo_when_present() -> None:
assert summary["repoFullName"] == "octo/repo"
def test_thread_summary_includes_trace_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
thread_api,
"get_langsmith_trace_url",
lambda thread_id: f"https://smith.example/t/{thread_id}",
)
summary = thread_api._thread_summary(
{"thread_id": "t3", "metadata": {"source": "dashboard", "title": "traced run"}}
)
assert summary["traceUrl"] == "https://smith.example/t/t3"
class _FakeThreadsClient:
async def create(
self, *, thread_id: str, metadata: dict[str, Any], if_exists: str

3
ui/.gitignore vendored
View file

@ -13,3 +13,6 @@ count.txt
dev-dist
__unconfig*
todos.json
.yarn
.yarnrc.yml
.pnp.*

View file

@ -1,3 +1,4 @@
import { ContextMenu } from "@base-ui/react/context-menu"
import { Dialog } from "@base-ui/react/dialog"
import { Link } from "@tanstack/react-router"
import {
@ -9,6 +10,8 @@ import {
CircleNotchIcon,
LightningIcon,
PlusIcon,
TrashIcon,
TreeStructureIcon,
XIcon,
} from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
@ -226,62 +229,97 @@ function ThreadRow({
const SourceIcon = source?.icon
const showFinishedIndicator = thread.status === "finished" && !thread.viewed
const openTrace = () => {
if (!thread.traceUrl) return
window.open(thread.traceUrl, "_blank", "noopener,noreferrer")
}
return (
<>
<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 py-1.5 transition-colors",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50"
)}
>
{thread.status === "running" ? (
<CircleNotchIcon
className="size-3 shrink-0 animate-spin text-[var(--ui-accent)]"
aria-label="Thread running"
/>
) : (
<span
className={cn(
"size-2 shrink-0 rounded-full",
showFinishedIndicator
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]"
)}
aria-label={
showFinishedIndicator ? "Thread finished" : "Thread viewed"
}
/>
)}
{source && SourceIcon && (
<SourceIcon
className="size-3.5 shrink-0 text-[var(--ui-text-dim)]"
aria-label={source.label}
>
<title>{source.label}</title>
</SourceIcon>
)}
<span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
{badge && (
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-dim)] group-hover:hidden">
{badge}
</span>
)}
<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)]"
<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 py-1.5 transition-colors",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50"
)}
/>
}
>
<XIcon className="size-3" weight="bold" />
</button>
</Link>
{thread.status === "running" ? (
<CircleNotchIcon
className="size-3 shrink-0 animate-spin text-[var(--ui-accent)]"
aria-label="Thread running"
/>
) : (
<span
className={cn(
"size-2 shrink-0 rounded-full",
showFinishedIndicator
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]"
)}
aria-label={
showFinishedIndicator ? "Thread finished" : "Thread viewed"
}
/>
)}
{source && SourceIcon && (
<SourceIcon
className="size-3.5 shrink-0 text-[var(--ui-text-dim)]"
aria-label={source.label}
>
<title>{source.label}</title>
</SourceIcon>
)}
<span className="min-w-0 flex-1 truncate text-xs">
{thread.title}
</span>
{badge && (
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-dim)] group-hover:hidden">
{badge}
</span>
)}
<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"
>
<TreeStructureIcon className="size-3.5" />
Open trace
</ContextMenu.Item>
<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"
>
<TrashIcon className="size-3.5" />
Delete thread
</ContextMenu.Item>
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
<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

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