open-swe/ui/src/components/agents/AgentsSidebar.tsx
Johannes du Plessis 8c7b789122
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>
2026-06-10 15:40:43 -07:00

375 lines
13 KiB
TypeScript

import { ContextMenu } from "@base-ui/react/context-menu"
import { Dialog } from "@base-ui/react/dialog"
import { Link } from "@tanstack/react-router"
import {
CalendarBlankIcon,
CaretDownIcon,
CaretRightIcon,
ChartLineUpIcon,
ChatCircleIcon,
CircleNotchIcon,
LightningIcon,
PlusIcon,
TrashIcon,
TreeStructureIcon,
XIcon,
} from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
import { SiLinear } from "react-icons/si"
import { useState } from "react"
import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import { Button } from "@/components/ui/button"
import {
SidebarCollapseButton,
SidebarFrame,
useSidebarLayout,
} from "@/components/sidebar-layout"
import { groupThreads } from "@/lib/agents/api"
import {
useAgentThreads,
useDeleteAgentThread,
usePrefetchAgentThreadDetails,
} from "@/lib/agents/queries"
import { cn } from "@/lib/utils"
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" },
github: { icon: IoLogoGithub, label: "Triggered from GitHub" },
slack: { icon: IoLogoSlack, label: "Triggered from Slack" },
linear: { icon: SiLinear, label: "Triggered from Linear" },
schedule: { icon: CalendarBlankIcon, label: "Triggered from a schedule" },
}
interface AgentsSidebarProps {
user: SessionUser
activeThreadId?: string
}
const NAV = [
{ to: "/agents/automations", label: "Automations", icon: LightningIcon },
{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon },
] as const
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
const threadsQuery = useAgentThreads()
const threads = threadsQuery.data ?? []
usePrefetchAgentThreadDetails(threads, activeThreadId)
const groups = groupThreads(threads)
const layout = useSidebarLayout()
return (
<SidebarFrame
{...layout}
className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]"
>
<div className="flex items-center justify-between px-4 pt-5 pb-4">
<Link
to="/my-settings"
className="flex items-center gap-2 font-heading text-sm font-medium tracking-tight text-[var(--ui-text)]"
>
<img src="/logo-mark.png" alt="" className="size-5" />
open-swe
</Link>
<SidebarCollapseButton onToggle={layout.toggle} />
</div>
<div className="px-2 pb-1">
<Link
to="/agents"
onClick={layout.closeOnMobile}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-[var(--ui-text)] transition-colors hover:bg-[var(--ui-sidebar-hover)]"
>
<PlusIcon className="size-4" />
New Agent
</Link>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-4">
{NAV.map((item) => {
const Icon = item.icon
return (
<Link
key={item.to}
to={item.to}
onClick={layout.closeOnMobile}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
activeProps={{
className:
"bg-[var(--ui-sidebar-hover)] !text-[var(--ui-text)] font-medium",
}}
>
<Icon className="size-4" />
{item.label}
</Link>
)
})}
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup
label="Today"
threads={groups.today}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
<ThreadGroup
label="Last 7 days"
threads={groups.last7}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Last 30 days"
threads={groups.last30}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Older"
threads={groups.older}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
</div>
<div className="p-2">
<SidebarUserMenu user={user} showSettingsLink />
</div>
</SidebarFrame>
)
}
function ThreadGroup({
label,
threads,
activeThreadId,
onNavigate,
defaultCollapsed = false,
}: {
label: string
threads: Array<AgentThread>
activeThreadId?: string
onNavigate?: () => void
defaultCollapsed?: boolean
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
if (threads.length === 0) return null
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
return (
<div className="mb-3">
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase transition-colors hover:text-[var(--ui-text-muted)]"
aria-expanded={!collapsed}
>
<ToggleIcon className="size-3" />
<span className="min-w-0 flex-1 truncate">{label}</span>
<span>{threads.length}</span>
</button>
{!collapsed &&
threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))}
</div>
)
}
function ThreadRow({
thread,
isActive,
onNavigate,
}: {
thread: AgentThread
isActive: boolean
onNavigate?: () => void
}) {
const deleteThread = useDeleteAgentThread()
const [deleteOpen, setDeleteOpen] = useState(false)
const badge =
thread.diffStats && thread.diffStats.additions > 0
? `+${thread.diffStats.additions}`
: null
const isDeleting =
deleteThread.isPending && deleteThread.variables === thread.id
const onDelete = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (isDeleting) return
setDeleteOpen(true)
}
const onConfirmDelete = () => {
if (isDeleting) return
deleteThread.mutate(thread.id, {
onSuccess: () => setDeleteOpen(false),
})
}
const source =
thread.source && thread.source !== "dashboard"
? SOURCE_META[thread.source]
: null
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 (
<>
<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"
)}
/>
}
>
{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" />
<Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-popover p-6 text-popover-foreground shadow-md ring-1 ring-foreground/10 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">
<div className="flex flex-col gap-4">
<Dialog.Title className="text-base font-semibold">
Delete thread
</Dialog.Title>
<Dialog.Description className="text-sm text-muted-foreground">
Delete "{thread.title}"? This cannot be undone.
</Dialog.Description>
<div className="mt-2 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</>
)
}
export function AgentsShell({
user,
activeThreadId,
children,
}: {
user: SessionUser
activeThreadId?: string
children: React.ReactNode
}) {
return (
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
<div className="flex min-w-0 flex-1">{children}</div>
</div>
)
}